mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
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>
144 lines
5.7 KiB
JavaScript
144 lines
5.7 KiB
JavaScript
'use strict';
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert');
|
|
|
|
// Mirror of static/v3/songs.js _cardSig / _buildCardNode / _syncWindow (the
|
|
// windowed-grid recycle path, #636 item 3 follow-up) — keep in sync. Exercised
|
|
// against a minimal DOM shim so the reconcile invariants are covered off-browser:
|
|
// (1) after every slide the grid's children are exactly [start,end) ascending,
|
|
// (2) card nodes for indices that stay in-window are REUSED (identity kept) —
|
|
// i.e. sliding one row never tears down + rebuilds the whole window (the
|
|
// per-slide stall behind the "skips every so many scrolls" report), and
|
|
// (3) a select-mode toggle rebuilds the visible window (checkbox/ring change).
|
|
|
|
let NODE_SEQ = 0;
|
|
function makeNode() {
|
|
const attrs = {};
|
|
return {
|
|
_uid: ++NODE_SEQ,
|
|
parent: null,
|
|
getAttribute(k) { return k in attrs ? attrs[k] : null; },
|
|
setAttribute(k, v) { attrs[k] = String(v); },
|
|
get nextSibling() {
|
|
const p = this.parent; if (!p) return null;
|
|
const i = p._kids.indexOf(this);
|
|
return i >= 0 && i + 1 < p._kids.length ? p._kids[i + 1] : null;
|
|
},
|
|
remove() {
|
|
const p = this.parent; if (!p) return;
|
|
const i = p._kids.indexOf(this);
|
|
if (i >= 0) p._kids.splice(i, 1);
|
|
this.parent = null;
|
|
},
|
|
};
|
|
}
|
|
function makeGrid() {
|
|
return {
|
|
_kids: [],
|
|
get children() { return this._kids.slice(); },
|
|
get firstChild() { return this._kids[0] || null; },
|
|
insertBefore(node, ref) {
|
|
if (node.parent) node.remove();
|
|
if (ref == null) this._kids.push(node);
|
|
else { const i = this._kids.indexOf(ref); this._kids.splice(i < 0 ? this._kids.length : i, 0, node); }
|
|
node.parent = this;
|
|
return node;
|
|
},
|
|
};
|
|
}
|
|
|
|
// --- state + the three helpers, mirrored from songs.js ---
|
|
const state = { songs: [], selectMode: false };
|
|
for (let i = 0; i < 5000; i++) state.songs[i] = { filename: 'song' + i };
|
|
|
|
function _cardSig(i) { return (state.songs[i] ? 'r' : 's') + (state.selectMode ? '1' : '0'); }
|
|
function _buildCardNode(i) {
|
|
const node = makeNode();
|
|
node.setAttribute('data-idx', String(i));
|
|
node.setAttribute('data-sig', _cardSig(i));
|
|
return node;
|
|
}
|
|
function _syncWindow(grid, start, end) {
|
|
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();
|
|
}
|
|
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++) {
|
|
let node = existing.get(i);
|
|
if (!node) node = _buildCardNode(i);
|
|
if (node === ref) ref = ref.nextSibling;
|
|
else grid.insertBefore(node, ref);
|
|
}
|
|
}
|
|
|
|
const idxOf = (g) => g._kids.map((n) => Number(n.getAttribute('data-idx')));
|
|
const uidOf = (g) => { const m = new Map(); for (const n of g._kids) m.set(Number(n.getAttribute('data-idx')), n._uid); return m; };
|
|
function assertContig(g, start, end) {
|
|
const a = idxOf(g);
|
|
assert.strictEqual(a.length, end - start, `len == ${end - start}`);
|
|
for (let k = 0; k < a.length; k++) assert.strictEqual(a[k], start + k, `child ${k} == ${start + k}`);
|
|
}
|
|
|
|
const COLS = 6, WIN = 12 * COLS; // 12 rows visible
|
|
|
|
test('window stays [start,end) contiguous scrolling down, one row at a time', () => {
|
|
const grid = makeGrid();
|
|
for (let row = 0; row < 40; row++) {
|
|
const start = row * COLS;
|
|
_syncWindow(grid, start, start + WIN);
|
|
assertContig(grid, start, start + WIN);
|
|
}
|
|
});
|
|
|
|
test('in-window card nodes are reused across a slide (no whole-window teardown)', () => {
|
|
const grid = makeGrid();
|
|
_syncWindow(grid, 0, WIN);
|
|
const before = uidOf(grid);
|
|
_syncWindow(grid, COLS, COLS + WIN); // slide down one row
|
|
const after = uidOf(grid);
|
|
let reused = 0, built = 0;
|
|
for (const [i, uid] of after) (before.get(i) === uid ? reused++ : built++);
|
|
assert.strictEqual(built, COLS, `only the entering row is built (${COLS}), got ${built}`);
|
|
assert.strictEqual(reused, WIN - COLS, 'every overlapping card node is reused');
|
|
});
|
|
|
|
test('scrolling back UP reuses nodes too and keeps order', () => {
|
|
const grid = makeGrid();
|
|
for (let row = 0; row < 30; row++) _syncWindow(grid, row * COLS, row * COLS + WIN);
|
|
let prev = uidOf(grid);
|
|
for (let row = 29; row >= 0; row--) {
|
|
const start = row * COLS;
|
|
_syncWindow(grid, start, start + WIN);
|
|
assertContig(grid, start, start + WIN);
|
|
const now = uidOf(grid);
|
|
for (const [i, uid] of prev) if (i >= start && i < start + WIN) assert.strictEqual(now.get(i), uid, `idx ${i} reused going up`);
|
|
prev = now;
|
|
}
|
|
});
|
|
|
|
test('a select-mode toggle rebuilds the visible window', () => {
|
|
const grid = makeGrid();
|
|
const start = 6 * COLS;
|
|
_syncWindow(grid, start, start + WIN);
|
|
const before = uidOf(grid);
|
|
state.selectMode = true;
|
|
_syncWindow(grid, start, start + WIN);
|
|
const after = uidOf(grid);
|
|
let rebuilt = 0;
|
|
for (const [i, uid] of before) if (after.get(i) !== uid) rebuilt++;
|
|
assert.strictEqual(rebuilt, WIN, 'select-mode change rebuilds every visible card');
|
|
assertContig(grid, start, start + WIN);
|
|
state.selectMode = false;
|
|
});
|
|
|
|
test('a large jump (rail seek) rebuilds cleanly with no stale survivors', () => {
|
|
const grid = makeGrid();
|
|
_syncWindow(grid, 0, WIN);
|
|
_syncWindow(grid, 1000 * COLS, 1000 * COLS + WIN); // non-overlapping jump
|
|
assertContig(grid, 1000 * COLS, 1000 * COLS + WIN);
|
|
});
|