From 3c9894c7b0ccda50c8ab8c009380390d01e78ba1 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 19 Apr 2026 13:07:42 +0200 Subject: [PATCH] Performance: ItemRow extraction + scheduler single-pass + selectedIds memo fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major optimizations to reduce UI lag with large queues (5000+ items): 1. ItemRow extracted to its own memoized component (renderer) Previously every package re-render mapped all its items inline, producing N×M re-renders per state update. Now each item-row only re-renders when ITS specific data changes, with custom equality on the visible fields (status, progress, speed, fullStatus, etc.). Also adds stable useCallback handlers per item. 2. PackageCard stats consolidated into single useMemo (renderer) Replaces 5 separate filter()/some() + 2 reduce() calls (O(7N)) with one O(N) pass collecting all aggregates (done/failed/cancelled/ extracted/extracting/activeProgress/extractingProgress). 3. selectedIds memo comparator fixed (renderer) Custom equality now checks if selection state changed for items in THIS package only. Previously any selection anywhere broke memo on all 200+ visible PackageCards. 4. Scheduler single-pass queue presence (main) New getQueuePresence() returns hasImmediate + hasDelayed in one iteration. Replaces hasQueuedItems() + hasDelayedQueuedItems() that each scanned packages independently. Saves one full O(n) iteration per scheduler tick. No functional changes. All 565 tests green. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/download-manager.ts | 62 +++---- src/renderer/App.tsx | 310 +++++++++++++++++++++++------------ 2 files changed, 229 insertions(+), 143 deletions(-) diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 77bb288..8a5e4ce 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -7407,7 +7407,9 @@ export class DownloadManager extends EventEmitter { this.runGlobalStallWatchdog(now); - const downloadsComplete = this.activeTasks.size === 0 && !this.hasQueuedItems() && !this.hasDelayedQueuedItems(); + // Single-pass queue presence check (saves one full O(n) iteration per tick) + const queuePresence = this.activeTasks.size === 0 ? this.getQueuePresence(now) : { hasImmediate: true, hasDelayed: false }; + const downloadsComplete = this.activeTasks.size === 0 && !queuePresence.hasImmediate && !queuePresence.hasDelayed; const postProcessComplete = this.packagePostProcessTasks.size === 0; if (downloadsComplete && (postProcessComplete || this.settings.autoExtractWhenStopped)) { this.finishRun(); @@ -7617,56 +7619,38 @@ export class DownloadManager extends EventEmitter { return null; } - private hasQueuedItems(): boolean { - const now = nowMs(); + /** Single-pass alternative to hasQueuedItems + hasDelayedQueuedItems. + * Returns both flags so the scheduler termination check needs only ONE + * iteration over packages/items per tick instead of two separate scans. */ + private getQueuePresence(now = nowMs()): { hasImmediate: boolean; hasDelayed: boolean } { + let hasImmediate = false; + let hasDelayed = false; for (const packageId of this.session.packageOrder) { const pkg = this.session.packages[packageId]; - if (!pkg || pkg.cancelled || !pkg.enabled) { - continue; - } - if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) { - continue; - } + if (!pkg || pkg.cancelled || !pkg.enabled) continue; + if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) continue; for (const itemId of pkg.itemIds) { const item = this.session.items[itemId]; - if (!item) { - continue; - } + if (!item) continue; + if (item.status !== "queued" && item.status !== "reconnect_wait") continue; const retryAfter = this.retryAfterByItem.get(itemId) || 0; if (retryAfter > now) { - continue; - } - if (item.status === "queued" || item.status === "reconnect_wait") { - return true; + hasDelayed = true; + } else { + hasImmediate = true; } + if (hasImmediate && hasDelayed) return { hasImmediate, hasDelayed }; } } - return false; + return { hasImmediate, hasDelayed }; + } + + private hasQueuedItems(): boolean { + return this.getQueuePresence().hasImmediate; } private hasDelayedQueuedItems(): boolean { - const now = nowMs(); - for (const [itemId, readyAt] of this.retryAfterByItem.entries()) { - if (readyAt <= now) { - continue; - } - const item = this.session.items[itemId]; - if (!item) { - continue; - } - if (item.status !== "queued" && item.status !== "reconnect_wait") { - continue; - } - const pkg = this.session.packages[item.packageId]; - if (!pkg || pkg.cancelled || !pkg.enabled) { - continue; - } - if (this.runPackageIds.size > 0 && !this.runPackageIds.has(item.packageId)) { - continue; - } - return true; - } - return false; + return this.getQueuePresence().hasDelayed; } private countQueuedItems(): number { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9a33990..b59f672 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -6117,6 +6117,153 @@ export function App(): ReactElement { ); } +/** Computes the user-facing status text for an item, applying business rules + * about which states are visible while the session is stopped. */ +function computeDisplayedItemStatus(item: DownloadItem, sessionRunning: boolean): string { + const statusText = String(item.fullStatus || "").trim(); + if (statusText === "Wartet") return ""; + if (sessionRunning) return statusText; + if (item.status !== "queued" && item.status !== "reconnect_wait") return statusText; + if (statusText === "Paket gestoppt") return statusText; + if (/^Entpacken\b/i.test(statusText) || /^Entpackt\b/i.test(statusText) || /^Entpack-Fehler\b/i.test(statusText) || /^Fertig\b/i.test(statusText)) { + return statusText; + } + return ""; +} + +interface ItemRowProps { + item: DownloadItem; + packageId: string; + isSelected: boolean; + sessionRunning: boolean; + columnOrder: string[]; + gridTemplate: string; + onSelect: (id: string, ctrlKey: boolean, shiftKey: boolean) => void; + onSelectMouseDown: (id: string, e: React.MouseEvent) => void; + onSelectMouseEnter: (id: string) => void; + onContextMenu: (packageId: string, itemId: string | undefined, x: number, y: number) => void; +} + +/** Per-item row, memoized so a status update on one item doesn't re-render + * every other item in the same package (the bottleneck on packages with + * many episodes). Custom equality only checks the fields actually rendered. */ +const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunning, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onContextMenu }: ItemRowProps): ReactElement { + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onSelect(item.id, e.ctrlKey, e.shiftKey); + }, [item.id, onSelect]); + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onSelectMouseDown(item.id, e); + }, [item.id, onSelectMouseDown]); + const handleMouseEnter = useCallback(() => { + onSelectMouseEnter(item.id); + }, [item.id, onSelectMouseEnter]); + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu(packageId, item.id, e.clientX, e.clientY); + }, [packageId, item.id, onContextMenu]); + + return ( +
+ {columnOrder.map((col) => { + switch (col) { + case "name": return ( + + {item.onlineStatus && } + {item.fileName} + + ); + case "size": { + const total = item.totalBytes || item.downloadedBytes || 0; + const dl = item.downloadedBytes || 0; + const pct = total > 0 ? Math.min(100, Math.round((dl / total) * 100)) : 0; + const label = `${humanSize(dl)} / ${humanSize(total)}`; + return ( + + {total > 0 ? ( + + + {label} + {label} + + ) : ""} + + ); + } + case "progress": return ( + + {(item.totalBytes || 0) > 0 ? ( + + + {item.progressPercent}% + {item.progressPercent}% + + ) : ""} + + ); + case "hoster": { const h = extractHoster(item.url) || ""; return {h}; } + case "account": return {item.providerLabel || (item.provider ? providerLabels[item.provider] : "")}; + case "prio": return ; + case "status": { + const displayStatus = computeDisplayedItemStatus(item, sessionRunning); + const title = !displayStatus ? "" : (item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus); + return ( + + {displayStatus} + + ); + } + case "speed": return {item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}; + case "added": return {formatDateTime(item.createdAt)}; + default: return null; + } + })} +
+ ); +}, (prev, next) => { + // Skip re-render unless something visible actually changed for THIS item. + if (prev.item !== next.item) { + const a = prev.item; + const b = next.item; + if (a.id !== b.id + || a.updatedAt !== b.updatedAt + || a.status !== b.status + || a.fileName !== b.fileName + || a.url !== b.url + || a.provider !== b.provider + || a.providerLabel !== b.providerLabel + || a.fullStatus !== b.fullStatus + || a.onlineStatus !== b.onlineStatus + || a.progressPercent !== b.progressPercent + || a.speedBps !== b.speedBps + || a.downloadedBytes !== b.downloadedBytes + || a.totalBytes !== b.totalBytes + || a.retries !== b.retries + || a.createdAt !== b.createdAt) { + return false; + } + } + if (prev.packageId !== next.packageId) return false; + if (prev.isSelected !== next.isSelected) return false; + if (prev.sessionRunning !== next.sessionRunning) return false; + if (prev.columnOrder !== next.columnOrder) return false; + if (prev.gridTemplate !== next.gridTemplate) return false; + if (prev.onSelect !== next.onSelect) return false; + if (prev.onSelectMouseDown !== next.onSelectMouseDown) return false; + if (prev.onSelectMouseEnter !== next.onSelectMouseEnter) return false; + if (prev.onContextMenu !== next.onContextMenu) return false; + return true; +}); + interface PackageCardProps { pkg: PackageEntry; items: DownloadItem[]; @@ -6151,61 +6298,48 @@ interface PackageCardProps { } const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripeVariant, isFirst, isLast, isEditing, editingName, collapsed, hideExtractedItems, sessionRunning, selectedIds, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onContextMenu, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement { - const done = items.filter((item) => item.status === "completed").length; - const failed = items.filter((item) => item.status === "failed").length; - const cancelled = items.filter((item) => item.status === "cancelled").length; - const extracted = items.filter((item) => item.fullStatus?.startsWith("Entpackt")).length; - const extracting = items.some((item) => item.fullStatus?.startsWith("Entpacken")); - const total = Math.max(1, items.length); - // Use 50/50 split when extraction is active OR package is in extracting state - // (prevents bar jumping from 100% to 50% when extraction starts) - const allDownloaded = done + failed + cancelled >= total; - const allExtracted = extracted >= total; - const useExtractSplit = extracting || pkg.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0); - // Include fractional progress from active downloads so the bar moves continuously - const activeProgress = items.reduce((sum, item) => { - if (item.status === "downloading" || (item.status === "queued" && (item.progressPercent || 0) > 0)) { - return sum + (item.progressPercent || 0) / 100; + // Single-pass aggregation: replaces 5 separate filter()/some() + 2 reduce() calls. + // For a package with N items this is O(N) instead of O(7N) per render. + const stats = useMemo(() => { + let done = 0; + let failed = 0; + let cancelled = 0; + let extracted = 0; + let extracting = false; + let activeProgress = 0; + let extractingProgress = 0; + for (const item of items) { + if (item.status === "completed") done += 1; + else if (item.status === "failed") failed += 1; + else if (item.status === "cancelled") cancelled += 1; + const fs = item.fullStatus || ""; + if (fs.startsWith("Entpackt")) { + extracted += 1; + } else if (fs.startsWith("Entpacken")) { + extracting = true; + const m = fs.match(/^Entpacken\s+(\d+)%/); + if (m) extractingProgress += Number(m[1]) / 100; + } + if (item.status === "downloading" || (item.status === "queued" && (item.progressPercent || 0) > 0)) { + activeProgress += (item.progressPercent || 0) / 100; + } } - return sum; - }, 0); - const dlProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(((done + activeProgress) / total) * (useExtractSplit ? 50 : 100))); - // Include fractional progress from items currently being extracted - const extractingProgress = items.reduce((sum, item) => { - const fs = item.fullStatus || ""; - if (fs.startsWith("Entpackt")) return sum; - const m = fs.match(/^Entpacken\s+(\d+)%/); - if (m) return sum + Number(m[1]) / 100; - return sum; - }, 0); - const exProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50)); - const combinedProgress = Math.min(100, useExtractSplit ? dlProgress + exProgress : dlProgress); + const total = Math.max(1, items.length); + const allDownloaded = done + failed + cancelled >= total; + const allExtracted = extracted >= total; + const useExtractSplit = extracting || pkg.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0); + const dlProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(((done + activeProgress) / total) * (useExtractSplit ? 50 : 100))); + const exProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50)); + const combinedProgress = Math.min(100, useExtractSplit ? dlProgress + exProgress : dlProgress); + return { done, failed, cancelled, extracted, extracting, total, useExtractSplit, dlProgress, exProgress, combinedProgress }; + }, [items, pkg.status]); + const { done, failed, cancelled, extracted, extracting, total, useExtractSplit, dlProgress, exProgress, combinedProgress } = stats; const onKeyDown = (e: ReactKeyboardEvent): void => { if (e.key === "Enter") { onFinishEdit(pkg.id, pkg.name, editingName); } if (e.key === "Escape") { onFinishEdit(pkg.id, pkg.name, pkg.name); } }; - const getDisplayedItemStatus = (item: DownloadItem): string => { - const statusText = String(item.fullStatus || "").trim(); - if (statusText === "Wartet") { - return ""; - } - if (sessionRunning) { - return statusText; - } - if (item.status !== "queued" && item.status !== "reconnect_wait") { - return statusText; - } - if (statusText === "Paket gestoppt") { - return statusText; - } - if (/^Entpacken\b/i.test(statusText) || /^Entpackt\b/i.test(statusText) || /^Entpack-Fehler\b/i.test(statusText) || /^Fertig\b/i.test(statusText)) { - return statusText; - } - return ""; - }; - return (
} {!collapsed && items.filter((item) => !hideExtractedItems || !item.fullStatus?.startsWith("Entpackt")).map((item) => ( -
{ e.stopPropagation(); onSelect(item.id, e.ctrlKey, e.shiftKey); }} onMouseDown={(e) => { e.stopPropagation(); onSelectMouseDown(item.id, e); }} onMouseEnter={() => onSelectMouseEnter(item.id)} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onContextMenu(pkg.id, item.id, e.clientX, e.clientY); }}> - {columnOrder.map((col) => { - switch (col) { - case "name": return ( - - {item.onlineStatus && } - {item.fileName} - - ); - case "size": return ( - {(() => { - const total = item.totalBytes || item.downloadedBytes || 0; - const dl = item.downloadedBytes || 0; - const pct = total > 0 ? Math.min(100, Math.round((dl / total) * 100)) : 0; - const label = `${humanSize(dl)} / ${humanSize(total)}`; - return total > 0 ? ( - - - {label} - {label} - - ) : ""; - })()} - ); - case "progress": return ( - - {(item.totalBytes || 0) > 0 ? ( - - - {item.progressPercent}% - {item.progressPercent}% - - ) : ""} - - ); - case "hoster": { const h = extractHoster(item.url) || ""; return {h}; } - case "account": return {item.providerLabel || (item.provider ? providerLabels[item.provider] : "")}; - case "prio": return ; - case "status": return ( - { - const displayStatus = getDisplayedItemStatus(item); - if (!displayStatus) { - return ""; - } - return item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus; - })()}> - {getDisplayedItemStatus(item)} - - ); - case "speed": return {item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}; - case "added": return {formatDateTime(item.createdAt)}; - default: return null; - } - })} -
+ ))}
); @@ -6369,11 +6461,21 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripe || prev.isEditing !== next.isEditing || prev.collapsed !== next.collapsed || prev.hideExtractedItems !== next.hideExtractedItems - || prev.selectedIds !== next.selectedIds || prev.columnOrder !== next.columnOrder || prev.gridTemplate !== next.gridTemplate) { return false; } + // selectedIds is a Set that gets a new reference on every selection change + // anywhere in the app. Only re-render this card if the selection state + // changed for an item that ACTUALLY belongs to this package — that way + // selecting an item in a different package doesn't re-render all 200+ cards. + if (prev.selectedIds !== next.selectedIds) { + for (const itemId of next.pkg.itemIds) { + if (prev.selectedIds.has(itemId) !== next.selectedIds.has(itemId)) { + return false; + } + } + } if ((prev.isEditing || next.isEditing) && prev.editingName !== next.editingName) { return false; }