perf(renderer): keep recent-uploads panel append-only past the cap (kill ~80ms per-completion freeze)

The recent-uploads panel had a cheap append-only fast path, but it was gated on
`rows.length > _recentLastRenderedLen`. maybeAddSessionFile caps sessionFilesData
by push-then-slice (2000 -> 2001 -> sliced back to 2000), so once a session
produces more than SESSION_FILES_CAP rows the length is pinned at the cap and the
gate is false forever. Every subsequent completion then fell through to the full
`tbody.innerHTML = rows.map(...).join('')` rebuild of all ~2000 rows.

The cap is per (link x file x hoster), so with 4-5 selected hosters the 2000 cap
is hit at only ~400-500 distinct files — very reachable in a long folder-monitor
session. Profiled in Chromium (same Blink engine as Electron, table-layout:fixed):
the full 2000-row rebuild costs ~80ms and ran on EVERY completion past the cap — a
repeating ~80ms main-thread freeze. That is the "fine on a fresh start, gets laggy
after many uploads while CPU (~40%) and RAM (~6GB, stable) stay normal" symptom: a
render-thread stall, not CPU saturation or a memory leak.

Fix: track newly-pushed rows in _recentPendingAppends (incremented in
maybeAddSessionFile, consumed every render) and gate the fast path on
`pendingAppends > 0` instead of length growth, so it survives the cap. Prepend the
new rows, then evict the same overflow count from the DOM bottom (oldest rows,
which is where the date-desc view places the front-of-array entries the cap slices
off). DOM work is O(added) again. The fast path is gated behind an explicit
`appendOnly` flag passed only by scheduleRecentRender's rAF, so selection / delete
/ clear / sort / batch-done renders stay full rebuilds and cannot wrong-evict or
double-prepend.

Verified in Blink over a simulated 5000-completion session (3/frame, far past the
cap): per-frame render 80ms -> median 7.4ms (>10x), and the DOM stays exactly equal
to the data (cap held at 2000, newest-on-top, oldest evicted, zero duplicates).
397/397 tests pass, eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-06-21 01:47:59 +02:00
parent 29d1944328
commit 0f9096be3c

View File

@ -1177,7 +1177,7 @@ let _recentRenderQueued = false;
function scheduleRecentRender() { function scheduleRecentRender() {
if (_recentRenderQueued) return; if (_recentRenderQueued) return;
_recentRenderQueued = true; _recentRenderQueued = true;
requestAnimationFrame(() => { _recentRenderQueued = false; renderRecentUploadsPanel(); }); requestAnimationFrame(() => { _recentRenderQueued = false; renderRecentUploadsPanel(true); });
} }
// Toggle the .selected class on existing rows without rebuilding the table. // Toggle the .selected class on existing rows without rebuilding the table.
@ -2768,6 +2768,7 @@ function maybeAddSessionFile(job) {
}); });
_recentDataVersion++; _recentDataVersion++;
_sessionDoneCount++; _sessionDoneCount++;
_recentPendingAppends++;
// Drop oldest entries past the cap to keep render cost bounded. // Drop oldest entries past the cap to keep render cost bounded.
// Without this, sessionFilesData grows unbounded across the session // Without this, sessionFilesData grows unbounded across the session
// and every renderRecentUploadsPanel call becomes a megabyte-sized // and every renderRecentUploadsPanel call becomes a megabyte-sized
@ -4672,10 +4673,13 @@ function _buildRecentRowHtml(row) {
// accumulating new uploads (the default case: sort=date desc, rows only grow). // accumulating new uploads (the default case: sort=date desc, rows only grow).
let _recentLastRenderedSig = ''; let _recentLastRenderedSig = '';
let _recentLastRenderedLen = 0; let _recentLastRenderedLen = 0;
let _recentPendingAppends = 0;
function renderRecentUploadsPanel() { 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;
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 = ''; _recentLastRenderedSig = '';
@ -4685,9 +4689,10 @@ function renderRecentUploadsPanel() {
const rows = sortRecentFiles(sessionFilesData); const rows = sortRecentFiles(sessionFilesData);
const sig = `${recentSortState.key}|${recentSortState.direction}`; const sig = `${recentSortState.key}|${recentSortState.direction}`;
const dateDescAppendOnly = sig === 'date|desc' const dateDescAppendOnly = appendOnly
&& pendingAppends > 0
&& sig === 'date|desc'
&& _recentLastRenderedSig === sig && _recentLastRenderedSig === sig
&& rows.length > _recentLastRenderedLen
&& tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen; && tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen;
const wrap = tbody.closest('.recent-files-table-wrap'); const wrap = tbody.closest('.recent-files-table-wrap');
@ -4695,10 +4700,17 @@ function renderRecentUploadsPanel() {
let wasAppendOnly = false; let wasAppendOnly = false;
if (dateDescAppendOnly) { if (dateDescAppendOnly) {
const added = rows.length - _recentLastRenderedLen; const added = Math.min(pendingAppends, rows.length);
let html = ''; let html = '';
for (let i = 0; i < added; i++) html += _buildRecentRowHtml(rows[i]); for (let i = 0; i < added; i++) html += _buildRecentRowHtml(rows[i]);
tbody.insertAdjacentHTML('afterbegin', html); 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; wasAppendOnly = true;
} else { } else {
tbody.innerHTML = rows.map(_buildRecentRowHtml).join(''); tbody.innerHTML = rows.map(_buildRecentRowHtml).join('');