mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 11:49:28 +00:00
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:
co-authored by
Claude Opus 4.8
parent
5ed6f454e7
commit
a791a0d8fe
+21
-12
@@ -1,11 +1,12 @@
|
||||
// Pins the v3 Songs A–Z 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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user