feat(ui): warm album art ahead of the scroll
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:
@@ -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*: `<lit-virtualizer>`
|
||||
* renders about 1000px past the viewport and the `<img>` — 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<void> {
|
||||
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<LitElement>('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<LitElement>('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<LitElement>('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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
<!-- A real, servable image for the prefetch tests: one transparent pixel. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>
|
||||
|
After Width: | Height: | Size: 147 B |
@@ -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 `<img>` 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 <img>', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user