feat(ui): warm album art ahead of the scroll #215

Merged
logan merged 1 commits from feat/65-art-prefetch-ahead into main 2026-08-25 17:58:42 +00:00
Collaborator

What the issue was

Scrolling the albums grid pops the art in (#65). Two of the three
things the Direction asks for turned out to be checks rather than
changes — cover-grid.getCoverUrl() and artists-view's avatar
already pick _sm/_md/_lg from the card size and DPR, and both are
already loading="lazy" decoding="async". The third, "ask the
virtualizer for a larger overscan", is not available:
@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.

What the overhang stands in for is the real problem: the <img> — and
therefore the request — does not exist until the virtualizer renders
its card, and 1000px is about two screens on the reference device's
439px viewport.

What changed, and why

frontend/src/utils/image-prefetch.ts warms a bounded window of images
either side of the rendered range, hung off cover-grid's and
artists-view's virtualizers.

Four things in it are load-bearing:

  • rangeChanged, not visibilityChanged. They report different
    ranges: visibility is what is on screen, and the virtualizer has
    already rendered ~1000px past it. Anchored to visibility the window
    is spent on cards that already exist and have already asked for their
    own art. The existing visibilityChanged handlers are untouched —
    they want the first visible row, and that is correct.
  • The bound is the URL record, not an image cache. The Findings warn
    about the LRUMap ceilings; those hold Explore's base64 data URLs. A
    library cover is a plain URL under Cache-Control: public, max-age=31536000, immutable (content-hashed filenames), so the bytes
    are the browser's cache. What this module retains is the set of URLs
    already asked for, capped at 512 and registered with cache-stats,
    which is the __yjCacheStats() measurement the issue asks for.
  • The prefetch asks for the tier the card will draw.
    artistAvatarURL() is split out of renderArtistAvatar so the two
    cannot disagree; a second copy of the ladder would warm a tier
    nothing renders.
  • An in-flight image is held until it loads or fails. new Image().src = url and drop it usually survives, but "usually" is an
    engine detail and the engine that matters here is Chrome 113.

Verification

  • Measured against the running app, make dev-headless SEED=bulk
    (50 000 tracks, 4 988 albums), ten 2 400px jumps of the grid's
    scroller, counting covers intersecting the viewport with
    naturalWidth === 0:

    build blank at frame 1 at frame 2 at 50 ms
    main 254 / 258 214 / 258 0
    main, second run 254 / 258 190 / 258 0
    this branch 117 / 258 77 / 258 0
    this branch, second run 118 / 258 96 / 258 0

    Fresh browser session per row (a second run in the same session
    measures the HTTP cache, not the build) and a rebuild between
    builds, since the frontend is embedded and a stash alone re-runs the
    old bundle. The first attempt at this measurement waited 220 ms after
    each jump and found zero blanks on either build — the metric only
    discriminates at one and two frames, which is where a pop-in lives.

  • window.__yjCacheStats().imagePrefetch after that run: 497
    entries, 15 407 chars, cap 512
    . The ceiling holds and is checkable.

  • make ui-test1104 passed, 12 of them new
    (test/utils/image-prefetch.test.ts,
    test/components/art-prefetch.test.ts). Proved non-vacuous: with
    the two @rangeChanged bindings removed, the two component cases
    that assert a row past the rendered range fail.

  • make e2e239 passed against make dev-headless SEED=default
    (chromium; WebKit is CI's half). As a regression check: no new spec,
    because the fixture library is eight albums and nothing scrolls
    there.

  • npx tsc --noEmit, make css-check, make skill-check — clean. No
    Go, no .sql and no .templ changed, so make lint, make test,
    make generate and make bindings were not run.

Deliberately not done

  • No device tier. The numbers above are a desktop engine against
    the bulk library's 3.7 kB covers, which is why both builds are clean
    by 50 ms there; the phone's own magnitude is unmeasured, though the
    direction is not.
  • fetchpriority / eager loading for the first screenful, the
    issue's third suggestion. The first screenful renders on mount and
    asks for its art immediately; that is a different question from the
    one reported.
  • The split grid's "after" virtualizer gets no hook. Its indices
    are into a second array and it carries no visibilityChanged either;
    it exists only while an album dropdown is expanded, which is the
    state where the user has stopped scrolling.

Why this issue and not a higher one

Six PRs from earlier runs are open against a main that has not moved,
so this run avoided another branch over the same files. #200 is
queue-panel, menu-surface and index.ts and #39 is track-list
both entirely inside files #208/#209/#211/#214 already edit. #85 opens
with "re-measure before building", which needs a real tagged library
and MusicBrainz. Said on the issue at claim time.

Closes #65

## What the issue was Scrolling the albums grid pops the art in (#65). Two of the three things the Direction asks for turned out to be checks rather than changes — `cover-grid.getCoverUrl()` and `artists-view`'s avatar already pick `_sm`/`_md`/`_lg` from the card size and DPR, and both are already `loading="lazy" decoding="async"`. The third, "ask the virtualizer for a larger overscan", **is not available**: `@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. What the overhang stands in for is the real problem: the `<img>` — and therefore the request — does not exist until the virtualizer renders its card, and 1000px is about two screens on the reference device's 439px viewport. ## What changed, and why `frontend/src/utils/image-prefetch.ts` warms a bounded window of images either side of the rendered range, hung off `cover-grid`'s and `artists-view`'s virtualizers. Four things in it are load-bearing: - **`rangeChanged`, not `visibilityChanged`.** They report different ranges: visibility is what is on screen, and the virtualizer has already rendered ~1000px past it. Anchored to visibility the window is spent on cards that already exist and have already asked for their own art. The existing `visibilityChanged` handlers are untouched — they want the first *visible* row, and that is correct. - **The bound is the URL record, not an image cache.** The Findings warn about the `LRUMap` ceilings; those hold Explore's base64 data URLs. A library cover is a plain URL under `Cache-Control: public, max-age=31536000, immutable` (content-hashed filenames), so the bytes are the browser's cache. What this module retains is the set of URLs already asked for, capped at 512 and registered with `cache-stats`, which is the `__yjCacheStats()` measurement the issue asks for. - **The prefetch asks for the tier the card will draw.** `artistAvatarURL()` is split out of `renderArtistAvatar` so the two cannot disagree; a second copy of the ladder would warm a tier nothing renders. - **An in-flight image is held** until it loads or fails. `new Image().src = url` and drop it usually survives, but "usually" is an engine detail and the engine that matters here is Chrome 113. ## Verification - **Measured against the running app**, `make dev-headless SEED=bulk` (50 000 tracks, 4 988 albums), ten 2 400px jumps of the grid's scroller, counting covers intersecting the viewport with `naturalWidth === 0`: | build | blank at frame 1 | at frame 2 | at 50 ms | |---|---|---|---| | `main` | 254 / 258 | 214 / 258 | 0 | | `main`, second run | 254 / 258 | 190 / 258 | 0 | | this branch | 117 / 258 | 77 / 258 | 0 | | this branch, second run | 118 / 258 | 96 / 258 | 0 | Fresh browser session per row (a second run in the same session measures the HTTP cache, not the build) and a **rebuild** between builds, since the frontend is embedded and a stash alone re-runs the old bundle. The first attempt at this measurement waited 220 ms after each jump and found zero blanks on either build — the metric only discriminates at one and two frames, which is where a pop-in lives. - `window.__yjCacheStats().imagePrefetch` after that run: **497 entries, 15 407 chars, cap 512**. The ceiling holds and is checkable. - `make ui-test` — **1104 passed**, 12 of them new (`test/utils/image-prefetch.test.ts`, `test/components/art-prefetch.test.ts`). **Proved non-vacuous**: with the two `@rangeChanged` bindings removed, the two component cases that assert a row past the rendered range fail. - `make e2e` — **239 passed** against `make dev-headless SEED=default` (chromium; WebKit is CI's half). As a regression check: no new spec, because the fixture library is eight albums and nothing scrolls there. - `npx tsc --noEmit`, `make css-check`, `make skill-check` — clean. No Go, no `.sql` and no `.templ` changed, so `make lint`, `make test`, `make generate` and `make bindings` were not run. ## Deliberately not done - **No device tier.** The numbers above are a desktop engine against the bulk library's 3.7 kB covers, which is why both builds are clean by 50 ms there; the phone's own magnitude is unmeasured, though the direction is not. - **`fetchpriority` / eager loading for the first screenful**, the issue's third suggestion. The first screenful renders on mount and asks for its art immediately; that is a different question from the one reported. - **The split grid's "after" virtualizer** gets no hook. Its indices are into a second array and it carries no `visibilityChanged` either; it exists only while an album dropdown is expanded, which is the state where the user has stopped scrolling. ## Why this issue and not a higher one Six PRs from earlier runs are open against a `main` that has not moved, so this run avoided another branch over the same files. #200 is `queue-panel`, `menu-surface` and `index.ts` and #39 is `track-list` — both entirely inside files #208/#209/#211/#214 already edit. #85 opens with "re-measure before building", which needs a real tagged library and MusicBrainz. Said on the issue at claim time. Closes #65
Author
Collaborator

CI is green on 32aa0d9 — run
17767:
check success (lint, three Go build configurations, tsc,
make ui-test, bindings-check, skill-check, commit-check) and e2e
success, which is the run that carries WebKit — the browser this
worktree cannot run, and the only tier here that had not seen the
change.

**CI is green** on `32aa0d9` — run [17767](https://git.ljones.me/yonlu/yellowjacket/actions/runs/17767): `check` success (lint, three Go build configurations, `tsc`, `make ui-test`, bindings-check, skill-check, commit-check) and `e2e` success, which is the run that carries **WebKit** — the browser this worktree cannot run, and the only tier here that had not seen the change.
logan added 1 commit 2026-08-25 17:58:22 +00:00
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
3479ae8d39
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
logan force-pushed feat/65-art-prefetch-ahead from 32aa0d98ce to 3479ae8d39 2026-08-25 17:58:22 +00:00 Compare
logan merged commit 245647f12b into main 2026-08-25 17:58:42 +00:00
Sign in to join this conversation.