diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index ce23534..b5ac074 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -2149,12 +2149,14 @@ export class DownloadManager extends EventEmitter { } private resetSessionTotalsIfQueueEmpty(force = false): void { + // Cheap O(1) check via cached counters covers the common case. + // The Object.keys() cross-check below was redundant — itemCount and + // packageOrder are kept in sync with session.items / session.packages + // by every mutation site, so the second check just allocated two + // arrays per call without ever changing the outcome. if (this.itemCount > 0 || this.session.packageOrder.length > 0) { return; } - if (Object.keys(this.session.items).length > 0 || Object.keys(this.session.packages).length > 0) { - return; - } if (!force && (this.sessionDownloadedBytes > 0 || this.sessionCompletedFiles > 0 || this.itemContributedBytes.size > 0)) { return; } @@ -7566,22 +7568,25 @@ export class DownloadManager extends EventEmitter { let changed = false; const waitSeconds = Math.max(0, Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)); const waitText = `Reconnect-Wait (${waitSeconds}s)`; - const itemIds = this.runItemIds.size > 0 ? this.runItemIds : Object.keys(this.session.items); - for (const itemId of itemIds) { + // Iterate without allocating an Object.keys() array (called every 900ms + // during reconnect; with 5000+ items that's a 5000-string allocation per tick). + const updateItem = (itemId: string): void => { const item = this.session.items[itemId]; - if (!item) { - continue; - } + if (!item) return; const pkg = this.session.packages[item.packageId]; - if (!pkg || pkg.cancelled || !pkg.enabled) { - continue; - } + if (!pkg || pkg.cancelled || !pkg.enabled) return; if (item.status === "queued") { item.status = "reconnect_wait"; item.fullStatus = waitText; item.updatedAt = nowMs(); changed = true; } + }; + if (this.runItemIds.size > 0) { + for (const itemId of this.runItemIds) updateItem(itemId); + } else { + // for-in iterates own enumerable string keys without allocating an array + for (const itemId in this.session.items) updateItem(itemId); } if (changed) { this.emitState(); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b59f672..4a891a1 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1673,15 +1673,22 @@ export function App(): ReactElement { showToast("Accounts-Spalten zurückgesetzt", 1800); }, []); - // Sync column order from settings (value-based comparison to avoid reference issues) - const columnOrderJson = JSON.stringify(snapshot.settings.columnOrder); + // Sync column order from settings. Avoid JSON.stringify on every render + // (which was a 7-element array stringify per snapshot tick). A simple + // join() is one O(n) string concat without Object/Array allocation overhead, + // and useMemo caches the resulting key so React only sees a new dep when the + // contents actually changed. + const columnOrderKey = useMemo( + () => (snapshot.settings.columnOrder || []).join("|"), + [snapshot.settings.columnOrder] + ); useEffect(() => { const order = snapshot.settings.columnOrder; if (order && order.length > 0) { setColumnOrder(order); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [columnOrderJson]); + }, [columnOrderKey]); const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0]; @@ -2057,14 +2064,25 @@ export function App(): ReactElement { const hiddenPackageCount = shouldLimitPackageRendering ? Math.max(0, totalPackageCount - packages.length) : 0; + // The sort-by-progress logic only runs when the session is running AND auto-sort + // is enabled AND there's more than one package. When any of those isn't true, + // the items reference is irrelevant — passing null here makes useMemo skip the + // re-evaluation that previously fired on EVERY item update (progress, status, + // speed) even when the sort would have returned the original `packages` array. + const sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1) + ? snapshot.session.items + : null; const visiblePackages = useMemo(() => { + if (!sortRelevantItems) { + return packages; + } return sortPackagesForDisplay( packages, - snapshot.session.items, - snapshot.session.running, - settingsDraft.autoSortPackagesByProgress + sortRelevantItems, + true, + true ); - }, [packages, settingsDraft.autoSortPackagesByProgress, snapshot.session.running, snapshot.session.items]); + }, [packages, sortRelevantItems]); const hasSavedAllDebridAccount = Boolean(snapshot.settings.allDebridUseWebLogin || snapshot.settings.allDebridToken.trim()); const allDebridSettingsDirty = snapshot.settings.allDebridUseWebLogin !== settingsDraft.allDebridUseWebLogin @@ -6164,6 +6182,12 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn e.stopPropagation(); onContextMenu(packageId, item.id, e.clientX, e.clientY); }, [packageId, item.id, onContextMenu]); + // Memoize the date string so it doesn't get re-formatted on every re-render + // when only progress/speed changed but createdAt is stable. + const formattedCreatedAt = useMemo(() => formatDateTime(item.createdAt), [item.createdAt]); + // Memoize the displayed status so we don't compute it twice (title + body) + const displayStatus = useMemo(() => computeDisplayedItemStatus(item, sessionRunning), [item, sessionRunning]); + const statusTitle = displayStatus ? (item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus) : ""; return (