From 85287aa62022b8b2016fc00e69d2afd7fb3f6e61 Mon Sep 17 00:00:00 2001 From: Administrator Date: Sun, 19 Apr 2026 12:27:16 +0200 Subject: [PATCH] perf: kill lag with 1000s of rows during upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two worst hot paths were: - clicking a row triggered a full table rebuild with sort+innerHTML (queue AND recent panel), and the opposite panel got cleared with another full rebuild - every upload progress tick (4/sec) scanned queueJobs twice and filtered sessionFilesData twice just to update the status bar Fixes: - applyQueueSelectionClasses / applyRecentSelectionClasses toggle the .selected class on existing rows instead of rebuilding the tbody. Click selection is now O(rendered rows) instead of O(total × sort). - maybeAddSessionFile schedules renderRecentUploadsPanel via rAF so a batch of 1000 successful uploads coalesces into one render. - sortRecentFiles memoizes its result per (sortKey, direction, len) — unchanged sort state + unchanged length returns the cached array instead of re-sorting thousands of entries. - _computeQueueStats now also returns inProgressBytes, dropping the second queueJobs scan in updateStatusBar. - session done/error counts are maintained incrementally, replacing two sessionFilesData.filter().length calls every status-bar tick. - handleRowClick uses the _jobIndexById map instead of Array.find. --- renderer/app.js | 105 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 77 insertions(+), 28 deletions(-) diff --git a/renderer/app.js b/renderer/app.js index c32480f..5b2968b 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -57,6 +57,9 @@ let historySortState = { key: 'date', direction: 'desc' }; let sessionFilesData = []; const recentSortState = { key: 'date', direction: 'desc' }; const selectedRecentIds = new Set(); +// Maintained incrementally — avoids O(n) filter() scans every 250ms in the status bar. +let _sessionDoneCount = 0; +let _sessionErrorCount = 0; // --- Init --- async function init() { @@ -787,6 +790,34 @@ function scheduleQueueRender() { requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); }); } +let _recentRenderQueued = false; +function scheduleRecentRender() { + if (_recentRenderQueued) return; + _recentRenderQueued = true; + requestAnimationFrame(() => { _recentRenderQueued = false; renderRecentUploadsPanel(); }); +} + +// Toggle the .selected class on existing rows without rebuilding the table. +// Used on click/selection changes — O(rendered rows) instead of O(total rows × sort). +function applyQueueSelectionClasses() { + const tbody = document.getElementById('queueBody'); + if (!tbody) return; + const rows = tbody.querySelectorAll('.queue-row'); + for (const tr of rows) { + tr.classList.toggle('selected', selectedJobIds.has(tr.dataset.jobId)); + } +} + +function applyRecentSelectionClasses() { + const tbody = document.getElementById('recentFilesBody'); + if (!tbody) return; + const rows = tbody.querySelectorAll('.recent-file-row'); + for (const tr of rows) { + const order = parseInt(tr.dataset.order, 10); + tr.classList.toggle('selected', selectedRecentIds.has(order)); + } +} + function scheduleThrottledUIUpdate() { if (_uiUpdateTimer) return; _uiUpdateTimer = setTimeout(() => { @@ -1007,8 +1038,8 @@ function getStatusText(job) { // --- Queue interactions --- function handleRowClick(e, row) { const jobId = row.dataset.jobId; - // Clear recent panel selection when clicking in queue - if (selectedRecentIds.size > 0) { selectedRecentIds.clear(); renderRecentUploadsPanel(); } + // Clear recent panel selection when clicking in queue — class-toggle only. + if (selectedRecentIds.size > 0) { selectedRecentIds.clear(); applyRecentSelectionClasses(); } if (e.ctrlKey || e.metaKey) { if (selectedJobIds.has(jobId)) selectedJobIds.delete(jobId); @@ -1027,7 +1058,7 @@ function handleRowClick(e, row) { selectedJobIds.clear(); selectedJobIds.add(jobId); // Single click on done job -> copy link - const job = queueJobs.find(j => j.id === jobId); + const job = _jobIndexById.get(jobId); if (job && job.status === 'done' && job.result) { const link = job.result.download_url || job.result.embed_url || ''; if (link) { @@ -1036,7 +1067,9 @@ function handleRowClick(e, row) { } } } - renderQueueTable(); + // Selection changes don't change sort order / row content — just toggle classes. + applyQueueSelectionClasses(); + updateQueueActionButtons(); } // --- Context menu --- @@ -1048,7 +1081,8 @@ function handleRowContextMenu(e, row) { if (!selectedJobIds.has(jobId)) { selectedJobIds.clear(); selectedJobIds.add(jobId); - renderQueueTable(); + applyQueueSelectionClasses(); + updateQueueActionButtons(); } showContextMenu(e.clientX, e.clientY); } @@ -1112,7 +1146,14 @@ function hideContextMenu() { function deleteSelectedRecentFiles() { if (selectedRecentIds.size === 0) return; - sessionFilesData = sessionFilesData.filter(r => !selectedRecentIds.has(r.order)); + let removedDone = 0, removedErr = 0; + sessionFilesData = sessionFilesData.filter(r => { + if (!selectedRecentIds.has(r.order)) return true; + if (r.isError) removedErr++; else removedDone++; + return false; + }); + _sessionDoneCount = Math.max(0, _sessionDoneCount - removedDone); + _sessionErrorCount = Math.max(0, _sessionErrorCount - removedErr); selectedRecentIds.clear(); renderRecentUploadsPanel(); } @@ -1121,6 +1162,8 @@ function clearAllRecentFiles() { if (sessionFilesData.length === 0) return; if (!confirm(`Wirklich alle ${sessionFilesData.length} Links aus diesem Panel entfernen?`)) return; sessionFilesData = []; + _sessionDoneCount = 0; + _sessionErrorCount = 0; selectedRecentIds.clear(); renderRecentUploadsPanel(); } @@ -1887,7 +1930,9 @@ function maybeAddSessionFile(job) { isError: false, order: sessionFilesData.length }); - renderRecentUploadsPanel(); + _sessionDoneCount++; + // Coalesce rapid successive adds into one render per frame. + scheduleRecentRender(); } } @@ -1921,10 +1966,11 @@ 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. function _computeQueueStats() { let remaining = 0, inProgress = 0, done = 0, errors = 0; - let bytesRemaining = 0, totalSize = 0, remainingSize = 0; + let bytesRemaining = 0, totalSize = 0, remainingSize = 0, inProgressBytes = 0; const total = queueJobs.length; for (let i = 0; i < total; i++) { @@ -1937,6 +1983,7 @@ function _computeQueueStats() { if (s === 'uploading' || s === 'getting-server' || s === 'retrying') { inProgress++; remaining++; + inProgressBytes += bu; bytesRemaining += Math.max(0, bt - bu); remainingSize += Math.max(0, bt - bu); } else if (s === 'preview' || s === 'queued') { @@ -1952,7 +1999,7 @@ function _computeQueueStats() { } } - return { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize }; + return { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize, inProgressBytes }; } function updateStatusBar() { @@ -1972,15 +2019,7 @@ function updateStatusBar() { document.getElementById('sbState').textContent = stateText; document.getElementById('sbSpeed').textContent = formatSpeed(lastUploadStats.globalSpeedKbs || 0); - // Session-based bytes: survive removeFromQueueOnDone - // Uploaded = done jobs (session) + in-progress bytes still in queue - let inProgressBytes = 0; - for (const job of queueJobs) { - if (job.status === 'uploading' || job.status === 'getting-server' || job.status === 'retrying') { - inProgressBytes += job.bytesUploaded || 0; - } - } - const uploadedSize = _sessionUploadedBytes + inProgressBytes; + const uploadedSize = _sessionUploadedBytes + stats.inProgressBytes; const totalSize = Math.max(stats.totalSize, _sessionTotalBytes); document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`; document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`; @@ -1988,10 +2027,8 @@ function updateStatusBar() { document.getElementById('sbQueueCount').textContent = `Total: ${stats.total}`; document.getElementById('sbRemainingCount').textContent = `Remaining: ${stats.remaining}`; document.getElementById('sbInProgressCount').textContent = `In Progress: ${stats.inProgress}`; - const sessionDone = sessionFilesData.filter(r => !r.isError).length; - const sessionErrors = sessionFilesData.filter(r => r.isError).length; - document.getElementById('sbDoneCount').textContent = `Done: ${sessionDone}`; - document.getElementById('sbErrorCount').textContent = `Error: ${sessionErrors}`; + document.getElementById('sbDoneCount').textContent = `Done: ${_sessionDoneCount}`; + document.getElementById('sbErrorCount').textContent = `Error: ${_sessionErrorCount}`; } // --- Health Check --- @@ -3052,9 +3089,17 @@ async function exportHistory() { showCopyToast(`Verlauf exportiert (${result.totalRows || 0} Zeilen)`); } +// Memoize sort result: invalidated only when data length changes or sort state changes. +// Selection changes and re-renders reuse the cached sorted array — a big win when +// the panel has thousands of rows and the sort is stable. +let _recentSortCache = { sig: '', result: [] }; + function sortRecentFiles(data) { - const sorted = data.slice(); const { key, direction } = recentSortState; + const sig = `${key}|${direction}|${data.length}`; + if (_recentSortCache.sig === sig) return _recentSortCache.result; + + const sorted = data.slice(); const dir = direction === 'asc' ? 1 : -1; sorted.sort((a, b) => { if (key === 'date') return dir * ((a.dateTs - b.dateTs) || (a.order - b.order)); @@ -3063,6 +3108,7 @@ function sortRecentFiles(data) { if (key === 'link') return dir * _collatorDE.compare(a.link, b.link); return 0; }); + _recentSortCache = { sig, result: sorted }; return sorted; } @@ -3106,14 +3152,16 @@ function renderRecentUploadsPanel() { tbody.addEventListener('click', (e) => { const tr = e.target.closest('.recent-file-row'); if (!tr) return; - // Clear queue selection when clicking in recent panel - if (selectedJobIds.size > 0) { selectedJobIds.clear(); renderQueueTable(); updateQueueActionButtons(); } + // Clear queue selection when clicking in recent panel — class-toggle only. + if (selectedJobIds.size > 0) { selectedJobIds.clear(); applyQueueSelectionClasses(); updateQueueActionButtons(); } const id = parseInt(tr.dataset.order, 10); if (e.ctrlKey || e.metaKey) { if (selectedRecentIds.has(id)) selectedRecentIds.delete(id); else selectedRecentIds.add(id); } else if (e.shiftKey && selectedRecentIds.size > 0) { - const sortedOrders = sortRecentFiles(sessionFilesData).map(r => r.order); + // Use already-sorted DOM order (cheap) instead of re-sorting the full array. + const sortedOrders = Array.from(tbody.querySelectorAll('.recent-file-row')) + .map(r => parseInt(r.dataset.order, 10)); const lastIdx = sortedOrders.findIndex(o => selectedRecentIds.has(o)); const curIdx = sortedOrders.indexOf(id); if (lastIdx >= 0 && curIdx >= 0) { @@ -3125,7 +3173,8 @@ function renderRecentUploadsPanel() { selectedRecentIds.clear(); selectedRecentIds.add(id); } - renderRecentUploadsPanel(); + // Selection change — toggle classes, no tbody rebuild. + applyRecentSelectionClasses(); }); tbody.addEventListener('dblclick', (e) => {