perf(renderer): memoize queue summary + stabilize list callbacks (L94/PERF4/PERF5/L163)

- L94/PERF4: add queueSummaryOf — a 1-entry items-ref memo over summarizeQueue.
  The store hands out a new items array per change, so App's taskbar subscription
  and DownloadsView's render compute the aggregate once per change (second caller
  hits the cache) instead of twice per tick. summarizeQueue stays pure/tested.
- PERF5: hoist VirtualList estimateSize/getKey/renderItem to stable module-level
  functions so the virtualizer doesn't churn its measure/key cache.
- L163: one useShallow selection replaces QueueItem's 10 individual useDownloads
  subscriptions (stable actions -> never re-renders the row).

typecheck + 268 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 11:09:09 -04:00
parent 0a681f864f
commit 63f0a40f0e
5 changed files with 74 additions and 25 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ import { useDownloads } from './store/downloads'
// was guaranteed by downloads.ts importing it; now that the cycle is broken, App
// owns the eager load so it survives the views becoming lazy-loaded (PERF8).
import './store/sources'
import { summarizeQueue } from './store/queueStats'
import { queueSummaryOf } from './store/queueStats'
import { useResolvedDark } from './store/systemTheme'
import { logError } from './reportError'
@@ -82,7 +82,7 @@ function App(): React.JSX.Element {
let lastMode = ''
let lastBadge = -1
function push(items: ReturnType<typeof useDownloads.getState>['items']): void {
const s = summarizeQueue(items)
const s = queueSummaryOf(items)
const mode = s.active ? (s.failed > 0 ? 'error' : 'normal') : 'none'
if (s.progress === lastFraction && mode === lastMode && s.downloading === lastBadge) return
lastFraction = s.progress
+12 -6
View File
@@ -10,13 +10,19 @@
shorthands
} from '@fluentui/react-components'
import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'
import { useDownloads } from '../store/downloads'
import { summarizeQueue } from '../store/queueStats'
import { useDownloads, type DownloadItem } from '../store/downloads'
import { queueSummaryOf } from '../store/queueStats'
import { DownloadBar } from './DownloadBar'
import { QueueItem } from './QueueItem'
import { VirtualList } from './VirtualList'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
// Hoisted so the virtualizer gets stable function identities and doesn't churn its
// measurement/key cache on every render (PERF5).
const estimateRowSize = (): number => 100
const getItemKey = (item: DownloadItem): string => item.id
const renderQueueRow = (item: DownloadItem): React.JSX.Element => <QueueItem item={item} />
const useStyles = makeStyles({
root: {
// Fill the scroll area so the queue list below can flex to the remaining
@@ -77,7 +83,7 @@ export function DownloadsView(): React.JSX.Element {
const clearFinished = useDownloads((s) => s.clearFinished)
const retryAll = useDownloads((s) => s.retryAll)
const summary = summarizeQueue(items)
const summary = queueSummaryOf(items)
const hasFinished = items.some(
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
)
@@ -146,9 +152,9 @@ export function DownloadsView(): React.JSX.Element {
className={styles.listScroll}
gap={10}
overscan={6}
estimateSize={() => 100}
getKey={(item) => item.id}
renderItem={(item) => <QueueItem item={item} />}
estimateSize={estimateRowSize}
getKey={getItemKey}
renderItem={renderQueueRow}
/>
)}
</div>
+28 -10
View File
@@ -25,6 +25,7 @@ import {
ErrorCircleFilled,
EyeOffRegular
} from '@fluentui/react-icons'
import { useShallow } from 'zustand/react/shallow'
import { useDownloads, type DownloadItem } from '../store/downloads'
import { thumbUrl } from '../thumb'
import { fmtSchedule } from '../datetime'
@@ -115,16 +116,33 @@ export const QueueItem = memo(function QueueItem({
}): React.JSX.Element {
const styles = useStyles()
const focus = useFocusStyles()
const cancel = useDownloads((s) => s.cancel)
const pause = useDownloads((s) => s.pause)
const resume = useDownloads((s) => s.resume)
const prioritize = useDownloads((s) => s.prioritize)
const saveForLater = useDownloads((s) => s.saveForLater)
const queueNow = useDownloads((s) => s.queueNow)
const remove = useDownloads((s) => s.remove)
const retry = useDownloads((s) => s.retry)
const openFile = useDownloads((s) => s.openFile)
const showInFolder = useDownloads((s) => s.showInFolder)
// One shallow-compared selection instead of 10 separate subscriptions per row
// (L163). The actions are stable, so this never re-renders from the store.
const {
cancel,
pause,
resume,
prioritize,
saveForLater,
queueNow,
remove,
retry,
openFile,
showInFolder
} = useDownloads(
useShallow((s) => ({
cancel: s.cancel,
pause: s.pause,
resume: s.resume,
prioritize: s.prioritize,
saveForLater: s.saveForLater,
queueNow: s.queueNow,
remove: s.remove,
retry: s.retry,
openFile: s.openFile,
showInFolder: s.showInFolder
}))
)
const active = item.status === 'downloading' || item.status === 'queued'
+16
View File
@@ -66,6 +66,22 @@ export function summarizeQueue(items: DownloadItem[]): QueueSummary {
}
}
// A 1-entry memo of the last summary, keyed on the `items` array reference. The
// store hands out a new `items` array on every change, and both App's taskbar
// subscription and DownloadsView's render summarize that same array per change — so
// caching by reference computes the O(n) aggregate once per change instead of twice
// (PERF4 / L94). `summarizeQueue` stays pure for the unit tests.
let cachedItems: DownloadItem[] | null = null
let cachedSummary: QueueSummary | null = null
/** Memoized `summarizeQueue`: recomputes only when the `items` reference changes. */
export function queueSummaryOf(items: DownloadItem[]): QueueSummary {
if (items === cachedItems && cachedSummary) return cachedSummary
cachedItems = items
cachedSummary = summarizeQueue(items)
return cachedSummary
}
// --- Duplicate detection ----------------------------------------------------
function normalizeUrl(url: string): string {