perf: memoize queue sort, dedup stats scan per tick, skip no-op DOM writes

Three more wins on top of the previous pass:
  - sortQueueJobs memoizes the result for static sort keys
    (filename, host, size) — these don't change during upload, so
    every 200ms progress render now reuses the same sorted array
    instead of running an O(n log n) Collator compare.
  - _computeQueueStats caches within a single tick via queueMicrotask.
    updateStatusBar + updateStatsPanel are always called back-to-back
    and now share one queue scan instead of running two.
  - _updateRowInPlace writes DOM values only when they actually
    changed. Idle/queued/done rows (the majority) incur zero DOM
    mutations per progress tick.
This commit is contained in:
Administrator 2026-04-19 12:59:10 +02:00
parent 571d507889
commit 9158949480
2 changed files with 51 additions and 12 deletions

View File

@ -32,6 +32,7 @@ export default [
alert: 'readonly', alert: 'readonly',
confirm: 'readonly', confirm: 'readonly',
requestAnimationFrame: 'readonly', requestAnimationFrame: 'readonly',
queueMicrotask: 'readonly',
Intl: 'readonly', Intl: 'readonly',
crypto: 'readonly', crypto: 'readonly',
URLSearchParams: 'readonly', URLSearchParams: 'readonly',

View File

@ -874,25 +874,41 @@ function _updateRowInPlace(tr, job) {
const pct = Math.min(100, Math.round((job.progress || 0) * 100)); const pct = Math.min(100, Math.round((job.progress || 0) * 100));
const link = job.result ? (job.result.download_url || job.result.embed_url || '') : ''; const link = job.result ? (job.result.download_url || job.result.embed_url || '') : '';
// Update row class // Write DOM only when the target value actually changes — a no-op progress
tr.className = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`; // tick (same pct, same speed) then performs zero DOM work. Massive saver
tr.dataset.link = link; // when most of the visible jobs are idle/queued/done and only a few are
// actively uploading.
const newClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`;
if (tr.className !== newClass) tr.className = newClass;
if (tr.dataset.link !== link) tr.dataset.link = link;
const cells = tr.children; const cells = tr.children;
if (cells.length < 8) return false; // structure mismatch, needs full rebuild if (cells.length < 8) return false; // structure mismatch, needs full rebuild
cells[1].textContent = uploadedSize; if (cells[1].textContent !== uploadedSize) cells[1].textContent = uploadedSize;
// cells[0] (filename) and cells[2] (hoster) don't change during upload // cells[0] (filename) and cells[2] (hoster) don't change during upload
const badge = cells[3].querySelector('.status-badge'); const badge = cells[3].querySelector('.status-badge');
if (badge) { badge.className = `status-badge ${statusClass}`; badge.textContent = statusText; } if (badge) {
cells[4].textContent = elapsed; const badgeClass = `status-badge ${statusClass}`;
cells[5].textContent = remaining; if (badge.className !== badgeClass) badge.className = badgeClass;
cells[6].textContent = speed; if (badge.textContent !== statusText) badge.textContent = statusText;
}
if (cells[4].textContent !== elapsed) cells[4].textContent = elapsed;
if (cells[5].textContent !== remaining) cells[5].textContent = remaining;
if (cells[6].textContent !== speed) cells[6].textContent = speed;
const fill = cells[7].querySelector('.progress-bar-fill'); const fill = cells[7].querySelector('.progress-bar-fill');
if (fill) { fill.style.width = pct + '%'; fill.className = `progress-bar-fill ${statusClass}`; } if (fill) {
const pctStr = pct + '%';
if (fill.style.width !== pctStr) fill.style.width = pctStr;
const fillClass = `progress-bar-fill ${statusClass}`;
if (fill.className !== fillClass) fill.className = fillClass;
}
const pctSpan = cells[7].querySelector('.progress-pct'); const pctSpan = cells[7].querySelector('.progress-pct');
if (pctSpan) pctSpan.textContent = job.status === 'preview' ? '' : pct + '%'; if (pctSpan) {
const pctText = job.status === 'preview' ? '' : pct + '%';
if (pctSpan.textContent !== pctText) pctSpan.textContent = pctText;
}
return true; return true;
} }
@ -999,11 +1015,22 @@ function _onQueueScroll() {
const _collatorDE = new Intl.Collator('de', { sensitivity: 'base', numeric: true }); const _collatorDE = new Intl.Collator('de', { sensitivity: 'base', numeric: true });
const _collatorSimple = new Intl.Collator('de'); const _collatorSimple = new Intl.Collator('de');
// Queue sort memoization. Keys that don't change during upload (filename, host,
// size) reuse the cached result across progress-driven re-renders. Dynamic keys
// (status/speed/progress) are recomputed each call since the sort order itself
// moves every tick. For a queue of 1000+ jobs sorted by filename, this skips
// the Collator-based O(n log n) sort on every 200ms progress render.
let _queueSortCache = { sig: '', result: [] };
const _STATIC_SORT_KEYS = new Set(['filename', 'host', 'size']);
function sortQueueJobs(jobs) { function sortQueueJobs(jobs) {
const { key, direction } = queueSortState; const { key, direction } = queueSortState;
const factor = direction === 'asc' ? 1 : -1; const factor = direction === 'asc' ? 1 : -1;
const canCache = _STATIC_SORT_KEYS.has(key);
const sig = canCache ? `${key}|${direction}|${jobs.length}` : '';
if (sig && _queueSortCache.sig === sig) return _queueSortCache.result;
return jobs.slice().sort((a, b) => { const sorted = jobs.slice().sort((a, b) => {
let cmp = 0; let cmp = 0;
if (key === 'filename') cmp = _collatorDE.compare(a.fileName, b.fileName); if (key === 'filename') cmp = _collatorDE.compare(a.fileName, b.fileName);
else if (key === 'size') cmp = (a.bytesTotal || 0) - (b.bytesTotal || 0); else if (key === 'size') cmp = (a.bytesTotal || 0) - (b.bytesTotal || 0);
@ -1013,6 +1040,8 @@ function sortQueueJobs(jobs) {
else if (key === 'progress') cmp = (a.progress || 0) - (b.progress || 0); else if (key === 'progress') cmp = (a.progress || 0) - (b.progress || 0);
return cmp * factor; return cmp * factor;
}); });
if (sig) _queueSortCache = { sig, result: sorted };
return sorted;
} }
function getStatusOrder(status) { function getStatusOrder(status) {
@ -1968,7 +1997,14 @@ function applySummaryResults(summary) {
// Single-pass queue stats computation (shared by status bar + stats panel). // Single-pass queue stats computation (shared by status bar + stats panel).
// Also tracks inProgressBytes so the status bar doesn't need a second scan. // Also tracks inProgressBytes so the status bar doesn't need a second scan.
//
// Memoized within a single tick: back-to-back calls (updateStatusBar +
// updateStatsPanel fire together 4×/sec during upload) share one scan. The
// cache is cleared on microtask so the next tick picks up fresh state.
let _queueStatsCache = null;
function _computeQueueStats() { function _computeQueueStats() {
if (_queueStatsCache) return _queueStatsCache;
let remaining = 0, inProgress = 0, done = 0, errors = 0; let remaining = 0, inProgress = 0, done = 0, errors = 0;
let bytesRemaining = 0, totalSize = 0, remainingSize = 0, inProgressBytes = 0; let bytesRemaining = 0, totalSize = 0, remainingSize = 0, inProgressBytes = 0;
const total = queueJobs.length; const total = queueJobs.length;
@ -1999,7 +2035,9 @@ function _computeQueueStats() {
} }
} }
return { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize, inProgressBytes }; _queueStatsCache = { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize, inProgressBytes };
queueMicrotask(() => { _queueStatsCache = null; });
return _queueStatsCache;
} }
function updateStatusBar() { function updateStatusBar() {