Performance: ItemRow extraction + scheduler single-pass + selectedIds memo fix

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) <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-04-19 13:07:42 +02:00
parent c1edb07009
commit 3c9894c7b0
2 changed files with 229 additions and 143 deletions

View File

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

View File

@ -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 (
<div
className={`item-row${isSelected ? " item-selected" : ""}`}
style={{ gridTemplateColumns: gridTemplate }}
onClick={handleClick}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onContextMenu={handleContextMenu}
>
{columnOrder.map((col) => {
switch (col) {
case "name": return (
<span key={col} className="pkg-col pkg-col-name item-indent" title={item.fileName}>
{item.onlineStatus && <span className={`link-status-dot ${item.onlineStatus}`} title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : "Wird geprüft..."} />}
{item.fileName}
</span>
);
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 (
<span key={col} className="pkg-col pkg-col-size">
{total > 0 ? (
<span className="progress-size progress-size-small">
<span className="progress-size-bar" style={{ width: `${pct}%` }} />
<span className="progress-size-text">{label}</span>
<span className="progress-size-text-filled" style={{ clipPath: `inset(0 ${100 - pct}% 0 0)` }}>{label}</span>
</span>
) : ""}
</span>
);
}
case "progress": return (
<span key={col} className="pkg-col pkg-col-progress">
{(item.totalBytes || 0) > 0 ? (
<span className="progress-inline progress-inline-small">
<span className="progress-inline-bar" style={{ width: `${item.progressPercent}%` }} />
<span className="progress-inline-text">{item.progressPercent}%</span>
<span className="progress-inline-text-filled" style={{ clipPath: `inset(0 ${100 - (item.progressPercent || 0)}% 0 0)` }}>{item.progressPercent}%</span>
</span>
) : ""}
</span>
);
case "hoster": { const h = extractHoster(item.url) || ""; return <span key={col} className="pkg-col pkg-col-hoster" title={h}>{h}</span>; }
case "account": return <span key={col} className="pkg-col pkg-col-account">{item.providerLabel || (item.provider ? providerLabels[item.provider] : "")}</span>;
case "prio": return <span key={col} className="pkg-col pkg-col-prio"></span>;
case "status": {
const displayStatus = computeDisplayedItemStatus(item, sessionRunning);
const title = !displayStatus ? "" : (item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus);
return (
<span key={col} className="pkg-col pkg-col-status" title={title}>
{displayStatus}
</span>
);
}
case "speed": return <span key={col} className="pkg-col pkg-col-speed">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>;
case "added": return <span key={col} className="pkg-col pkg-col-added">{formatDateTime(item.createdAt)}</span>;
default: return null;
}
})}
</div>
);
}, (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<HTMLInputElement>): 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 (
<article
className={`package-card queue-package-card pkg-stripe-${stripeVariant}${pkg.enabled ? "" : " disabled-pkg"}${selectedIds.has(pkg.id) ? " pkg-selected" : ""}`}
@ -6293,61 +6427,19 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripe
{useExtractSplit && <div className="progress-ex" style={{ width: `${exProgress}%` }} />}
</div>
{!collapsed && items.filter((item) => !hideExtractedItems || !item.fullStatus?.startsWith("Entpackt")).map((item) => (
<div key={item.id} className={`item-row${selectedIds.has(item.id) ? " item-selected" : ""}`} style={{ gridTemplateColumns: gridTemplate }} onClick={(e) => { 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 (
<span key={col} className="pkg-col pkg-col-name item-indent" title={item.fileName}>
{item.onlineStatus && <span className={`link-status-dot ${item.onlineStatus}`} title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : "Wird geprüft..."} />}
{item.fileName}
</span>
);
case "size": return (
<span key={col} className="pkg-col pkg-col-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 ? (
<span className="progress-size progress-size-small">
<span className="progress-size-bar" style={{ width: `${pct}%` }} />
<span className="progress-size-text">{label}</span>
<span className="progress-size-text-filled" style={{ clipPath: `inset(0 ${100 - pct}% 0 0)` }}>{label}</span>
</span>
) : "";
})()}</span>
);
case "progress": return (
<span key={col} className="pkg-col pkg-col-progress">
{(item.totalBytes || 0) > 0 ? (
<span className="progress-inline progress-inline-small">
<span className="progress-inline-bar" style={{ width: `${item.progressPercent}%` }} />
<span className="progress-inline-text">{item.progressPercent}%</span>
<span className="progress-inline-text-filled" style={{ clipPath: `inset(0 ${100 - (item.progressPercent || 0)}% 0 0)` }}>{item.progressPercent}%</span>
</span>
) : ""}
</span>
);
case "hoster": { const h = extractHoster(item.url) || ""; return <span key={col} className="pkg-col pkg-col-hoster" title={h}>{h}</span>; }
case "account": return <span key={col} className="pkg-col pkg-col-account">{item.providerLabel || (item.provider ? providerLabels[item.provider] : "")}</span>;
case "prio": return <span key={col} className="pkg-col pkg-col-prio"></span>;
case "status": return (
<span key={col} className="pkg-col pkg-col-status" title={(() => {
const displayStatus = getDisplayedItemStatus(item);
if (!displayStatus) {
return "";
}
return item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus;
})()}>
{getDisplayedItemStatus(item)}
</span>
);
case "speed": return <span key={col} className="pkg-col pkg-col-speed">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>;
case "added": return <span key={col} className="pkg-col pkg-col-added">{formatDateTime(item.createdAt)}</span>;
default: return null;
}
})}
</div>
<ItemRow
key={item.id}
item={item}
packageId={pkg.id}
isSelected={selectedIds.has(item.id)}
sessionRunning={sessionRunning}
columnOrder={columnOrder}
gridTemplate={gridTemplate}
onSelect={onSelect}
onSelectMouseDown={onSelectMouseDown}
onSelectMouseEnter={onSelectMouseEnter}
onContextMenu={onContextMenu}
/>
))}
</article>
);
@ -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;
}