mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
fix(v3): recycle library grid cards on scroll instead of rebuilding the window (#742)
The virtualized v3 Songs grid rebuilt its entire visible window (grid.innerHTML = _renderCardsRange(...) + a full wireCards pass) every time it slid by one row. Each row-boundary crossing was therefore a heavy synchronous frame — reparse ~60 cards, re-attach hundreds of listeners, reflow — that stalled the main thread and buffered held-arrow key-repeats, flushing them in a burst. Testers saw the library "go super fast for a second then slow down," skipping "every so many scrolls," up or down, at the same spots each time. It hitched scrolling back up over already-loaded songs too, because the cost was DOM teardown, not fetching. renderWindow() now reconciles the window in place: it reuses the card nodes that stay on-screen and builds only the row that enters/leaves (~6 nodes per slide instead of ~60). Nodes are keyed by absolute index (data-idx) with a real-vs-skeleton + select-mode signature (data-sig) so hole-fills after a page fetch and select-mode toggles still rebuild exactly the nodes that changed. wireCards()'s existing data-wired guard then wires only the freshly-built nodes, so per-slide listener churn drops with it. Everything keyed off data-fn (favorites, ⋮ menu, right-click, selection, accuracy badges, A–Z rail) is unaffected. Follow-up to the stage-2 virtualized grid (#636 item 3). Frontend-only. Tests: tests/js/v3_songs_window_recycle.test.js — window stays [start,end) contiguous and in-window node identity is reused across a down-then-up scroll; select-mode toggle and a rail-seek jump rebuild correctly. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
byrongamatos
parent
fa2d12222a
commit
c7aa5a10b0
+50
-6
@@ -1859,13 +1859,57 @@
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function _renderCardsRange(start, end) {
|
||||
let html = '';
|
||||
// Signature of the card at absolute index i: real-card vs skeleton, plus the
|
||||
// select-mode it was built under. A change here is the ONLY reason a recycled
|
||||
// node must be rebuilt (a hole filled after a fetch, or select mode toggled) —
|
||||
// otherwise the node is reused as-is across window slides.
|
||||
function _cardSig(i) {
|
||||
return (state.songs[i] ? 'r' : 's') + (state.selectMode ? '1' : '0');
|
||||
}
|
||||
|
||||
function _buildCardNode(i) {
|
||||
const s = state.songs[i];
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = s ? songCard(s) : _skeletonCard();
|
||||
const node = tmp.firstElementChild;
|
||||
node.setAttribute('data-idx', String(i));
|
||||
node.setAttribute('data-sig', _cardSig(i));
|
||||
return node;
|
||||
}
|
||||
|
||||
// Reconcile the grid's children to exactly cover [start, end) in ascending
|
||||
// index order, REUSING the card nodes that stay in-window. Sliding the window
|
||||
// one row now mutates only the row that entered/left instead of tearing down +
|
||||
// rebuilding (+ re-wiring) the whole ~60-card window every frame — that
|
||||
// per-slide teardown was the main-thread stall behind the "library skips every
|
||||
// so many scrolls, up or down" report (the stall buffers held-arrow key-repeats
|
||||
// that then flush in a burst). wireCards()'s data-wired guard wires only the
|
||||
// freshly-built nodes.
|
||||
function _syncWindow(grid, start, end) {
|
||||
// Pass 1: drop nodes that left the window, are untagged, or whose content
|
||||
// signature is stale (skeleton→real, or select-mode toggled). What remains
|
||||
// is a reusable, correctly-rendered subset in ascending DOM order.
|
||||
for (const el of Array.from(grid.children)) {
|
||||
const a = el.getAttribute('data-idx');
|
||||
const idx = a == null ? NaN : Number(a);
|
||||
if (!(idx >= start && idx < end) || el.getAttribute('data-sig') !== _cardSig(idx)) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
// Pass 2: walk [start, end) in order, reusing survivors and inserting new
|
||||
// nodes into their correct slot; `ref` tracks the child expected next.
|
||||
const existing = new Map();
|
||||
for (const el of grid.children) existing.set(Number(el.getAttribute('data-idx')), el);
|
||||
let ref = grid.firstChild;
|
||||
for (let i = start; i < end; i++) {
|
||||
const s = state.songs[i];
|
||||
html += s ? songCard(s) : _skeletonCard();
|
||||
let node = existing.get(i);
|
||||
if (!node) node = _buildCardNode(i);
|
||||
if (node === ref) {
|
||||
ref = ref.nextSibling;
|
||||
} else {
|
||||
grid.insertBefore(node, ref);
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
// Fetch a single OFFSET page into the sparse store. Uses the stage-1 keyset
|
||||
@@ -1983,7 +2027,7 @@
|
||||
}
|
||||
if (_closeCardMenu) _closeCardMenu(); // its DOM is about to be replaced
|
||||
grid.style.top = (firstRow * rowH) + 'px';
|
||||
grid.innerHTML = _renderCardsRange(start, end);
|
||||
_syncWindow(grid, start, end); // recycle in-window nodes; only the entering/leaving row rebuilds
|
||||
wireCards(grid);
|
||||
decorateTuningChips(grid); // colour tuning chips by working-tuning match (async, feature-detected)
|
||||
state.winRange = { start, end };
|
||||
|
||||
Reference in New Issue
Block a user