perf(recent): virtualize the Recent-uploads panel — the last non-virtual table (v3.3.104)
The v3.3.103 log (real 2464-job, 4-hoster batch ramping to 95 concurrent) confirmed the statSync fix held (batch-start main spike 336→231ms with 10× more jobs) and the whole 90s ramp to 95 active was pristine (event-loop mean ~11ms, fps=32, longtasks=0). The one residual: rapidly clicking tabs DURING the 95-active upload produced 210-221ms renderer long-tasks (proc=0ms → layout/paint, not JS). Cause: the Recent-uploads panel rendered every sessionFilesData row (up to 2000) into the DOM non-virtualized — the exact analog of the History table before it was virtualized in v3.3.102. Switching to that view laid out ~2000 rows (~210ms on the RDP VM). Fix — virtualize renderRecentUploadsPanel, mirroring the queue/History pattern: - The tbody gets only the visible rows plus top/bottom spacer <tr> sized from VIRTUAL_ROW_HEIGHT. A rAF-coalesced scroll handler and a ResizeObserver on .recent-files-table-wrap re-render the visible window (the ResizeObserver also serves as the show-trigger when the hidden panel gains size). _recentWorking holds the sorted set. The insertAdjacentHTML append-only fast path is dropped — a ~40-row window re-render is cheap, so every render just re-renders the window; on prepend (date desc) the scroll position is preserved (scrollTop=0 at top, else += added*ROW_HEIGHT). - Selection stays correct: _buildRecentRowHtml already stamps the selected class from selectedRecentIds.has(row.order) per row, so an off-screen-selected row renders selected when scrolled into view; selectedRecentIds remains the source of truth and shift-select already reads the sort cache, not the DOM. - styles.css gives .recent-file-row a fixed 28px height so the virtualization math is exact (the table already had table-layout:fixed, so no column-jump fix needed). - _renderRecentVirtualRows returns early when there are no rows, so it never wipes the empty-state message. Verified with Playwright at 2000 rows (bounded container): view-show layout drops from ~118ms to ~2.4ms, the DOM stays at 29-39 rows, scrolling maps to the correct rows, the scrollbar height is exact, an off-screen-selected row renders with the selected class, and the row height is exactly 28. Every large table (Queue, History, Recent) is now virtualized. 407 tests pass; clean Electron boot. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7f636258d4
commit
335f365497
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "3.3.103",
|
"version": "3.3.104",
|
||||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -4738,54 +4738,67 @@ function _buildRecentRowHtml(row) {
|
|||||||
let _recentLastRenderedSig = '';
|
let _recentLastRenderedSig = '';
|
||||||
let _recentLastRenderedLen = 0;
|
let _recentLastRenderedLen = 0;
|
||||||
let _recentPendingAppends = 0;
|
let _recentPendingAppends = 0;
|
||||||
|
let _recentWorking = [];
|
||||||
|
let _recentLastRange = { start: -1, end: -1 };
|
||||||
|
let _recentScrollQueued = false;
|
||||||
|
|
||||||
|
function _onRecentScroll() {
|
||||||
|
if (_recentScrollQueued) return;
|
||||||
|
_recentScrollQueued = true;
|
||||||
|
requestAnimationFrame(() => { _recentScrollQueued = false; _renderRecentVirtualRows(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderRecentVirtualRows() {
|
||||||
|
const wrap = document.querySelector('.recent-files-table-wrap');
|
||||||
|
const tbody = document.getElementById('recentFilesBody');
|
||||||
|
if (!wrap || !tbody) return;
|
||||||
|
const total = _recentWorking.length;
|
||||||
|
if (!total) return;
|
||||||
|
const scrollTop = wrap.scrollTop;
|
||||||
|
const viewportHeight = Math.max(wrap.clientHeight, 400);
|
||||||
|
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 === _recentLastRange.start && endIdx === _recentLastRange.end) return;
|
||||||
|
_recentLastRange = { 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++) parts.push(_buildRecentRowHtml(_recentWorking[i]));
|
||||||
|
if (bottomPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${bottomPad}px"><td colspan="4"></td></tr>`);
|
||||||
|
tbody.innerHTML = parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
function renderRecentUploadsPanel(appendOnly = false) {
|
function renderRecentUploadsPanel(appendOnly = false) {
|
||||||
const tbody = document.getElementById('recentFilesBody');
|
const tbody = document.getElementById('recentFilesBody');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
const pendingAppends = _recentPendingAppends;
|
|
||||||
_recentPendingAppends = 0;
|
_recentPendingAppends = 0;
|
||||||
|
const wrap = tbody.closest('.recent-files-table-wrap');
|
||||||
|
|
||||||
if (!sessionFilesData.length) {
|
if (!sessionFilesData.length) {
|
||||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Noch keine Uploads in dieser Session.</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Noch keine Uploads in dieser Session.</td></tr>';
|
||||||
_recentLastRenderedSig = '';
|
_recentWorking = [];
|
||||||
_recentLastRenderedLen = 0;
|
_recentLastRange = { start: -1, end: -1 };
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = sortRecentFiles(sessionFilesData);
|
|
||||||
const sig = `${recentSortState.key}|${recentSortState.direction}`;
|
|
||||||
const dateDescAppendOnly = appendOnly
|
|
||||||
&& pendingAppends > 0
|
|
||||||
&& sig === 'date|desc'
|
|
||||||
&& _recentLastRenderedSig === sig
|
|
||||||
&& tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen;
|
|
||||||
|
|
||||||
const wrap = tbody.closest('.recent-files-table-wrap');
|
|
||||||
const wasAtTop = !wrap || wrap.scrollTop <= 48;
|
|
||||||
|
|
||||||
let wasAppendOnly = false;
|
|
||||||
if (dateDescAppendOnly) {
|
|
||||||
const added = Math.min(pendingAppends, rows.length);
|
|
||||||
let html = '';
|
|
||||||
for (let i = 0; i < added; i++) html += _buildRecentRowHtml(rows[i]);
|
|
||||||
tbody.insertAdjacentHTML('afterbegin', html);
|
|
||||||
let evict = (_recentLastRenderedLen + added) - rows.length;
|
|
||||||
while (evict > 0) {
|
|
||||||
const last = tbody.lastElementChild;
|
|
||||||
if (!last) break;
|
|
||||||
last.remove();
|
|
||||||
evict--;
|
|
||||||
}
|
|
||||||
wasAppendOnly = true;
|
|
||||||
} else {
|
} else {
|
||||||
tbody.innerHTML = rows.map(_buildRecentRowHtml).join('');
|
const prevLen = _recentWorking.length;
|
||||||
|
_recentWorking = sortRecentFiles(sessionFilesData);
|
||||||
|
_recentLastRange = { start: -1, end: -1 };
|
||||||
|
const sig = `${recentSortState.key}|${recentSortState.direction}`;
|
||||||
|
if (wrap) {
|
||||||
|
const added = _recentWorking.length - prevLen;
|
||||||
|
if (sig === 'date|desc' && wrap.scrollTop <= 48) wrap.scrollTop = 0;
|
||||||
|
else if (sig === 'date|desc' && added > 0) wrap.scrollTop += added * VIRTUAL_ROW_HEIGHT;
|
||||||
|
}
|
||||||
|
_renderRecentVirtualRows();
|
||||||
}
|
}
|
||||||
if (wrap && sig === 'date|desc' && wasAtTop) wrap.scrollTop = 0;
|
|
||||||
_recentLastRenderedSig = sig;
|
|
||||||
_recentLastRenderedLen = rows.length;
|
|
||||||
|
|
||||||
// Event delegation – bind once, not per-row
|
// Event delegation – bind once, not per-row
|
||||||
if (!_recentListenersBound) {
|
if (!_recentListenersBound) {
|
||||||
_recentListenersBound = true;
|
_recentListenersBound = true;
|
||||||
|
if (wrap) {
|
||||||
|
wrap.addEventListener('scroll', _onRecentScroll, { passive: true });
|
||||||
|
if (typeof window.ResizeObserver !== 'undefined') new window.ResizeObserver(_onRecentScroll).observe(wrap);
|
||||||
|
}
|
||||||
tbody.addEventListener('click', (e) => {
|
tbody.addEventListener('click', (e) => {
|
||||||
const tr = e.target.closest('.recent-file-row');
|
const tr = e.target.closest('.recent-file-row');
|
||||||
if (!tr) return;
|
if (!tr) return;
|
||||||
@ -4824,8 +4837,7 @@ function renderRecentUploadsPanel(appendOnly = false) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort headers only change when the sort state changes — skip on appends.
|
updateRecentSortHeaders();
|
||||||
if (!wasAppendOnly) updateRecentSortHeaders();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const HISTORY_RENDER_CAP = 2000;
|
const HISTORY_RENDER_CAP = 2000;
|
||||||
|
|||||||
@ -657,6 +657,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
|||||||
.recent-file-row {
|
.recent-file-row {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
height: 28px;
|
||||||
}
|
}
|
||||||
.recent-file-row:hover {
|
.recent-file-row:hover {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
|||||||
@ -1,3 +1,40 @@
|
|||||||
|
# v3.3.104 — virtualize the Recent-uploads panel (the last non-virtual table)
|
||||||
|
|
||||||
|
v3.3.103 log (real 2464-job, 4-hoster batch → 95 concurrent): the statSync fix HELD (batch-start main spike
|
||||||
|
336→231ms with 10× more jobs), and the whole 90s ramp to 95 active was PRISTINE (mean ~11ms, fps=32,
|
||||||
|
longtasks=0). Residual: rapidly clicking tabs DURING the 95-active upload → renderer-longtask 210-221ms
|
||||||
|
(proc=0ms = layout). Cause: the Recent-uploads panel (renderRecentUploadsPanel) rendered ALL sessionFilesData
|
||||||
|
rows (≤2000) into the DOM non-virtualized — the exact analog of the History table pre-v3.3.102. Switching to
|
||||||
|
that view laid out ~2000 rows.
|
||||||
|
|
||||||
|
Workflow w23318hm0 hit transient 529 overload (no cached results); did the fix directly using the proven
|
||||||
|
History-virtualization template + Playwright empirical verification (stronger than agent review for layout).
|
||||||
|
|
||||||
|
SHIPPED v3.3.104 (renderer/app.js + styles.css):
|
||||||
|
- Virtualized renderRecentUploadsPanel mirroring History/_renderVirtualRows: tbody#recentFilesBody gets only
|
||||||
|
~visible rows + top/bottom spacer <tr> (VIRTUAL_ROW_HEIGHT=28, OVERSCAN=10). Scroll handler (_onRecentScroll
|
||||||
|
rAF-coalesced) + ResizeObserver on .recent-files-table-wrap (doubles as show-trigger). _recentWorking holds
|
||||||
|
the sorted set. DROPPED the insertAdjacentHTML append-only fast path (a ~40-row window re-render is cheap);
|
||||||
|
every render re-renders the visible window. Scroll-position preserved on prepend (date|desc: scrollTop=0 at
|
||||||
|
top, else += added*ROW_HEIGHT).
|
||||||
|
- SELECTION SAFE: _buildRecentRowHtml already stamps `selected` from selectedRecentIds.has(row.order) per row,
|
||||||
|
so off-screen-selected rows render selected when scrolled in; selectedRecentIds stays the source of truth;
|
||||||
|
shift-select already uses _recentSortCache (not the DOM). applyRecentSelectionClasses toggling only visible
|
||||||
|
rows is correct.
|
||||||
|
- styles.css: .recent-file-row { height: 28px } so the virtualization math is exact (table already had
|
||||||
|
table-layout:fixed, so no column-jump fix needed unlike History).
|
||||||
|
- Empty-state guard: _renderRecentVirtualRows returns early when total=0 so it never wipes the "Noch keine
|
||||||
|
Uploads" message.
|
||||||
|
- PLAYWRIGHT-VERIFIED @2000 rows (bounded container): show-cost 118ms→2.4ms, DOM stays 29-39 rows, scroll maps
|
||||||
|
correctly (row1500→window@1486), scrollHeight exact (56014≈56000), off-screen-selected renders with class,
|
||||||
|
row height exactly 28. 407 tests pass, clean boot.
|
||||||
|
|
||||||
|
Every large table is now virtualized (Queue, History, Recent). DEFERRED still (one-time/minor): batch-start
|
||||||
|
~500ms first-render + residual 231ms main spike for 2464 jobs (one-time per batch); first-get-history-after-
|
||||||
|
batch parse-cache/JSONL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# v3.3.103 — kill the batch-start 336ms main stall (synchronous statSync storm)
|
# v3.3.103 — kill the batch-start 336ms main stall (synchronous statSync storm)
|
||||||
|
|
||||||
v3.3.102 log (real 224-job batch): History virtualization CONFIRMED (no get-history on tab switch),
|
v3.3.102 log (real 224-job batch): History virtualization CONFIRMED (no get-history on tab switch),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user