Skip to content

Conversation

@dfsm
Copy link
Contributor

@dfsm dfsm commented Nov 29, 2025

Since Imgur and maybe even postimages are dying/dead, many smaller image hosts are being used by SA posters.

These new hosts can cause timeouts/long page load times for images, resulting in the V throbber lottie progress view to be displayed for many minutes sometimes.

This branch reduces the number of images a new page downloads to ten. This does not count Avatars or forums attachments, but rather all other third party images in post contents.
Images 11+ are lazy loaded using the browser's native loading="lazy" attribute which was supported by iOS Safari starting in 15.4.

(Previous commits on this branch used IntersectionObservers with a 600px offset so that the images were download and embedded before the user scrolls to the post. That is no longer the case for images, but it IS the approach applied to embedding Tweets.)

In addition to this, the loadingview has been enhanced to include an exit button and progress messages, on a 3 second delay. For most page loads, this will remain unseen but on longer load times the two new elements will fade into the view. This allows the user to exit out of the loading view early and begin reading the page.

Attempts to load the initial batch of images now have timeouts and retry logic.

The "Dead Tweet" badge has been adapted to now also apply to images.
Dead Image badges have a manual Retry button that users can use if the image fails to load.

These features make the loadingview exit button and progress indicator even less likely to appear, but am leaving them in as they still provide an escape hatch if needed.

A good page for testing is Page 2 of the Comics thread in PYF.

Below is the LLM generated take on the changes (expect emojis):

Lazy Loading and Enhanced Loading UX

Overview

Implements lazy loading for post content images to improve perceived performance and page load times, along with enhanced loading view feedback including progress tracking, timeout detection, and user control.

Features

1. 🚀 Lazy Loading for Images

What: Posts now load the first 10 images immediately, then defer loading of subsequent images using the browser's native lazy loading.

Why: Improves initial page load time and reduces unnecessary bandwidth usage. Users see content faster while images below the fold load on-demand.

Implementation:

  • Swift-side preprocessing sets loading="lazy" attribute on post content images 11+
  • Browser handles lazy loading natively (iOS 15.4+ support)
  • Avatars, smilies, and attachment.php images are always loaded immediately (excluded from lazy loading)

Files:


2. 💀 Dead Image Badges

What: Failed images are replaced with animated ghost badges (similar to dead tweets) that show the filename and offer retry functionality. Works for both immediately-loaded images (first 10) and lazy-loaded images (11+).

Why: Provides clear visual feedback when images fail to load (404s, broken images, etc.) and gives users the ability to retry without refreshing the entire page.

Features:

  • Animated Lottie ghost animation
  • Displays image filename
  • "Retry" button to attempt reload
  • Visual feedback during retry ("Retrying...")
  • Consistent with existing dead tweet styling
  • Works for all images: immediately-loaded and lazy-loaded

Implementation:

  • Generalized .dead-embed() mixin for reuse across tweets/images
  • Event delegation for retry clicks (prevents listener accumulation)
  • Automatic ghost animation setup via IntersectionObserver
  • Timeout detection for stalled initial image loads (3 seconds)
  • Error event listeners for lazy-loaded images (triggered after browser attempts load)

Files:


3. 📊 Image Download Progress Tracking

What: Loading view displays real-time progress of image downloads ("Downloading images: 5/10").

Why: Users understand what's happening during page load and have visibility into download progress.

Implementation:

  • JavaScript imageLoadTracker sends progress updates to Swift side
  • Swift displays progress in loading view status label
  • Tracks all immediately-loaded images: first 10 post content images, plus avatars and other non-lazy-loaded content
  • Excludes lazy-loaded images (11+), smilies, and .awful-avatar classed images
  • Loading view dismisses automatically when all tracked images complete

Files:


4. ⏏️ Enhanced Loading View

What: Loading view improvements for better UX:

  • Exit button (X) to dismiss loading early
  • Status messages showing current operation
  • 3-second delay before showing exit button (prevents accidental dismissal)

Why: Users stuck on slow loads can escape without force-quitting the app. Status messages provide transparency.

Features:

  • "Loading..." → "Fetching posts from server..." → "Generating page..." → "Rendering page..." → "Downloading images: X/Y"
  • Exit button appears after 3 seconds with status label
  • Positioned for visual balance (40px above center)
  • Only DefaultLoadingView supports this (themed views unchanged)

Implementation:

  • Timer-based visibility delay with proper cleanup
  • onDismiss callback for exit button tap
  • updateStatus() virtual method for subclass customization
  • Auto Layout constraints for responsive positioning

Files:


Technical Details

Constants & Configuration

All timeout values, distances, and delays are defined as named constants with detailed documentation:

  • IMMEDIATELY_LOADED_IMAGE_COUNT = 10 (synced between Swift/JS)
  • statusElementsVisibilityDelay = 3.0 seconds
  • Image timeout detection: 3 seconds max wait

Memory Management

  • Proper cleanup functions for IntersectionObservers
  • Event listener removal on page navigation
  • Timer invalidation to prevent leaks
  • { once: true } for single-fire event listeners
  • Explicit interval timer cleanup

Race Condition Prevention

  • handled flags prevent duplicate event processing
  • embedTweetsInProgress flag prevents concurrent setup
  • Observer disconnection before recreation

Browser Compatibility

  • Uses native loading="lazy" attribute (iOS 15.4+ support)
  • IntersectionObserver for tweet embeds and ghost animations
  • Graceful degradation with try/catch blocks

Performance Impact

Positive:

  • Faster initial page render (fewer images block DOM)
  • Reduced bandwidth on short sessions (unused images never load)
  • Better perceived performance (content visible sooner)
  • Native browser optimization for lazy loading
  • Reduced JavaScript complexity (~215 lines removed vs custom implementation)

Considerations:

  • Progress tracking message passing to native side (minimal overhead)
  • IntersectionObserver for tweet embeds (browser-optimized)

Net Result: Significant improvement in perceived performance, especially for image-heavy threads.


Testing Recommendations

Image Lazy Loading

  1. Load long threads with 20+ images → verify first 10 load immediately, rest defer
  2. Scroll down → verify images load as they approach viewport
  3. Check that avatars/smilies always load immediately
  4. Verify no layout shifts during lazy load

Timeout & Error Handling

  1. Load page with unreachable images in first 10 → verify timeout after ~3 seconds
  2. Verify ghost badges appear for failed immediately-loaded images
  3. Scroll to lazy-loaded images that fail → verify ghost badges appear after browser attempts load
  4. Click "Retry" on dead image (both immediate and lazy) → verify reload attempt
  5. Test slow network → verify patient behavior for active downloads
  6. Verify lazy-loaded images don't show dead badges prematurely (only after browser attempts load)

Progress Tracking

  1. Load image-heavy page → verify "Downloading images: X/Y" displays
  2. Load text-only page → verify loading view dismisses immediately (no images)
  3. Verify progress count includes all immediately-loaded images (post images 1-10, avatars, etc.)

Loading View

  1. Let page load naturally → verify exit button appears after 3 seconds
  2. Tap exit button during load → verify loading view dismisses
  3. Quick loads (< 3 seconds) → verify exit button never appears
  4. Verify status messages progress through all stages

Edge Cases

  1. Pages where all images are attachment.php → verify proper handling
  2. Mixed content (images, tweets, avatars, smilies) → verify correct counting
  3. Rapid navigation between pages → verify no memory leaks or observer accumulation

Files Changed

7 files changed (+1131, -177 lines)

File Changes Description
App/Resources/RenderView.js +956, -215 Core lazy loading, timeout detection, progress tracking, ghost badges
App/Views/LoadingView.swift +133 Enhanced loading view with exit button and status
App/Views/RenderView.swift +99 Message passing for progress updates
App/Misc/HTMLRenderingHelpers.swift +54 HTML preprocessing for lazy loading
App/View Controllers/Posts/PostsPageViewController.swift +38 Progress handling and status updates
AwfulTheming/.../_dead-tweet-ghost.less +42 Dead image badge styling
App/View Controllers/Posts/PostsPageView.swift +4 Type change for LoadingView

dfsm added 10 commits November 19, 2025 20:45
- Add an X button to Loading View to allow exiting out of V spinner view early
- First attempt to add lazy loading to images
- Attempt to add timeouts and in case of failure set "Dead Image" badge similar to Dead Tweets
…. This can be done by the browser as iOS15.4+ now accepts the loading="lazy" attribute.

Tweets continue to use intersection method.
If an image fails to download during a lazyload, replace with Dead Image ghost badge.
@dfsm dfsm requested a review from nolanw November 29, 2025 05:24

// Create new image element with native browser loading
const successImg = document.createElement('img');
successImg.setAttribute('alt', '');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why clear the alt here, is it visible with a long-press or similar, and if it is user-visible is it worth preserving the original by stuffing it into a data-orig-alt attribute?

(I’m reading this on my phone and can’t see much context but I’m assuming this is the success case for lazy loading)

let config = {
root: document.body.posts,
rootMargin: `${topMarginOffset}px 0px`,
threshold: 0.000001,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth a comment here for intent, e.g. // load when first row of pixels visible ?

(This is my first time looking at IntersectionObserver so I had to check MDN and guess at the config's intent)

Copy link
Contributor

@syncsynchalt syncsynchalt Nov 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, originally meant to add this to the newly added config, did not realize this is old/moved code, not going to ask you to guess at the original author's intent. So never mind unless you know for sure.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's questionable and eyebrow raising, I probably was the original author. If not today, then years ago...

This particular snippet is not in the latest commit tho. There's similar setups to this used for the playback of the ghost lottie and also for "lazy loading" Tweets. These are better defined with descriptive names and comments in the latest commit.

e.g.

  // Set up lazy-loading IntersectionObserver for tweet embeds
  // Tweets are loaded before entering the viewport based on LAZY_LOAD_LOOKAHEAD_DISTANCE
  // Disconnect previous observer if it exists (prevents memory leak on re-render)
  if (Awful.tweetLazyLoadObserver) {
    Awful.tweetLazyLoadObserver.disconnect();
  }

  const lazyLoadConfig = {
    root: null,
    rootMargin: `${LAZY_LOAD_LOOKAHEAD_DISTANCE} 0px`,
    threshold: 0.000001,
  };

IntersectionObserver is meant to detect when html elements have crossed into the viewport. My homespun lazy load solution was to use this with an offset of 600px so that we fire the embedTweetNow javascript command for tweets a post or two in advance of them scrolling into view.

For the ghost, it's to reduce resources playing the lottie animation when the thing isn't anywhere near within view.

@syncsynchalt
Copy link
Contributor

I don't feel qualified to fully bless a PR but I like the feature and wanted to give you some eyeballs; LGTM.

@nolanw
Copy link
Member

nolanw commented Nov 29, 2025

I’m clearly the bottleneck here so please feel free to merge any pr’s that make sense. Ideally someone does some kind of review first :)

@dfsm
Copy link
Contributor Author

dfsm commented Nov 30, 2025

Thanks for the eyeballs!

I have pushed some changes and added some comments. Always happy to clarify or change/redo anything.

Copy link
Contributor

@syncsynchalt syncsynchalt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users are going to be stoked to get this (though really they won't notice it, just that paging no longer "hangs" as much).

I say if it's working in your testing, :shipit:

@dfsm
Copy link
Contributor Author

dfsm commented Nov 30, 2025

I'll do a little more testing and then merge this one in

Thanks again

dfsm added 2 commits December 15, 2025 19:29
… were (race condition between twitter.js widget being loaded and embed processing taking place).

Added the retry option to Dead Tweet badge, similar to Dead Image badge
…eout in RenderView.js). Added optional show/hide configuration option for the newly introduced LoadingView status message and exit button. This will now display for loading threads but not for loading while previewing new posts or private messages.
@dfsm dfsm merged commit c744be8 into main Dec 21, 2025
1 check failed
@dfsm dfsm deleted the lazyloadin branch December 21, 2025 01:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants