diff --git a/eslint.config.mjs b/eslint.config.mjs index 768df91..6759a1c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -32,6 +32,7 @@ export default [ alert: 'readonly', confirm: 'readonly', requestAnimationFrame: 'readonly', + queueMicrotask: 'readonly', Intl: 'readonly', crypto: 'readonly', URLSearchParams: 'readonly', diff --git a/renderer/app.js b/renderer/app.js index 5b2968b..ce77638 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -874,25 +874,41 @@ function _updateRowInPlace(tr, job) { const pct = Math.min(100, Math.round((job.progress || 0) * 100)); const link = job.result ? (job.result.download_url || job.result.embed_url || '') : ''; - // Update row class - tr.className = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`; - tr.dataset.link = link; + // Write DOM only when the target value actually changes — a no-op progress + // tick (same pct, same speed) then performs zero DOM work. Massive saver + // 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; 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 const badge = cells[3].querySelector('.status-badge'); - if (badge) { badge.className = `status-badge ${statusClass}`; badge.textContent = statusText; } - cells[4].textContent = elapsed; - cells[5].textContent = remaining; - cells[6].textContent = speed; + if (badge) { + const badgeClass = `status-badge ${statusClass}`; + if (badge.className !== badgeClass) badge.className = badgeClass; + 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'); - 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'); - 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; } @@ -999,11 +1015,22 @@ function _onQueueScroll() { const _collatorDE = new Intl.Collator('de', { sensitivity: 'base', numeric: true }); 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) { const { key, direction } = queueSortState; 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; if (key === 'filename') cmp = _collatorDE.compare(a.fileName, b.fileName); 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); return cmp * factor; }); + if (sig) _queueSortCache = { sig, result: sorted }; + return sorted; } function getStatusOrder(status) { @@ -1968,7 +1997,14 @@ function applySummaryResults(summary) { // Single-pass queue stats computation (shared by status bar + stats panel). // 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() { + if (_queueStatsCache) return _queueStatsCache; + let remaining = 0, inProgress = 0, done = 0, errors = 0; let bytesRemaining = 0, totalSize = 0, remainingSize = 0, inProgressBytes = 0; 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() {