feat(v3): DOM-virtualize the Songs grid (#636 item 3 stage 2) (#643)

The v3 Songs grid appended every scrolled page and never released nodes,
so card-node count grew unbounded with scroll depth (24 → 624 → 2001 for a
2000-song library). Replace it with a windowed/recycled render: only the
visible window (± overscan) is in the DOM while a #v3-songs-gridsizer
element sized to ceil(total/cols)*rowH gives the scrollbar full-library
geometry; #v3-songs-grid is absolutely positioned to the first visible row.

- state.songs is a sparse, absolutely-indexed store filled a page at a time
  by ensureWindow(): the stage-1 keyset cursor for contiguous forward scroll
  (O(page)), OFFSET page= for jumps/restore/non-keyset providers. _loadPage
  shares an in-flight promise per page and an epoch guard discards a stale
  fetch that lands after a reset.
- A–Z rail seeks directly via sort_letters cumulative counts (O(1), no
  page-through); bounded scan fallback for legacy providers without it.
- Snapshot/restore is now scrollTop-based (geometry is stable). Select mode,
  accuracy badges, ⋮ menu, plugin card actions, and tree/folder coexistence
  survive cards recycling; renderWindow re-renders when select mode toggles.
- Plugins get window.v3Songs.visibleCards() + a v3:library-window-rendered
  event instead of assuming all cards are present (highway-stutter lesson).

Verified in a browser against a seeded 2001-song library: DOM bounded to
~60 nodes while the count reads "2001 songs", rail jump lands on the target
row, selection survives recycling, scroll-restore exact. Codex-reviewed
(3 findings fixed: stale-fetch epoch guard, await-in-flight page promise,
select-mode resync on cached re-entry).

Frontend-only. Tests: tests/browser/v3-grid-virtualization.spec.ts pins the
bounded-DOM invariant + direct rail jump; tests/js/v3_az_rail.test.js and
v3_songs_scroll.test.js updated to the new wiring.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-29 12:26:29 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 5ed6f454e7
commit a791a0d8fe
6 changed files with 547 additions and 141 deletions
+21 -12
View File
@@ -1,11 +1,12 @@
// Pins the v3 Songs AZ jump rail wiring in static/v3/songs.js.
//
// The rail lets a user jump the library grid to artists/titles starting with a
// letter (Plex/Radarr/iOS-contacts pattern). Because the grid is forward-only,
// server-paged infinite scroll, the jump pages through to the target card then
// scrolls — and the rail only offers letters the server reports present for the
// active sort+filter (so a tap always terminates at a real card). It is shown
// only for the grid view + alphabetical (artist/title) sorts.
// letter (Plex/Radarr/iOS-contacts pattern). With the windowed grid (#636 item 3
// stage 2) the jump seeks DIRECTLY: the sort_letters song-counts give the first
// card's absolute index (cumulative of prior buckets), which converts to a
// scrollTop — no page-through. The rail only offers letters the server reports
// present for the active sort+filter (so a tap always lands on a real card). It
// is shown only for the grid view + alphabetical (artist/title) sorts.
//
// Source-level only — same strategy as tests/js/highway_3d_camera_framing.test.js.
@@ -65,16 +66,24 @@ test('the rail + drag bubble are rendered in the Songs markup', () => {
assert.match(src, /id="v3-songs-azbubble"/);
});
test('jumpToLetter pages through to the target then scrolls (load-through)', () => {
// Forward-paging helper used to load rows up to the target letter.
assert.match(src, /async function\s+_loadNextAwait\s*\(\)/);
test('jumpToLetter seeks directly via sort_letters cumulative (no page-through)', () => {
// The cumulative-count seek: sum the song-counts of buckets ordered before
// the target to get its first row's absolute index.
assert.match(src, /function\s+_letterStartIndex\s*\(letter\)/,
'jumpToLetter must derive the target index from sort_letters counts');
assert.match(
src,
/async function\s+jumpToLetter[\s\S]*?_loadNextAwait\(\)[\s\S]*?(scrollTo|scrollIntoView)/,
'jumpToLetter must page forward (_loadNextAwait) then scroll to the target card',
/async function\s+jumpToLetter[\s\S]*?_letterStartIndex\(letter\)[\s\S]*?scrollTo/,
'jumpToLetter must compute the target index then scrollTo (no _loadNextAwait page-through)',
);
// A token guards against overlapping jumps (drag scrubbing) — newest wins.
assert.match(src, /_jumpToken\s*===\s*myToken/);
// It pre-fetches the destination window so cards are ready when the scroll lands.
assert.match(src, /async function\s+jumpToLetter[\s\S]*?ensureWindow\(/,
'jumpToLetter must pre-fetch the destination window before scrolling');
// The old forward-paging helper is gone (the seek is O(1)).
assert.doesNotMatch(src, /_loadNextAwait/,
'the page-through helper must be removed under the windowed grid');
// A token still guards overlapping jumps (drag scrubbing) — newest wins.
assert.match(src, /_jumpToken\s*!==\s*myToken/);
});
test('the rail supports pointer drag-scrub + keyboard arrows', () => {
+12 -8
View File
@@ -36,13 +36,15 @@ function makeStore() {
};
}
function saveSnapshot(storage, state, scrollTop, page, loadedCount) {
// Mirror of static/v3/songs.js _saveLibraryScrollSnapshot. Under the windowed
// grid (#636 item 3 stage 2) geometry is stable, so the snapshot is just
// {hash, scrollTop, view} — no page/loadedCount depth bookkeeping (restore sets
// scrollTop and re-renders the window that maps to it).
function saveSnapshot(storage, state, scrollTop) {
const snap = {
hash: buildLibraryStateHash(state),
scrollTop,
view: state.view,
page,
loadedCount,
};
storage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap));
}
@@ -88,19 +90,21 @@ test('buildLibraryStateHash is stable for equivalent filter arrays', () => {
assert.strictEqual(buildLibraryStateHash(s1), buildLibraryStateHash(s2));
});
test('snapshot stores scrollTop and page', () => {
test('snapshot stores scrollTop + view + hash (geometry-stable restore)', () => {
const storage = makeStore();
saveSnapshot(storage, baseState, 1840, 3, 96);
saveSnapshot(storage, baseState, 1840);
const snap = readSnapshot(storage);
assert.strictEqual(snap.scrollTop, 1840);
assert.strictEqual(snap.page, 3);
assert.strictEqual(snap.loadedCount, 96);
assert.strictEqual(snap.view, 'grid');
assert.strictEqual(snap.hash, buildLibraryStateHash(baseState));
// Page-depth bookkeeping is gone — the windowed grid restores from scrollTop.
assert.strictEqual(snap.page, undefined);
assert.strictEqual(snap.loadedCount, undefined);
});
test('stale snapshot is detected when filters change', () => {
const storage = makeStore();
saveSnapshot(storage, baseState, 500, 1, 48);
saveSnapshot(storage, baseState, 500);
const snap = readSnapshot(storage);
const changed = buildLibraryStateHash({ ...baseState, q: 'beatles' });
assert.notStrictEqual(snap.hash, changed);