perf(history): virtualize the History table — kill the last tab-switch layout cost (v3.3.102)
v3.3.101's gate removed the get-history parse on tab switch, but the log still
showed tab clicks at ~216ms with a ~197ms renderer long-task and NO get-history
(proc=0ms → pure browser layout, not JS). Cause: `.view{display:none}` ->
`.active{display:flex}` and the History table built up to 2000 non-virtualized
<tr>, so making the view visible laid out 2000 rows (~197ms on the RDP VM). The
queue was already virtualized; the History table was the only large non-virtual
one.
Measured the options with Playwright on the real DOM (this machine; the user's VM
is ~1.7x slower):
- current 2000 rows (auto layout): 118ms
- content-visibility + table-layout:fixed: 117ms (no help — rows still laid out)
- cap to 300 rows: 16ms, but rejected: History rows are per-file-per-hoster, so a
single 1280-file batch is ~3840 rows and a small cap would hide a recent batch's
links
- virtualize (40 visible of 2000): 2.3ms
Fix — virtualize renderHistoryTable, mirroring the queue's _renderVirtualRows:
- The header is always rendered; tbody#historyBody gets only the visible rows plus
top/bottom spacer <tr> sized from VIRTUAL_ROW_HEIGHT. A rAF-coalesced scroll
handler and a ResizeObserver on #historyContainer re-render the visible window;
the ResizeObserver also serves as the show-trigger (a hidden 0x0 container that
gains size on tab activation re-renders at the correct height). Sorting resets
scrollTop and re-renders; the copy-link / sort-header click delegation is
unchanged. _historyWorking holds the sorted working set.
- styles.css: .history-table gets table-layout:fixed plus scoped column widths so
columns do not jump as rows scroll in and out. This is scoped to .history-table
and does not touch the .col-* classes the queue shares.
Verified end-to-end with Playwright at 6000 rows: show cost 1.1ms (was 118ms), DOM
stays 32-42 rows, scroll maps to the right rows (top/middle/bottom), scrollbar
height exact, column widths stable, rows update on scroll. All rows remain
scrollable (no UX loss); the show cost drops ~100x.
407 tests pass; clean Electron boot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bad1c665f5
commit
d5bb97aefe
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "3.3.101",
|
||||
"version": "3.3.102",
|
||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@ -4829,12 +4829,59 @@ function renderRecentUploadsPanel(appendOnly = false) {
|
||||
}
|
||||
|
||||
const HISTORY_RENDER_CAP = 2000;
|
||||
let _historyWorking = [];
|
||||
let _historyLastRange = { start: -1, end: -1 };
|
||||
let _historyListenersBound = false;
|
||||
let _historyScrollQueued = false;
|
||||
|
||||
function _onHistoryScroll() {
|
||||
if (_historyScrollQueued) return;
|
||||
_historyScrollQueued = true;
|
||||
requestAnimationFrame(() => { _historyScrollQueued = false; _renderHistoryVirtualRows(); });
|
||||
}
|
||||
|
||||
function _renderHistoryVirtualRows() {
|
||||
const container = document.getElementById('historyContainer');
|
||||
const tbody = document.getElementById('historyBody');
|
||||
if (!container || !tbody) return;
|
||||
const total = _historyWorking.length;
|
||||
const scrollTop = container.scrollTop;
|
||||
const viewportHeight = Math.max(container.clientHeight, 600);
|
||||
const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN);
|
||||
const endIdx = Math.min(total, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN);
|
||||
if (startIdx === _historyLastRange.start && endIdx === _historyLastRange.end) return;
|
||||
_historyLastRange = { start: startIdx, end: endIdx };
|
||||
const topPad = startIdx * VIRTUAL_ROW_HEIGHT;
|
||||
const bottomPad = Math.max(0, (total - endIdx) * VIRTUAL_ROW_HEIGHT);
|
||||
const parts = [];
|
||||
if (topPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${topPad}px"><td colspan="4"></td></tr>`);
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
const row = _historyWorking[i];
|
||||
const link = row.link || '';
|
||||
parts.push('<tr class="history-row');
|
||||
if (row.isError) parts.push(' error');
|
||||
parts.push('" data-link="');
|
||||
parts.push(escapeAttr(link));
|
||||
parts.push(`" style="height:${VIRTUAL_ROW_HEIGHT}px"><td class="col-date">`);
|
||||
parts.push(escapeHtml(row.date));
|
||||
parts.push('</td><td class="col-filename">');
|
||||
parts.push(escapeHtml(row.filename));
|
||||
parts.push('</td><td class="col-host">');
|
||||
parts.push(escapeHtml(row.host));
|
||||
parts.push('</td><td class="col-link">');
|
||||
parts.push(escapeHtml(link));
|
||||
parts.push('</td></tr>');
|
||||
}
|
||||
if (bottomPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${bottomPad}px"><td colspan="4"></td></tr>`);
|
||||
tbody.innerHTML = parts.join('');
|
||||
}
|
||||
|
||||
function renderHistoryTable(container) {
|
||||
if (!container || !historyRowsData.length) {
|
||||
if (container) container.innerHTML = '<p class="empty-state">Noch keine Uploads.</p>';
|
||||
const emptyNotice = document.getElementById('historyCapNotice');
|
||||
if (emptyNotice) emptyNotice.style.display = 'none';
|
||||
_historyWorking = [];
|
||||
return;
|
||||
}
|
||||
|
||||
@ -4850,50 +4897,22 @@ function renderHistoryTable(container) {
|
||||
}
|
||||
}
|
||||
|
||||
const rows = sortHistoryRows(working);
|
||||
_historyWorking = sortHistoryRows(working);
|
||||
_historyLastRange = { start: -1, end: -1 };
|
||||
const headerCell = (key, label) => {
|
||||
const active = historySortState.key === key;
|
||||
const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕';
|
||||
return `<th class="sortable${active ? ' active' : ''}" data-history-sort="${key}">${label}<span class="sort-indicator">${dir}</span></th>`;
|
||||
};
|
||||
|
||||
let html = `<table class="results-table history-table"><thead><tr>
|
||||
container.innerHTML = `<table class="results-table history-table"><thead><tr>
|
||||
${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')}
|
||||
</tr></thead><tbody>`;
|
||||
</tr></thead><tbody id="historyBody"></tbody></table>`;
|
||||
|
||||
const parts = [html];
|
||||
const len = rows.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const row = rows[i];
|
||||
const link = row.link || '';
|
||||
const date = escapeHtml(row.date);
|
||||
const filename = escapeHtml(row.filename);
|
||||
const host = escapeHtml(row.host);
|
||||
const linkHtml = escapeHtml(link);
|
||||
const linkAttr = escapeAttr(link);
|
||||
parts.push('<tr class="history-row');
|
||||
if (row.isError) parts.push(' error');
|
||||
parts.push('" data-link="');
|
||||
parts.push(linkAttr);
|
||||
parts.push('"><td class="col-date">');
|
||||
parts.push(date);
|
||||
parts.push('</td><td class="col-filename">');
|
||||
parts.push(filename);
|
||||
parts.push('</td><td class="col-host">');
|
||||
parts.push(host);
|
||||
parts.push('</td><td class="col-link">');
|
||||
parts.push(linkHtml);
|
||||
parts.push('</td></tr>');
|
||||
}
|
||||
parts.push('</tbody></table>');
|
||||
container.innerHTML = parts.join('');
|
||||
|
||||
// Delegated listeners: bind once per render-target instead of once per
|
||||
// row/header. With a 5000-row history the per-row bind path was a
|
||||
// 5000-iteration synchronous loop on every Verlauf-tab switch — the
|
||||
// dominant cause of "tab switching lags" in the user report.
|
||||
if (!container.dataset.historyListenersBound) {
|
||||
container.dataset.historyListenersBound = '1';
|
||||
if (!_historyListenersBound) {
|
||||
_historyListenersBound = true;
|
||||
container.addEventListener('scroll', _onHistoryScroll, { passive: true });
|
||||
if (typeof window.ResizeObserver !== 'undefined') new window.ResizeObserver(_onHistoryScroll).observe(container);
|
||||
container.addEventListener('click', (e) => {
|
||||
const th = e.target.closest('th.sortable');
|
||||
if (th && container.contains(th)) {
|
||||
@ -4906,6 +4925,7 @@ function renderHistoryTable(container) {
|
||||
} else {
|
||||
historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc';
|
||||
}
|
||||
container.scrollTop = 0;
|
||||
renderHistoryTable(container);
|
||||
return;
|
||||
}
|
||||
@ -4916,6 +4936,8 @@ function renderHistoryTable(container) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_renderHistoryVirtualRows();
|
||||
}
|
||||
|
||||
function sortHistoryRows(rows) {
|
||||
|
||||
@ -1231,6 +1231,11 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
.results-table th.active, .history-table th.active { color: var(--text); }
|
||||
.sort-indicator { margin-left: 4px; font-size: 10px; }
|
||||
|
||||
.history-table { table-layout: fixed; }
|
||||
.history-table .col-date { width: 16%; }
|
||||
.history-table .col-filename { width: 34%; }
|
||||
.history-table .col-host { width: 12%; }
|
||||
.history-table .col-link { width: 38%; }
|
||||
.history-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
@ -1,3 +1,37 @@
|
||||
# v3.3.102 — virtualize the History table (the last tab-switch layout cost)
|
||||
|
||||
v3.3.101's gate killed the get-history PARSE on tab switch, but the v3.3.101 log showed a RESIDUAL: tab
|
||||
clicks still 216ms with a ~197ms `renderer-longtask` and NO get-history (gate worked). proc=0ms → pure
|
||||
browser layout, not JS. Cause: `.view{display:none}`→`.active{display:flex}` + the History table builds up
|
||||
to 2000 `<tr>` NON-virtualized (renderHistoryTable), so showing the view lays out 2000 rows (~197ms on the
|
||||
RDP VM). The queue was already virtualized; history was the only non-virtual large table.
|
||||
|
||||
MEASURED with Playwright (real DOM, this machine; VM ≈1.7×):
|
||||
- current 2000 rows auto-layout: 118ms ; content-visibility+fixed: 117ms (USELESS — rows still laid out)
|
||||
- cap 300: 16ms (but rejected: history rows are per-file×hoster, a single 1280-file batch ≈3840 rows, so a
|
||||
small cap would HIDE a recent batch's links)
|
||||
- virtualize (40 visible of 2000): 2.3ms ✓
|
||||
Verified the virtualization end-to-end @6000 rows: showCost 1.1ms, DOM stays 32-42 rows, scroll maps
|
||||
correctly (top=row0/mid=row2990/bottom=row5999), scrollHeight exact, columns STABLE (table-layout:fixed),
|
||||
rows update on scroll.
|
||||
|
||||
SHIPPED v3.3.102 (renderer/app.js + styles.css):
|
||||
- Virtualized renderHistoryTable mirroring the queue's _renderVirtualRows: header always rendered, tbody#historyBody
|
||||
gets only visible rows + top/bottom spacer `<tr>` (VIRTUAL_ROW_HEIGHT=28, OVERSCAN=10). Scroll handler
|
||||
(_onHistoryScroll, rAF-coalesced) + ResizeObserver on #historyContainer — the ResizeObserver doubles as the
|
||||
show-trigger (hidden 0×0 container → visible size → re-render at correct height). Sort resets scrollTop=0 +
|
||||
re-renders. Click delegation (copy-link / sort) unchanged. _historyWorking holds the sorted working set.
|
||||
- styles.css: `.history-table{table-layout:fixed}` + scoped col widths (16/34/12/38%) so columns don't jump as
|
||||
rows scroll in/out (does NOT touch the shared .col-* used by the queue). Measured: content-visibility was a
|
||||
no-op, so NOT used.
|
||||
All rows stay scrollable (no UX loss); show cost ~100× lower. 407 tests pass, clean boot. Playwright-verified.
|
||||
|
||||
DEFERRED still (only if needed): the FIRST get-history after a new batch parses 185MB once (~450ms) — needs
|
||||
the ConfigStore parse-cache (+185MB RAM, guarded) or JSONL. appendHistory still rewrites 185MB per batch-done
|
||||
(JSONL fixes that). The ~625ms batch-start spin-up + 107ms debug-log residuals.
|
||||
|
||||
---
|
||||
|
||||
# v3.3.101 — History-tab lag: gate the unconditional reload + fix the diagnostics history regression
|
||||
|
||||
v3.3.100's interaction instrument named the residual exactly: EVERY slow click was `button.tab`/`nav.tab-bar`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user