mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 05:04:30 +00:00
perf(folder_library): render only the songs on screen (#965)
A song list rendered EVERY song it held. On a flat 50,944-song library that is
one <div> with 50,938 children and ~1,300,000 DOM nodes — ~4.2 GB of renderer
RSS, for a screen the user may not even be looking at (it was built while the
visible screen was v3-home).
It is not just this plugin's problem. A million-node document poisons unrelated
code: any `document.querySelector` that MISSES has to walk the whole tree before
returning null. That is exactly how song_preview's per-frame menu check ended up
consuming ~50% of the renderer and dropping the app to 2.7 fps
(feedBack-plugin-song-preview#7 fixes the per-frame walk; this fixes the tree it
was walking).
So render only what is on screen. Rows are uniform height (grid cards uniform
size), so the window is pure arithmetic — no per-row observers. Off-window songs
are represented by padding ON THE LIST rather than spacer elements: a spacer div
would become a grid ITEM in grid view and shift the columns, whereas padding
behaves identically in both layouts. Lists at or below VIRTUAL_MIN (200) render
in full exactly as before, so normal folders are untouched.
Two ordering fixes this forced, both real bugs waiting to happen:
- Both expand handlers populated the list BEFORE showing it. A windowed list
measures a real row and the scroller viewport, and both are zero under
display:none. Show first, then populate.
- _render() now tears down the previous render's scroll listeners. Without it
they survive against detached nodes and leak on every re-render.
Verified in real Chromium over CDP with 50,000 rows — the DOM glue, not just the
maths:
at top rendered= 25 rows scrollHeight=2,200,000px [0..24]
scroll 500k rendered= 31 rows scrollHeight=2,200,000px [11357..11387]
scroll 1,100k rendered= 31 rows scrollHeight=2,200,000px [24994..25024]
scroll to end rendered= 25 rows scrollHeight=2,200,000px [49975..49999]
25-31 rows in the DOM instead of 50,000; scroll height exact and constant (the
scrollbar stays honest); the last row lands on song 49,999.
Tests: _visibleWindow is pure and exposed via __test — top/middle/bottom/past-
the-end windows, the grid row-packing case, the padding-plus-rendered-equals-
total invariant that keeps the list from changing height as you scroll, and the
degenerate zero-height case (a list still display:none) falling back to
render-everything rather than to an empty list. eslint clean; full JS suite
1186/1186.
This commit is contained in:
@@ -878,6 +878,115 @@ function createFolderSurface(cfg) {
|
|||||||
var _dragRafId = null;
|
var _dragRafId = null;
|
||||||
var _DRAG_THRESH = 5, _DRAG_ZONE = 150, _DRAG_SPEED = 50;
|
var _DRAG_THRESH = 5, _DRAG_ZONE = 150, _DRAG_SPEED = 50;
|
||||||
|
|
||||||
|
// ── Windowed song lists ─────────────────────────────────────────────
|
||||||
|
// A song list used to render EVERY song it held. On a flat 50,944-song
|
||||||
|
// library that is one <div> with 50,938 children and ~1.3 MILLION DOM nodes
|
||||||
|
// (~25 per row) — ~4.2 GB of renderer RSS, for a screen the user may not
|
||||||
|
// even be looking at. It also poisons unrelated code: any
|
||||||
|
// `document.querySelector` miss anywhere in the app must walk that whole
|
||||||
|
// tree, which is how song_preview's per-frame menu check ended up eating
|
||||||
|
// ~50% of the renderer and dropping the app to 2.7 fps (feedBack#965).
|
||||||
|
//
|
||||||
|
// So render only what is on screen. Rows are uniform height (and grid cards
|
||||||
|
// uniform size), so the window is pure arithmetic — no per-row observers.
|
||||||
|
// Off-window rows are represented by padding on the list itself rather than
|
||||||
|
// spacer elements: a spacer <div> would become a grid ITEM in grid view and
|
||||||
|
// shift the columns, whereas padding works identically for both layouts.
|
||||||
|
var VIRTUAL_MIN = 200; // below this, render everything — no behaviour change
|
||||||
|
var VIRTUAL_BUFFER = 6; // rows kept rendered above/below the viewport
|
||||||
|
var _virtualCleanups = [];
|
||||||
|
|
||||||
|
// Which slice of the list is on screen. Pure arithmetic — kept separate from
|
||||||
|
// the DOM so it can be tested directly (see tests/virtual_list.test.js).
|
||||||
|
//
|
||||||
|
// top : list's offset relative to the scroller viewport's top. NEGATIVE
|
||||||
|
// once the user has scrolled the list's start above the fold.
|
||||||
|
// rows : total ROWS (grid packs `perRow` songs into one row; list view is 1)
|
||||||
|
//
|
||||||
|
// Returns the song index range [start, end) to render, plus how many ROWS of
|
||||||
|
// padding stand in for the songs above and below it.
|
||||||
|
function _visibleWindow(top, viewportH, itemH, perRow, rows, total) {
|
||||||
|
if (!(itemH > 0) || !(rows > 0)) return { start: 0, end: total, padRowsTop: 0, padRowsBottom: 0 };
|
||||||
|
var firstRow = Math.max(0, Math.floor(-top / itemH) - VIRTUAL_BUFFER);
|
||||||
|
var lastRow = Math.min(rows, Math.ceil((-top + viewportH) / itemH) + VIRTUAL_BUFFER);
|
||||||
|
// Scrolled entirely past the list (either direction): keep one row alive
|
||||||
|
// rather than emptying it, so the padding math stays anchored.
|
||||||
|
if (lastRow <= firstRow) {
|
||||||
|
firstRow = Math.min(firstRow, rows - 1);
|
||||||
|
lastRow = firstRow + 1;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
start: firstRow * perRow,
|
||||||
|
end: Math.min(total, lastRow * perRow),
|
||||||
|
padRowsTop: firstRow,
|
||||||
|
padRowsBottom: Math.max(0, rows - lastRow),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clearVirtualLists() {
|
||||||
|
_virtualCleanups.forEach(function (fn) { try { fn(); } catch (_) {} });
|
||||||
|
_virtualCleanups = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill `list` with `songs`, windowed when the list is big enough to matter.
|
||||||
|
// `make(song)` builds one row/card.
|
||||||
|
function _fillSongList(list, songs, make) {
|
||||||
|
var sorted = _sortSongs(songs);
|
||||||
|
if (sorted.length <= VIRTUAL_MIN) {
|
||||||
|
sorted.forEach(function (s) { list.appendChild(make(s)); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scroller = _getScrollEl();
|
||||||
|
var basePadTop = parseFloat(window.getComputedStyle(list).paddingTop) || 0;
|
||||||
|
var basePadBot = parseFloat(window.getComputedStyle(list).paddingBottom) || 0;
|
||||||
|
|
||||||
|
// Measure one real row once — no hardcoded row height to drift out of
|
||||||
|
// sync with the CSS.
|
||||||
|
var probe = make(sorted[0]);
|
||||||
|
probe.style.visibility = 'hidden';
|
||||||
|
list.appendChild(probe);
|
||||||
|
var itemH = probe.getBoundingClientRect().height || 44;
|
||||||
|
var perRow = 1;
|
||||||
|
if (_view === 'grid') {
|
||||||
|
var cw = probe.getBoundingClientRect().width || 150;
|
||||||
|
var gap = 12;
|
||||||
|
perRow = Math.max(1, Math.floor((list.clientWidth + gap) / (cw + gap)));
|
||||||
|
itemH += gap;
|
||||||
|
}
|
||||||
|
list.removeChild(probe);
|
||||||
|
|
||||||
|
var rows = Math.ceil(sorted.length / perRow);
|
||||||
|
var raf = 0, lastStart = -1, lastEnd = -1;
|
||||||
|
|
||||||
|
function paint() {
|
||||||
|
raf = 0;
|
||||||
|
// Where the list sits relative to the scroller's viewport.
|
||||||
|
var top = list.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
||||||
|
var vh = scroller.clientHeight || window.innerHeight;
|
||||||
|
var w = _visibleWindow(top, vh, itemH, perRow, rows, sorted.length);
|
||||||
|
if (w.start === lastStart && w.end === lastEnd) return; // nothing moved
|
||||||
|
lastStart = w.start; lastEnd = w.end;
|
||||||
|
|
||||||
|
var frag = document.createDocumentFragment();
|
||||||
|
for (var i = w.start; i < w.end; i++) frag.appendChild(make(sorted[i]));
|
||||||
|
list.textContent = '';
|
||||||
|
list.style.paddingTop = (basePadTop + w.padRowsTop * itemH) + 'px';
|
||||||
|
list.style.paddingBottom = (basePadBot + w.padRowsBottom * itemH) + 'px';
|
||||||
|
list.appendChild(frag);
|
||||||
|
}
|
||||||
|
function onScroll() { if (!raf) raf = window.requestAnimationFrame(paint); }
|
||||||
|
|
||||||
|
scroller.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', onScroll);
|
||||||
|
_virtualCleanups.push(function () {
|
||||||
|
scroller.removeEventListener('scroll', onScroll);
|
||||||
|
window.removeEventListener('resize', onScroll);
|
||||||
|
if (raf) window.cancelAnimationFrame(raf);
|
||||||
|
});
|
||||||
|
paint();
|
||||||
|
}
|
||||||
|
|
||||||
function _getScrollEl() {
|
function _getScrollEl() {
|
||||||
var el = _treeEl();
|
var el = _treeEl();
|
||||||
while (el && el !== document.documentElement) {
|
while (el && el !== document.documentElement) {
|
||||||
@@ -1159,8 +1268,8 @@ function createFolderSurface(cfg) {
|
|||||||
|
|
||||||
var _listPopulated = open;
|
var _listPopulated = open;
|
||||||
function _populateList() {
|
function _populateList() {
|
||||||
_sortSongs(folder.songs).forEach(function (s) {
|
_fillSongList(list, folder.songs, function (s) {
|
||||||
list.appendChild(_view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path));
|
return _view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path);
|
||||||
});
|
});
|
||||||
(folder.children || []).forEach(function (child) {
|
(folder.children || []).forEach(function (child) {
|
||||||
childrenWrap.appendChild(_folderSection(child, depth + 1));
|
childrenWrap.appendChild(_folderSection(child, depth + 1));
|
||||||
@@ -1195,8 +1304,10 @@ function createFolderSurface(cfg) {
|
|||||||
hdr.addEventListener('click', function () {
|
hdr.addEventListener('click', function () {
|
||||||
if (_query()) return;
|
if (_query()) return;
|
||||||
var nowOpen = content.style.display === 'none';
|
var nowOpen = content.style.display === 'none';
|
||||||
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
|
// Show BEFORE populating: a windowed list measures a real row and the
|
||||||
|
// scroller viewport, and both are zero while display:none.
|
||||||
content.style.display = nowOpen ? '' : 'none';
|
content.style.display = nowOpen ? '' : 'none';
|
||||||
|
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
|
||||||
chev.style.transform = nowOpen ? 'rotate(90deg)' : '';
|
chev.style.transform = nowOpen ? 'rotate(90deg)' : '';
|
||||||
if (nowOpen) _openFolders.add(folder.path);
|
if (nowOpen) _openFolders.add(folder.path);
|
||||||
else _openFolders.delete(folder.path);
|
else _openFolders.delete(folder.path);
|
||||||
@@ -1245,8 +1356,8 @@ function createFolderSurface(cfg) {
|
|||||||
}
|
}
|
||||||
var _populated = _unsortedOpen;
|
var _populated = _unsortedOpen;
|
||||||
function _populate() {
|
function _populate() {
|
||||||
_sortSongs(songs).forEach(function (s) {
|
_fillSongList(list, songs, function (s) {
|
||||||
list.appendChild(_view === 'grid' ? _songCard(s, '') : _songRow(s, ''));
|
return _view === 'grid' ? _songCard(s, '') : _songRow(s, '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; }
|
if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; }
|
||||||
@@ -1255,8 +1366,9 @@ function createFolderSurface(cfg) {
|
|||||||
hdr.addEventListener('click', function () {
|
hdr.addEventListener('click', function () {
|
||||||
if (_query()) return;
|
if (_query()) return;
|
||||||
_unsortedOpen = list.style.display === 'none';
|
_unsortedOpen = list.style.display === 'none';
|
||||||
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
|
// Show BEFORE populating — see the folder toggle above.
|
||||||
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
|
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
|
||||||
|
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
|
||||||
chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : '';
|
chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : '';
|
||||||
_store(cfg.unsortedKey, String(_unsortedOpen));
|
_store(cfg.unsortedKey, String(_unsortedOpen));
|
||||||
});
|
});
|
||||||
@@ -1340,6 +1452,10 @@ function createFolderSurface(cfg) {
|
|||||||
// ── Render ──────────────────────────────────────────────────────────
|
// ── Render ──────────────────────────────────────────────────────────
|
||||||
function _render() {
|
function _render() {
|
||||||
_hoveredFolder = null; // DOM is rebuilt; discard any stale reference
|
_hoveredFolder = null; // DOM is rebuilt; discard any stale reference
|
||||||
|
// Drop the scroll listeners of the previous render's windowed lists —
|
||||||
|
// their `list` nodes are about to be detached, and a surviving listener
|
||||||
|
// would keep painting into orphaned DOM (and leak on every re-render).
|
||||||
|
_clearVirtualLists();
|
||||||
var treeEl = _treeEl();
|
var treeEl = _treeEl();
|
||||||
if (!treeEl) return;
|
if (!treeEl) return;
|
||||||
var data = _filtered();
|
var data = _filtered();
|
||||||
@@ -1451,6 +1567,7 @@ function createFolderSurface(cfg) {
|
|||||||
|
|
||||||
// ── Unload (lib surface) ────────────────────────────────────────────
|
// ── Unload (lib surface) ────────────────────────────────────────────
|
||||||
function _unload() {
|
function _unload() {
|
||||||
|
_clearVirtualLists(); // don't leave scroll listeners behind on teardown
|
||||||
if (!cfg.searchInputId) return;
|
if (!cfg.searchInputId) return;
|
||||||
var el = _el(cfg.searchInputId);
|
var el = _el(cfg.searchInputId);
|
||||||
if (el) el.style.maxWidth = '';
|
if (el) el.style.maxWidth = '';
|
||||||
@@ -1554,6 +1671,8 @@ function createFolderSurface(cfg) {
|
|||||||
init: _init,
|
init: _init,
|
||||||
onScreenChanged: _onScreenChanged,
|
onScreenChanged: _onScreenChanged,
|
||||||
render: _render,
|
render: _render,
|
||||||
|
// Pure window arithmetic, exposed for tests (no DOM needed).
|
||||||
|
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,6 +1775,7 @@ if (!window.__folderLibraryLib) {
|
|||||||
window.folderLibrary = {
|
window.folderLibrary = {
|
||||||
load: function (force) { return _lib.load(force); },
|
load: function (force) { return _lib.load(force); },
|
||||||
unload: function () { _lib.unload(); },
|
unload: function () { _lib.unload(); },
|
||||||
|
__test: _lib.__test,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-load if folder view was already active when this script was injected.
|
// Auto-load if folder view was already active when this script was injected.
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Windowed song lists (feedBack#965).
|
||||||
|
//
|
||||||
|
// A song list used to render EVERY song. On a flat 50,944-song library that is
|
||||||
|
// one div with 50,938 children and ~1.3 MILLION DOM nodes (~25 per row) —
|
||||||
|
// ~4.2 GB of renderer RSS, for a screen the user may not even be looking at. It
|
||||||
|
// also poisoned unrelated code: any `document.querySelector` miss anywhere in
|
||||||
|
// the app had to walk that whole tree.
|
||||||
|
//
|
||||||
|
// _visibleWindow is the arithmetic that decides which slice is on screen. If it
|
||||||
|
// is wrong the list silently shows the wrong songs, or scrolls to the wrong
|
||||||
|
// place, so it is tested directly — the DOM glue around it is not the risky bit.
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
const window = {
|
||||||
|
console,
|
||||||
|
document: {
|
||||||
|
readyState: 'complete',
|
||||||
|
addEventListener() {},
|
||||||
|
getElementById() { return null; },
|
||||||
|
querySelector() { return null; },
|
||||||
|
querySelectorAll() { return []; },
|
||||||
|
createElement() { return { style: {}, classList: { add() {}, remove() {}, contains() { return false; } }, addEventListener() {}, appendChild() {} }; },
|
||||||
|
},
|
||||||
|
addEventListener() {},
|
||||||
|
localStorage: { getItem() { return null; }, setItem() {} },
|
||||||
|
performance: { now: () => 0 },
|
||||||
|
setInterval() { return 0; },
|
||||||
|
clearInterval() {},
|
||||||
|
requestAnimationFrame() { return 0; },
|
||||||
|
cancelAnimationFrame() {},
|
||||||
|
getComputedStyle() { return { overflowY: 'visible', paddingTop: '0px', paddingBottom: '0px' }; },
|
||||||
|
innerHeight: 800,
|
||||||
|
};
|
||||||
|
window.window = window;
|
||||||
|
window.globalThis = window;
|
||||||
|
const ctx = vm.createContext(window);
|
||||||
|
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'), ctx, { filename: 'screen.js' });
|
||||||
|
assert.ok(window.folderLibrary && window.folderLibrary.__test, 'plugin must expose __test');
|
||||||
|
return window.folderLibrary.__test;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { visibleWindow, VIRTUAL_BUFFER, VIRTUAL_MIN } = load();
|
||||||
|
|
||||||
|
// A flat 50k library in list view: 1 song per row, 44px rows, 800px viewport.
|
||||||
|
const ROW = 44;
|
||||||
|
const VH = 800;
|
||||||
|
const TOTAL = 50938;
|
||||||
|
|
||||||
|
test('the whole point: a 50k list renders a bounded window, not 50k rows', () => {
|
||||||
|
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
|
||||||
|
const rendered = w.end - w.start;
|
||||||
|
assert.ok(rendered < 60, `expected a small window, got ${rendered} rows`);
|
||||||
|
// ~18 rows fit in 800px, plus buffer above and below.
|
||||||
|
assert.ok(rendered >= Math.ceil(VH / ROW), 'must at least fill the viewport');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('at the top: starts at 0, all remaining rows are bottom padding', () => {
|
||||||
|
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
|
||||||
|
assert.equal(w.start, 0);
|
||||||
|
assert.equal(w.padRowsTop, 0);
|
||||||
|
assert.equal(w.padRowsBottom, TOTAL - w.end);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrolled into the middle: window tracks the scroll, padding adds up', () => {
|
||||||
|
const scrolled = 10000 * ROW; // row 10,000 at the fold
|
||||||
|
const w = visibleWindow(-scrolled, VH, ROW, 1, TOTAL, TOTAL);
|
||||||
|
assert.equal(w.start, (10000 - VIRTUAL_BUFFER) * 1);
|
||||||
|
assert.ok(w.end > w.start);
|
||||||
|
// The invariant that keeps the scrollbar honest: padding rows + rendered
|
||||||
|
// rows must account for every song, or the list changes height as you scroll.
|
||||||
|
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('at the very bottom: no bottom padding, end lands on the last song', () => {
|
||||||
|
const rows = TOTAL;
|
||||||
|
const scrolled = rows * ROW - VH; // scrolled to the end
|
||||||
|
const w = visibleWindow(-scrolled, VH, ROW, 1, rows, TOTAL);
|
||||||
|
assert.equal(w.end, TOTAL);
|
||||||
|
assert.equal(w.padRowsBottom, 0);
|
||||||
|
assert.equal(w.padRowsTop + (w.end - w.start), TOTAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grid view: perRow songs collapse into one row', () => {
|
||||||
|
const perRow = 6;
|
||||||
|
const rows = Math.ceil(TOTAL / perRow);
|
||||||
|
const w = visibleWindow(0, VH, 190, perRow, rows, TOTAL);
|
||||||
|
assert.equal(w.start, 0);
|
||||||
|
assert.equal(w.start % perRow, 0, 'a window must start on a row boundary');
|
||||||
|
assert.ok(w.end <= TOTAL);
|
||||||
|
assert.ok((w.end - w.start) < 200, 'grid window must stay bounded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrolled far past the list: keeps one row, never a negative window', () => {
|
||||||
|
const w = visibleWindow(-99999999, VH, ROW, 1, TOTAL, TOTAL);
|
||||||
|
assert.ok(w.end > w.start, 'window must never invert');
|
||||||
|
assert.ok(w.start >= 0 && w.end <= TOTAL);
|
||||||
|
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list not yet scrolled to (below the fold): still yields a valid window', () => {
|
||||||
|
const w = visibleWindow(5000, VH, ROW, 1, TOTAL, TOTAL); // list starts below viewport
|
||||||
|
assert.equal(w.start, 0);
|
||||||
|
assert.ok(w.end > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('degenerate inputs fall back to rendering everything, never to a broken window', () => {
|
||||||
|
// Measured height of 0 (e.g. list still display:none) must not divide by zero
|
||||||
|
// and must not silently render an empty list.
|
||||||
|
const w = visibleWindow(0, VH, 0, 1, TOTAL, TOTAL);
|
||||||
|
assert.equal(w.start, 0);
|
||||||
|
assert.equal(w.end, TOTAL);
|
||||||
|
assert.equal(w.padRowsTop, 0);
|
||||||
|
assert.equal(w.padRowsBottom, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('small lists are below the virtualization threshold', () => {
|
||||||
|
assert.ok(VIRTUAL_MIN >= 100, 'threshold must be high enough that normal folders are untouched');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user