mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-26 23:01:22 +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>
126 lines
4.4 KiB
JavaScript
126 lines
4.4 KiB
JavaScript
'use strict';
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert');
|
|
|
|
// Mirror of static/v3/songs.js buildLibraryStateHash — keep in sync.
|
|
function buildLibraryStateHash(st) {
|
|
const f = (st && st.filters) || {};
|
|
return JSON.stringify({
|
|
view: st.view || 'grid',
|
|
q: st.q || '',
|
|
sort: st.sort || 'artist',
|
|
provider: st.provider || 'local',
|
|
format: st.format || '',
|
|
artist: st.artist || '',
|
|
album: st.album || '',
|
|
filters: {
|
|
arr_has: [...(f.arr_has || [])].sort(),
|
|
arr_lacks: [...(f.arr_lacks || [])].sort(),
|
|
stem_has: [...(f.stem_has || [])].sort(),
|
|
stem_lacks: [...(f.stem_lacks || [])].sort(),
|
|
lyrics: f.lyrics || '',
|
|
tunings: [...(f.tunings || [])].sort(),
|
|
},
|
|
});
|
|
}
|
|
|
|
const SCROLL_STATE_KEY = 'v3:songs-scroll-state';
|
|
|
|
function makeStore() {
|
|
const data = new Map();
|
|
return {
|
|
getItem(k) { return data.has(k) ? data.get(k) : null; },
|
|
setItem(k, v) { data.set(k, v); },
|
|
removeItem(k) { data.delete(k); },
|
|
clear() { data.clear(); },
|
|
};
|
|
}
|
|
|
|
// 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,
|
|
};
|
|
storage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap));
|
|
}
|
|
|
|
function readSnapshot(storage) {
|
|
const raw = storage.getItem(SCROLL_STATE_KEY);
|
|
if (!raw) return null;
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
const baseState = {
|
|
view: 'grid',
|
|
q: '',
|
|
sort: 'artist',
|
|
provider: 'local',
|
|
format: '',
|
|
artist: '',
|
|
album: '',
|
|
filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [] },
|
|
};
|
|
|
|
test('buildLibraryStateHash changes when sort changes', () => {
|
|
const a = buildLibraryStateHash(baseState);
|
|
const b = buildLibraryStateHash({ ...baseState, sort: 'title' });
|
|
assert.notStrictEqual(a, b);
|
|
});
|
|
|
|
test('buildLibraryStateHash changes when filter provider changes', () => {
|
|
const a = buildLibraryStateHash(baseState);
|
|
const b = buildLibraryStateHash({ ...baseState, provider: 'remote:x' });
|
|
assert.notStrictEqual(a, b);
|
|
});
|
|
|
|
test('buildLibraryStateHash is stable for equivalent filter arrays', () => {
|
|
const s1 = {
|
|
...baseState,
|
|
filters: { ...baseState.filters, arr_has: ['Lead', 'Bass'], tunings: ['Drop D', 'E Standard'] },
|
|
};
|
|
const s2 = {
|
|
...baseState,
|
|
filters: { ...baseState.filters, arr_has: ['Bass', 'Lead'], tunings: ['E Standard', 'Drop D'] },
|
|
};
|
|
assert.strictEqual(buildLibraryStateHash(s1), buildLibraryStateHash(s2));
|
|
});
|
|
|
|
test('snapshot stores scrollTop + view + hash (geometry-stable restore)', () => {
|
|
const storage = makeStore();
|
|
saveSnapshot(storage, baseState, 1840);
|
|
const snap = readSnapshot(storage);
|
|
assert.strictEqual(snap.scrollTop, 1840);
|
|
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);
|
|
const snap = readSnapshot(storage);
|
|
const changed = buildLibraryStateHash({ ...baseState, q: 'beatles' });
|
|
assert.notStrictEqual(snap.hash, changed);
|
|
});
|
|
|
|
test('state hash changes when artist or album changes', () => {
|
|
const a = buildLibraryStateHash(baseState);
|
|
const b = buildLibraryStateHash({ ...baseState, artist: 'A Band' });
|
|
const c = buildLibraryStateHash({ ...baseState, artist: 'A Band', album: 'A Band - LP' });
|
|
assert.notStrictEqual(a, b);
|
|
assert.notStrictEqual(b, c);
|
|
});
|
|
|
|
test('clearing artist should use different hash than album-only selection', () => {
|
|
const withArtist = buildLibraryStateHash({ ...baseState, artist: 'A Band', album: 'A Band - LP' });
|
|
const cleared = buildLibraryStateHash({ ...baseState, artist: '', album: '' });
|
|
assert.notStrictEqual(withArtist, cleared);
|
|
});
|