From d5bb97aefef4b8ddf1ca41e5f55167cac7180c1d Mon Sep 17 00:00:00 2001 From: Administrator Date: Sun, 21 Jun 2026 20:04:10 +0200 Subject: [PATCH] =?UTF-8?q?perf(history):=20virtualize=20the=20History=20t?= =?UTF-8?q?able=20=E2=80=94=20kill=20the=20last=20tab-switch=20layout=20co?= =?UTF-8?q?st=20(v3.3.102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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 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) --- package.json | 2 +- renderer/app.js | 94 ++++++++++++++++++++++++++++----------------- renderer/styles.css | 5 +++ tasks/todo.md | 34 ++++++++++++++++ 4 files changed, 98 insertions(+), 37 deletions(-) diff --git a/package.json b/package.json index 04da9d0..1df1430 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/renderer/app.js b/renderer/app.js index de05fe5..64bca40 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -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(``); + for (let i = startIdx; i < endIdx; i++) { + const row = _historyWorking[i]; + const link = row.link || ''; + parts.push('`); + parts.push(escapeHtml(row.date)); + parts.push(''); + parts.push(escapeHtml(row.filename)); + parts.push(''); + parts.push(escapeHtml(row.host)); + parts.push(''); + parts.push(escapeHtml(link)); + parts.push(''); + } + if (bottomPad > 0) parts.push(``); + tbody.innerHTML = parts.join(''); +} function renderHistoryTable(container) { if (!container || !historyRowsData.length) { if (container) container.innerHTML = '

Noch keine Uploads.

'; 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 `${label}${dir}`; }; - let html = ` + container.innerHTML = `
${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')} - `; +
`; - 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(''); - parts.push(date); - parts.push(''); - parts.push(filename); - parts.push(''); - parts.push(host); - parts.push(''); - parts.push(linkHtml); - parts.push(''); - } - parts.push(''); - 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) { diff --git a/renderer/styles.css b/renderer/styles.css index 95d86a4..f2e6ce3 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -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; diff --git a/tasks/todo.md b/tasks/todo.md index 1ecb6de..4139559 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -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 `` 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 `` (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`