From 3479ae8d392fa49378aaf73cd2625232a8d4c2eb Mon Sep 17 00:00:00 2001 From: Logan Date: Mon, 24 Aug 2026 04:51:25 -0400 Subject: [PATCH] feat(ui): warm album art ahead of the scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` — 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 --- .planning/NOTES.md | 49 +++++ CLAUDE.md | 40 ++++ .../components/artists-view/artists-view.ts | 40 +++- .../src/components/cover-grid/cover-grid.ts | 29 +++ frontend/src/utils/image-prefetch.ts | 156 ++++++++++++++ frontend/test/components/art-prefetch.test.ts | 190 ++++++++++++++++++ frontend/test/support/pixel.svg | 2 + frontend/test/utils/image-prefetch.test.ts | 113 +++++++++++ 8 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/image-prefetch.ts create mode 100644 frontend/test/components/art-prefetch.test.ts create mode 100644 frontend/test/support/pixel.svg create mode 100644 frontend/test/utils/image-prefetch.test.ts diff --git a/.planning/NOTES.md b/.planning/NOTES.md index 31f3767..8e4ba30 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -5029,3 +5029,52 @@ Findings offer it for the 300ms tap delay; this app's viewport is the stated benefit is not there to win. What it would change is the gesture stack #63 tuned by measurement on the device (`pan-y` plus a non-passive `preventDefault`), and that is not measurable from here. + +## Art pop-in is measurable in a browser, if you count frames rather than milliseconds (measured 2026-08-24) + +#65 is an Android report ("scrolling through albums, the art pops in") +and the desktop harness can measure it, which was not obvious: the +first attempt waited 220 ms after each scroll jump and found **zero** +blank covers on either build. The metric only discriminates at one and +two animation frames after the jump, which is where a pop-in actually +lives. + +Protocol, on `make dev-headless SEED=bulk` (4 988 albums), ten +2 400px jumps of `.grid-scroll-container`, counting covers whose rect +intersects 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 | +| prefetch | 117 / 258 | 77 / 258 | 0 | +| prefetch, second run | 118 / 258 | 96 / 258 | 0 | + +Two things this protocol gets wrong if repeated carelessly. **A second +run in the same browser session measures the HTTP cache**, not the +build — the skill already warns about this for `make perf`, and it +applies to any image measurement; every row above is a fresh +`playwright-cli close` + `open`. And **the frontend is embedded**, so +comparing builds is a `git stash` *and* a rebuild, not a stash. + +**The bulk library's covers are 300x300 and ~3.7 kB**, which is why +both builds are clean by 50 ms here and why the phone's number cannot +be inferred from this one — same caveat the skill already records +about full-size artwork. + +**`rangeChanged` and `visibilityChanged` are different ranges**, and +the difference is the whole of this fix's value. +`@lit-labs/virtualizer` reports `_first`/`_last` (rendered, including +the ~1000px overhang) on the former and `_firstVisible`/`_lastVisible` +on the latter. Both grids listen to `visibilityChanged` for scroll +persistence, which wants the visible range and is correct; a prefetch +window measured from it lands mostly on cards that already exist. +Anchored there, the component test could see only one row past the +last rendered card. + +**`_overhang` is not configurable.** It is a `protected` field set to +1000 in `BaseLayout` and read by every layout; there is no option on +`grid()`/`flow()` and no property on the element. The issue's Direction +("ask the virtualizer for a larger overscan") is therefore not +available without patching a private, which is why the request is +issued ahead of the element instead. diff --git a/CLAUDE.md b/CLAUDE.md index cc8d2f1..3bcf933 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/frontend/src/components/artists-view/artists-view.ts b/frontend/src/components/artists-view/artists-view.ts index 3c143fe..68edc67 100644 --- a/frontend/src/components/artists-view/artists-view.ts +++ b/frontend/src/components/artists-view/artists-view.ts @@ -7,6 +7,7 @@ import { import '@lit-labs/virtualizer'; import type { LitVirtualizer, + RangeChangedEvent, VisibilityChangedEvent, } from '@lit-labs/virtualizer'; import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; @@ -30,6 +31,7 @@ import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller import { FavoritesController } from '@store/controllers/favorites-controller'; import { ViewLifecycleMixin } from '@utils/view-lifecycle'; import { RovingGridController } from '@utils/roving-grid'; +import { prefetchImageWindow } from '@utils/image-prefetch'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/popup/popup.js'; @@ -582,6 +584,26 @@ export class ArtistsView * Scroll position persistence * ================================================================ */ + /** + * Warm the avatars just past the rendered range (#65). + * + * `rangeChanged` is the rendered range and `visibilityChanged` is + * what is on screen; the virtualizer has already drawn about + * 1000px past the latter, so that is the wrong anchor to measure a + * prefetch window from. It is deliberately outside the + * `restoringScroll` guard below: a restored scroll lands in the + * middle of the grid, which is exactly when nothing around it is + * cached. + */ + private onRangeChanged = (e: RangeChangedEvent) => { + prefetchImageWindow( + this.cachedGridEntries, + e.first, + e.last, + (entry) => this.artistAvatarURL(entry.artist), + ); + }; + /** * Save the first visible item index on scroll. */ @@ -1145,7 +1167,16 @@ export class ArtistsView * Helpers * ================================================================ */ - private renderArtistAvatar(artist: library.Artist) { + /** + * The image this artist's card will draw, or `''` for the initial + * placeholder. + * + * Split out of `renderArtistAvatar` so the prefetch (#65) asks for + * exactly what the card is going to ask for — a second copy of the + * tier ladder would be a second thing to keep in step, and warming + * the wrong tier is a download that buys nothing. + */ + private artistAvatarURL(artist: library.Artist): string { const needed = (this.imageSize ?? 176) * window.devicePixelRatio; let imageURL = ''; @@ -1172,6 +1203,12 @@ export class ArtistsView ) ?? ''; } + return imageURL; + } + + private renderArtistAvatar(artist: library.Artist) { + const imageURL = this.artistAvatarURL(artist); + if (imageURL) { return html` entry.artist.ID} .layout=${this.gridLayout} @visibilityChanged=${this.onVisibilityChanged} + @rangeChanged=${this.onRangeChanged} > ${this.renderContextMenu()} diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts index af3e986..6bf93cf 100644 --- a/frontend/src/components/cover-grid/cover-grid.ts +++ b/frontend/src/components/cover-grid/cover-grid.ts @@ -8,6 +8,7 @@ import { import '@lit-labs/virtualizer'; import type { LitVirtualizer, + RangeChangedEvent, VisibilityChangedEvent, } from '@lit-labs/virtualizer'; import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; @@ -30,6 +31,7 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@components/playlist-picker/playlist-picker.js'; import { loadTrackDetails } from '@utils/lazy-track-details.js'; import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js'; +import { prefetchImageWindow } from '@utils/image-prefetch.js'; import type { TrackDetails } from '@components/track-details/track-details.js'; import type { CoverArtUrls } from '@components/track-details/track-details.js'; import { AlbumSelectionManager } from './album-selection.js'; @@ -910,6 +912,31 @@ export class CoverGrid ); }; + /** + * Warm the covers just past the rendered range (#65). + * + * `rangeChanged` rather than `visibilityChanged`, because the two + * report different ranges and only one of them is the right + * anchor: visibility is what is on screen, and the virtualizer has + * already rendered about 1000px past that. Measured from the + * visible range this would spend most of its window on cards that + * already exist and have already asked for their own art. + * + * The entry lists are memoized, so asking for one here costs a + * reference compare. + */ + private onRangeChanged = (e: RangeChangedEvent) => { + const entries = this.splitMode + ? this.getBeforeEntries() + : this.buildGridEntries(); + + prefetchImageWindow(entries, e.first, e.last, (entry) => + entry.album.CoverArtPath + ? this.getCoverUrl(entry.album) + : '', + ); + }; + /* ==================================================================== * Virtualizer items * ==================================================================== */ @@ -2003,6 +2030,7 @@ export class CoverGrid @keydown=${this.onGridAlbumKeydown} @contextmenu=${this.onGridAlbumContextMenu} @visibilityChanged=${this.onVisibilityChanged} + @rangeChanged=${this.onRangeChanged} > `; } @@ -2037,6 +2065,7 @@ export class CoverGrid @keydown=${this.onGridAlbumKeydown} @contextmenu=${this.onGridAlbumContextMenu} @visibilityChanged=${this.onVisibilityChanged} + @rangeChanged=${this.onRangeChanged} > ` does not exist at all until the virtualizer decides to render + * its card, and only then can the browser ask for anything. + * + * The issue's Direction asks for a larger overscan, and that 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. 1000px is + * about two screens on the reference device's 439px viewport, which is + * a fraction of a second at speed. + * + * So the request is issued ahead of the element instead. Cover art and + * artist images are plain URLs served by `coverart.Handler` / + * `explore`'s image handler under `Cache-Control: public, + * max-age=31536000, immutable` — the filenames are content hashes — so + * a prefetched image is a cache hit by the time the card is drawn, and + * a second pass over the same rows costs nothing at all. + * + * Three things about it are load-bearing. + * + * **This is not the `LRUMap` path the issue's Findings warn about.** + * That ceiling (`ARTIST_IMAGE_CACHE_LIMIT` and friends) bounds + * Explore's base64 data URLs, which are held in JS. A library cover is + * a URL, and what retains the bytes is the browser's own HTTP cache, + * which evicts on its own terms. What this module retains is the *set + * of URLs already asked for*, which is why that set has a cap and + * reports itself to `window.__yjCacheStats()` — the measurement the + * issue asks for. + * + * **A window is warmed on both sides of the rendered range.** The + * event carries no direction, and scrolling back up needs the same + * treatment; the rows behind are already in `requested` from the pass + * that rendered them, so the backward half issues nothing in the + * common case and is free. + * + * **An in-flight image is held.** `new Image().src = url` and drop it + * is the usual idiom and usually survives, but "usually" is an engine + * detail and the engine that matters here is a two-year-old WebView. + * The element is kept until it loads or fails, and no longer — nothing + * here holds a decoded bitmap on purpose. + */ + +import { registerCacheProbe } from './cache-stats.js'; +import { LRUMap } from './lru-map.js'; + +/** + * How many entries past each edge of the rendered range to warm. + * + * Entries rather than pixels, because that is what the event reports + * and what the caller has an array of. Twelve rows on the phone's + * two-column grid and four on a desktop's six, on top of the + * virtualizer's own 1000px — enough to cover a flick, and bounded so a + * fast scroll through 5 000 albums cannot ask for 5 000 covers. + */ +export const PREFETCH_AHEAD = 24; + +/** Ceiling on the record of what has already been asked for. */ +export const PREFETCH_MEMORY = 512; + +/** URLs already requested; the value is a placeholder, the key is the record. */ +const requested = new LRUMap(PREFETCH_MEMORY); + +/** Images still loading, held so the request cannot be collected. */ +const inFlight = new Set(); + +registerCacheProbe('imagePrefetch', () => { + let chars = 0; + + for (const url of requested.keys()) chars += url.length; + + return { entries: requested.size, chars, limit: PREFETCH_MEMORY }; +}); + +/** Whether this URL has already been asked for. */ +export function imagePrefetched(url: string): boolean { + return requested.has(url); +} + +/** + * Ask the browser for `url` unless it has already been asked for. + * Returns whether a request was issued. + */ +export function prefetchImage(url: string): boolean { + if (!url || requested.has(url)) return false; + + requested.set(url, true); + + const img = new Image(); + + inFlight.add(img); + + const done = () => { + inFlight.delete(img); + }; + + img.addEventListener('load', done, { once: true }); + img.addEventListener('error', done, { once: true }); + img.decoding = 'async'; + img.src = url; + + return true; +} + +/** + * Warm the images either side of a virtualizer's rendered range. + * + * `first`/`last` are the indices the `visibilityChanged` event + * reported; `urlOf` returns the image the card at that index will + * draw, or `''` where it draws a placeholder. Returns how many + * requests were issued, which is what a test can assert on and what + * makes "a second run does approximately nothing" checkable. + */ +export function prefetchImageWindow( + items: readonly T[], + first: number, + last: number, + urlOf: (item: T) => string, + ahead: number = PREFETCH_AHEAD, +): number { + if (items.length === 0 || first < 0 || last < first) return 0; + + const from = Math.max(0, first - ahead); + const to = Math.min(items.length - 1, last + ahead); + let issued = 0; + + // Forward first: it is the direction a scroll is usually going, so + // it is the half that has to win the race. + for (let i = last + 1; i <= to; i++) { + const item = items[i]; + + if (item !== undefined && prefetchImage(urlOf(item))) issued++; + } + + for (let i = from; i < first; i++) { + const item = items[i]; + + if (item !== undefined && prefetchImage(urlOf(item))) issued++; + } + + return issued; +} + +/** Forget what has been asked for. For tests; the app never needs it. */ +export function resetImagePrefetch(): void { + requested.clear(); + inFlight.clear(); +} diff --git a/frontend/test/components/art-prefetch.test.ts b/frontend/test/components/art-prefetch.test.ts new file mode 100644 index 0000000..80b88e0 --- /dev/null +++ b/frontend/test/components/art-prefetch.test.ts @@ -0,0 +1,190 @@ +/** + * The grids ask for the art below the fold before the card exists + * (#65). + * + * Reported as "scrolling through albums, the art pops in". The cards + * already draw the smallest adequate tier and are already + * `loading="lazy"`, so what was left is *when*: `` + * renders about 1000px past the viewport and the `` — and + * therefore the request — does not exist until it does. On the + * reference device that is about two screens. + * + * These assert the mechanism, since no tier here can photograph a + * pop-in: that the rows past the rendered range are requested, that + * the request is for the same tier the card will draw, and that the + * window has an end — an unbounded prefetch of a 5 000-album library + * is the failure this trades against. + * + * What is *not* asserted here is that a rendered card was never + * prefetched. It often was, honestly: the grid lays out more than once + * on mount, so a row warmed by the first pass is drawn by the second, + * which is the whole point. The rule that a single pass skips its own + * rendered range is `image-prefetch.test.ts`'s, where one call can be + * looked at on its own. + */ +import { describe, expect, it, beforeEach } from 'vitest'; +import type { LitElement } from 'lit'; + +import '@components/cover-grid/cover-grid'; +import '@components/artists-view/artists-view'; +import { emit, stub, flush, resetHarness } from '@test/support/harness'; +import { Events } from '../../src/events'; +import { fixture, shadowAll } from '@test/support/render'; +import { + PREFETCH_AHEAD, + imagePrefetched, + resetImagePrefetch, +} from '@utils/image-prefetch'; + +/** Enough albums that the virtualizer's own window is nowhere near the end. */ +const ALBUMS = Array.from({ length: 400 }, (_, i) => { + const n = String(i + 1).padStart(4, '0'); + + return { + ID: i + 1, + Name: `Album ${n}`, + ArtistName: 'Aurora Fields', + Year: 2020, + CoverArtPath: `/covers/${n}.jpg`, + CoverArtSmall: `/covers/${n}_sm.jpg`, + CoverArtMedium: `/covers/${n}_md.jpg`, + CoverArtLarge: `/covers/${n}_lg.jpg`, + }; +}); + +const ARTISTS = Array.from({ length: 400 }, (_, i) => { + const n = String(i + 1).padStart(4, '0'); + + return { + ID: i + 1, + Name: `Artist ${n}`, + AlbumCount: 2, + TrackCount: 9, + ImageSmall: `/artists/${n}_sm.jpg`, + ImageMedium: `/artists/${n}_md.jpg`, + ImageLarge: `/artists/${n}_lg.jpg`, + }; +}); + +/** Give the virtualizer a viewport; a zero-height host renders nothing. */ +function sized(el: HTMLElement): void { + el.style.display = 'block'; + el.style.height = '600px'; + el.style.width = '900px'; +} + +async function settle(el: LitElement): Promise { + await flush(); + await el.updateComplete; + await new Promise((r) => setTimeout(r, 200)); +} + +/** The `src` of every card the grid actually rendered. */ +function renderedSources(el: LitElement, selector: string): string[] { + return shadowAll(el, selector) + .map((img) => (img as HTMLImageElement).getAttribute('src') ?? '') + .filter(Boolean); +} + +/** + * The last index the virtualizer has rendered, read off the cards + * rather than counted: the rendered range is what the prefetch window + * is measured from, and a count assumes it starts at 0 and has no + * gaps. + */ +function lastRenderedIndex(el: LitElement, selector: string): number { + const indices = shadowAll(el, selector).map((card) => + Number(card.getAttribute('data-index')), + ); + + return Math.max(...indices); +} + +/** + * The tier the cards chose, read off a rendered card rather than + * recomputed — the point of the assertion is that the prefetch and the + * card agree, so deriving both from the same ladder here would prove + * nothing. + */ +function tierSuffix(src: string): string { + const m = /_(sm|md|lg)\.jpg$/.exec(src); + + return m ? `_${m[1]}` : ''; +} + +beforeEach(() => { + resetHarness(); + resetImagePrefetch(); + localStorage.clear(); + stub('library.Library.GetAlbums', ALBUMS); + stub('library.Library.GetArtists', ARTISTS); + stub('library.Library.GetTracks', []); + stub('library.Library.GetGenres', []); + emit(Events.LibraryScanComplete); +}); + +describe('the albums grid warms the covers below the fold', () => { + it('asks for the covers past the rendered range, in the tier the card draws', async () => { + const el = await fixture('cover-grid'); + + sized(el); + await settle(el); + + const rendered = renderedSources(el, 'img.cover-image'); + + expect(rendered.length).toBeGreaterThan(0); + + const tier = tierSuffix(rendered[0]!); + const url = (index: number) => + `/covers/${String(index + 1).padStart(4, '0')}${tier}.jpg`; + + // The grid starts at the top and never scrolls here, so the whole + // window lies past the last card drawn. + const last = lastRenderedIndex(el, '.album-card'); + + expect(imagePrefetched(url(last + 1))).toBe(true); + expect(imagePrefetched(url(last + PREFETCH_AHEAD))).toBe(true); + }); + + it('stops at the end of the window rather than warming the library', async () => { + const el = await fixture('cover-grid'); + + sized(el); + await settle(el); + + const rendered = renderedSources(el, 'img.cover-image'); + const tier = tierSuffix(rendered[0]!); + const url = (index: number) => + `/covers/${String(index + 1).padStart(4, '0')}${tier}.jpg`; + + // Not "exactly `last + PREFETCH_AHEAD`": the grid lays out more + // than once on mount and each pass warms a window from wherever + // the rendered range was then, so the reachable set is a few + // windows wide. The property that matters is that it is a window + // at all rather than the library. + expect(imagePrefetched(url(399))).toBe(false); + expect(window.__yjCacheStats?.()['imagePrefetch']?.entries ?? 0) + .toBeLessThan(ALBUMS.length / 2); + }); +}); + +describe('the artists grid warms its avatars the same way', () => { + it('asks for the avatars past the rendered range', async () => { + const el = await fixture('artists-view'); + + sized(el); + await settle(el); + + const rendered = renderedSources(el, 'img.avatar-image'); + + expect(rendered.length).toBeGreaterThan(0); + + const tier = tierSuffix(rendered[0]!); + const last = lastRenderedIndex(el, '.artist-card'); + const url = (index: number) => + `/artists/${String(index + 1).padStart(4, '0')}${tier}.jpg`; + + expect(imagePrefetched(url(last + 1))).toBe(true); + expect(imagePrefetched(url(399))).toBe(false); + }); +}); diff --git a/frontend/test/support/pixel.svg b/frontend/test/support/pixel.svg new file mode 100644 index 0000000..d1ca2e1 --- /dev/null +++ b/frontend/test/support/pixel.svg @@ -0,0 +1,2 @@ + + diff --git a/frontend/test/utils/image-prefetch.test.ts b/frontend/test/utils/image-prefetch.test.ts new file mode 100644 index 0000000..838cd08 --- /dev/null +++ b/frontend/test/utils/image-prefetch.test.ts @@ -0,0 +1,113 @@ +/** + * What the grids ask for ahead of the scroll (#65). + * + * The virtualizer renders about 1000px past its viewport and nothing + * else can be asked for, because the `` does not exist until the + * card does — two screens on the reference device, which is a fraction + * of a second at speed. `prefetchImageWindow` issues the request + * before the element, so the assertions here are about *which* rows + * are asked for, that none is asked for twice, and that a request is + * really made rather than merely recorded. + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { + PREFETCH_MEMORY, + imagePrefetched, + prefetchImage, + prefetchImageWindow, + resetImagePrefetch, +} from '@utils/image-prefetch'; + +/** A hundred cards, each with its own cover URL. */ +const CARDS = Array.from({ length: 100 }, (_, i) => ({ url: `/covers/${i}_sm.jpg` })); + +const urlOf = (card: { url: string }) => card.url; + +beforeEach(() => { + resetImagePrefetch(); +}); + +describe('warming the images a scroll is about to reach', () => { + it('asks for the rows just past the rendered range, and no further', () => { + const issued = prefetchImageWindow(CARDS, 40, 50, urlOf, 3); + + // Three past each edge: 51-53 and 37-39. + expect(issued).toBe(6); + expect(imagePrefetched('/covers/51_sm.jpg')).toBe(true); + expect(imagePrefetched('/covers/53_sm.jpg')).toBe(true); + expect(imagePrefetched('/covers/54_sm.jpg')).toBe(false); + expect(imagePrefetched('/covers/39_sm.jpg')).toBe(true); + expect(imagePrefetched('/covers/37_sm.jpg')).toBe(true); + expect(imagePrefetched('/covers/36_sm.jpg')).toBe(false); + }); + + it('leaves the rendered rows alone — they have their own ', () => { + prefetchImageWindow(CARDS, 40, 50, urlOf, 3); + + expect(imagePrefetched('/covers/45_sm.jpg')).toBe(false); + }); + + it('asks for nothing twice, so a scroll back over the same rows is free', () => { + prefetchImageWindow(CARDS, 40, 50, urlOf, 3); + + expect(prefetchImageWindow(CARDS, 40, 50, urlOf, 3)).toBe(0); + }); + + it('clamps at both ends of the list', () => { + // At the top of a five-item list nothing precedes the range, and + // the tail runs out after two. + expect(prefetchImageWindow(CARDS.slice(0, 5), 0, 2, urlOf, 10)).toBe(2); + }); + + it('asks for nothing when the virtualizer reports an empty range', () => { + // `visibilityChanged` reports -1/-1 before anything is laid out. + expect(prefetchImageWindow(CARDS, -1, -1, urlOf)).toBe(0); + }); + + it('skips a card that draws a placeholder rather than an image', () => { + expect(prefetchImageWindow(CARDS, 40, 50, () => '', 3)).toBe(0); + }); + + it('really issues the request, rather than only recording it', async () => { + // A served file, so the load succeeds and the resource timing entry + // is unambiguous; the query string keeps it distinct per run. + const url = `/test/support/pixel.svg?prefetch=${Date.now()}`; + const href = new URL(url, location.href).href; + + expect(prefetchImage(url)).toBe(true); + + for (let i = 0; i < 100; i++) { + if (performance.getEntriesByName(href).length > 0) break; + + await new Promise((r) => setTimeout(r, 20)); + } + + expect(performance.getEntriesByName(href)).toHaveLength(1); + expect(prefetchImage(url)).toBe(false); + expect(performance.getEntriesByName(href)).toHaveLength(1); + }); + + it('reports what it is holding, with its cap, to the cache stats', () => { + prefetchImageWindow(CARDS, 40, 50, urlOf, 3); + + const stat = window.__yjCacheStats?.()['imagePrefetch']; + + expect(stat).toBeTruthy(); + expect(stat!.entries).toBe(6); + expect(stat!.limit).toBe(PREFETCH_MEMORY); + // It holds URLs, not images — the bytes are the browser's cache. + expect(stat!.chars).toBe(6 * '/covers/51_sm.jpg'.length); + }); + + it('keeps its record bounded, so a 50 000-album scroll cannot grow it', () => { + const many = Array.from( + { length: PREFETCH_MEMORY * 2 }, + (_, i) => ({ url: `/covers/bulk-${i}_sm.jpg` }), + ); + + prefetchImageWindow(many, 0, 0, urlOf, many.length); + + expect(window.__yjCacheStats?.()['imagePrefetch']?.entries).toBe(PREFETCH_MEMORY); + }); +});