From 0f9096be3c89b106cb39ebaee1b3103e1c859417 Mon Sep 17 00:00:00 2001 From: Administrator Date: Sun, 21 Jun 2026 01:47:59 +0200 Subject: [PATCH] perf(renderer): keep recent-uploads panel append-only past the cap (kill ~80ms per-completion freeze) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- renderer/app.js | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/renderer/app.js b/renderer/app.js index 4216db7..d620b5c 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1177,7 +1177,7 @@ let _recentRenderQueued = false; function scheduleRecentRender() { if (_recentRenderQueued) return; _recentRenderQueued = true; - requestAnimationFrame(() => { _recentRenderQueued = false; renderRecentUploadsPanel(); }); + requestAnimationFrame(() => { _recentRenderQueued = false; renderRecentUploadsPanel(true); }); } // Toggle the .selected class on existing rows without rebuilding the table. @@ -2768,6 +2768,7 @@ function maybeAddSessionFile(job) { }); _recentDataVersion++; _sessionDoneCount++; + _recentPendingAppends++; // Drop oldest entries past the cap to keep render cost bounded. // Without this, sessionFilesData grows unbounded across the session // 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). let _recentLastRenderedSig = ''; let _recentLastRenderedLen = 0; +let _recentPendingAppends = 0; -function renderRecentUploadsPanel() { +function renderRecentUploadsPanel(appendOnly = false) { const tbody = document.getElementById('recentFilesBody'); if (!tbody) return; + const pendingAppends = _recentPendingAppends; + _recentPendingAppends = 0; if (!sessionFilesData.length) { tbody.innerHTML = 'Noch keine Uploads in dieser Session.'; _recentLastRenderedSig = ''; @@ -4685,9 +4689,10 @@ function renderRecentUploadsPanel() { const rows = sortRecentFiles(sessionFilesData); const sig = `${recentSortState.key}|${recentSortState.direction}`; - const dateDescAppendOnly = sig === 'date|desc' + const dateDescAppendOnly = appendOnly + && pendingAppends > 0 + && sig === 'date|desc' && _recentLastRenderedSig === sig - && rows.length > _recentLastRenderedLen && tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen; const wrap = tbody.closest('.recent-files-table-wrap'); @@ -4695,10 +4700,17 @@ function renderRecentUploadsPanel() { let wasAppendOnly = false; if (dateDescAppendOnly) { - const added = rows.length - _recentLastRenderedLen; + 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 { tbody.innerHTML = rows.map(_buildRecentRowHtml).join('');