perf: kill lag with 1000s of rows during upload

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.
This commit is contained in:
Administrator 2026-04-19 12:27:16 +02:00
parent 7dc68c7615
commit 85287aa620

View File

@ -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) => {