feat(ui): warm album art ahead of the scroll
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m46s
CI / e2e (pull_request) Successful in 10m13s

Scrolling the albums grid pops art in: the cards already draw the
smallest adequate tier and are already lazy, so what was left is *when*
the request happens. The grids are virtualized, so the `<img>` — and
therefore the fetch — does not exist until the virtualizer renders its
card, which is about 1000px past the viewport, or two screens on the
reference device.

The issue asks for a larger overscan and that is not available:
`_overhang` is a hard-coded `protected` field on `BaseLayout` with no
configuration surface. So the request is issued ahead of the element
instead. `utils/image-prefetch.ts` warms a bounded window either side
of the rendered range, from `rangeChanged` rather than
`visibilityChanged` — the two report different ranges, and a window
measured from what is *visible* is spent on cards that already exist.

Cover and artist URLs are served under `Cache-Control: immutable`
(content-hashed filenames), so a prefetched image is a cache hit by the
time its card is drawn. The bytes are the browser's; what this holds is
the set of URLs asked for, capped and reported to `__yjCacheStats()`.

Measured on the bulk seed (4 988 albums), ten 2 400px jumps, covers in
the viewport with `naturalWidth === 0`: 254 of 258 blank one frame
after the jump and 214 two frames after, against 117 and 77 with the
prefetch.

Closes #65
This commit is contained in:
2026-08-25 13:55:43 -04:00
parent e23e6f9a54
commit 3479ae8d39
8 changed files with 618 additions and 1 deletions
+40
View File
@@ -3403,6 +3403,46 @@ rather than searching it — the store replaces that array when its
contents change and shares the unchanged members, which is the same
signal `track-list`'s memoized caches key on.
**And the right tier arriving late still reads as no art at all**, so
the two grids ask for it before the card exists (#65).
`utils/image-prefetch.ts` warms the images a scroll is about to reach,
from `cover-grid`'s and `artists-view`'s virtualizers. Measured on the
50 000-track bulk seed over ten 2 400px jumps: of 258 covers arriving
in view, **254 were still blank one frame later and 214 two frames
later**; with the prefetch, 117 and 77. Both builds are clean by 50 ms
on a desktop with 3.7 kB fixture covers, which is where the reference
device's slower engine and 27 kB covers spend their pop-in.
Four things about it are load-bearing.
**The overscan the obvious fix asks for does not exist.**
`@lit-labs/virtualizer`'s `_overhang` is a hard-coded 1000px
`protected` field on `BaseLayout` with no configuration surface, so
raising it means monkey-patching a private. 1000px is about two
screens on a 439px viewport, and the *image* cannot be requested until
the card it lives in is rendered — which is what this asks for
instead.
**It hangs off `rangeChanged`, not `visibilityChanged`.** Those report
different ranges: visibility is what is on screen, and the virtualizer
has already rendered that 1000px past it. Anchored to the visible
range the window is spent on cards that already exist and have already
asked for their own art — measured as the difference between the
prefetch reaching one row past the last card and reaching a full
window past it.
**It is not the `LRUMap` path, and saying so is the bound.** That
ceiling holds Explore's base64 data URLs in JS; a library cover is a
plain URL under `Cache-Control: immutable` (the filenames are content
hashes), so what retains the bytes is the browser's own cache. What
this module retains is the *set of URLs already asked for*, capped at
512 and reported to `window.__yjCacheStats()` — 497 entries and 15 407
chars after the run above.
**The prefetch asks for what the card will draw.** `artists-view`'s
tier ladder moved into `artistAvatarURL()` so the two cannot disagree;
a second copy would be a warm cache for a tier nothing renders.
**The same rule, on the selection path, was the worst stall in the
app.** Five components turned selected file paths back into tracks with
`filePaths.map(fp => tracks.find(…))`, so "Select all → Edit tags" at