mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +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
@@ -0,0 +1,130 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Pins the bounded-DOM invariant of the windowed v3 Songs grid (#636 item 3
|
||||
// stage 2). Before virtualization the grid appended every scrolled page, so for
|
||||
// a 2000-song library the card-node count grew unbounded (24 → 624 → 2001).
|
||||
// Now only the visible window (± overscan) is ever in the DOM while a sizer
|
||||
// element gives the scrollbar the full-library geometry.
|
||||
//
|
||||
// Route-mocked (same strategy as v3-tree-select.spec.ts) so the invariant is
|
||||
// deterministic in CI without a seeded 2000-row library: /api/library serves a
|
||||
// synthetic page from the page/after param with total 2001, and the keyset
|
||||
// cursor is mocked as the next absolute offset.
|
||||
|
||||
const TOTAL = 2001;
|
||||
const PAGE_SIZE = 24;
|
||||
const COLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
// Same bucketing as the seed/server: index % 26 → a first letter, so the A–Z
|
||||
// rail has real buckets and a jump has somewhere to land.
|
||||
function songAt(i: number) {
|
||||
const letter = COLS[i % 26];
|
||||
return {
|
||||
filename: `seed/${String(i).padStart(5, '0')}.sloppak`,
|
||||
title: `Song ${String(i).padStart(4, '0')}`,
|
||||
artist: `${letter}Band ${String(i).padStart(4, '0')}`,
|
||||
album: `${letter} Album`,
|
||||
format: 'sloppak',
|
||||
arrangements: [{ index: 0, name: 'Lead' }, { index: 1, name: 'Rhythm' }],
|
||||
};
|
||||
}
|
||||
|
||||
// sort_letters song-counts per bucket for index%26 over [0, TOTAL).
|
||||
function sortLetters() {
|
||||
const m: Record<string, number> = {};
|
||||
for (let i = 0; i < TOTAL; i++) { const L = COLS[i % 26]; m[L] = (m[L] || 0) + 1; }
|
||||
return m;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/library?**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const after = url.searchParams.get('after');
|
||||
const size = Number(url.searchParams.get('size') || PAGE_SIZE);
|
||||
const offset = after != null ? Number(after) : Number(url.searchParams.get('page') || '0') * size;
|
||||
const songs = [];
|
||||
for (let i = offset; i < Math.min(TOTAL, offset + size); i++) songs.push(songAt(i));
|
||||
const nextOffset = offset + size;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
songs, total: TOTAL, page: Math.floor(offset / size), size,
|
||||
next_cursor: nextOffset < TOTAL ? String(nextOffset) : null,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/library/stats**', (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const body: any = { total_songs: TOTAL, total: TOTAL, letters: {} };
|
||||
if (url.searchParams.get('sort_letters')) body.sort_letters = sortLetters();
|
||||
return route.fulfill({ json: body });
|
||||
});
|
||||
await page.route('**/api/library/artists**', (route) => route.fulfill({ json: { artists: [], total_artists: 0 } }));
|
||||
await page.route('**/api/library/providers', (route) => route.fulfill({ json: { providers: [{ id: 'local', label: 'My Library' }] } }));
|
||||
await page.route('**/api/library/tuning-names**', (route) => route.fulfill({ json: { tunings: [] } }));
|
||||
await page.route('**/api/stats/best', (route) => route.fulfill({ json: {} }));
|
||||
await page.route('**/api/stats/recent**', (route) => route.fulfill({ json: [] }));
|
||||
});
|
||||
|
||||
async function openSongs(page) {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore — neutralize playback so a stray click can't navigate away.
|
||||
window.playSong = () => Promise.resolve();
|
||||
// @ts-ignore
|
||||
window.showScreen('v3-songs');
|
||||
});
|
||||
await page.waitForSelector('#v3-songs-grid [data-fn]', { state: 'attached', timeout: 10000 });
|
||||
}
|
||||
|
||||
test('the grid keeps a bounded number of card nodes while scrolling a 2001-song library', async ({ page }) => {
|
||||
await openSongs(page);
|
||||
|
||||
// The count reflects the FULL library even though only a window is rendered.
|
||||
await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs');
|
||||
|
||||
// The sizer reserves the full scroll height (so the scrollbar is library-wide).
|
||||
const scrollHeight = await page.evaluate(() => document.getElementById('v3-main')!.scrollHeight);
|
||||
expect(scrollHeight).toBeGreaterThan(20000);
|
||||
|
||||
// Scroll the whole library; the in-DOM card count must stay bounded throughout.
|
||||
const CAP = 150;
|
||||
let maxNodes = await page.locator('#v3-songs-grid [data-fn]').count();
|
||||
for (let s = 0; s < 50; s++) {
|
||||
await page.evaluate(() => { const m = document.getElementById('v3-main')!; m.scrollTop += m.clientHeight * 0.85; });
|
||||
await page.waitForTimeout(60);
|
||||
const n = await page.locator('#v3-songs-grid [data-fn]').count();
|
||||
maxNodes = Math.max(maxNodes, n);
|
||||
expect(n).toBeLessThanOrEqual(CAP);
|
||||
}
|
||||
// Sanity: we actually rendered a window (not zero), and stayed well under the
|
||||
// unbounded 2001 the old append-everything grid would have produced.
|
||||
expect(maxNodes).toBeGreaterThan(0);
|
||||
expect(maxNodes).toBeLessThanOrEqual(CAP);
|
||||
|
||||
// The count is still correct after scrolling to the end.
|
||||
await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs');
|
||||
});
|
||||
|
||||
test('the A–Z rail jumps directly to a letter without loading every page', async ({ page }) => {
|
||||
await openSongs(page);
|
||||
await page.waitForSelector('.v3-azrail-letter', { state: 'attached', timeout: 10000 });
|
||||
|
||||
// Jump to 'M'; the window scrolls to the row holding the first 'M' card.
|
||||
await page.evaluate(() => {
|
||||
const b = [...document.querySelectorAll('.v3-azrail-letter')]
|
||||
.find((x) => x.getAttribute('data-letter') === 'M' && !(x as HTMLButtonElement).disabled) as HTMLElement | undefined;
|
||||
if (!b) throw new Error('no M rail letter'); b.click();
|
||||
});
|
||||
|
||||
// After the jump+window render, an 'M' card is present near the top of the
|
||||
// viewport (the jump is O(1) via sort_letters, not a full page-through).
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const main = document.getElementById('v3-main')!;
|
||||
const top = main.getBoundingClientRect().top + (document.getElementById('v3-songs-toolbar')?.offsetHeight || 0);
|
||||
return [...document.querySelectorAll('#v3-songs-grid [data-fn]')].some((c) => {
|
||||
const r = c.getBoundingClientRect();
|
||||
return c.getAttribute('data-letter') === 'M' && r.top >= top - 4 && r.top < top + 320;
|
||||
});
|
||||
}), { timeout: 5000 }).toBe(true);
|
||||
});
|
||||
+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