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
+16 -7
View File
@@ -702,8 +702,11 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L93 — SettingsView bypasses the store layer.** It calls `window.api` directly for - [ ] **L93 — SettingsView bypasses the store layer.** It calls `window.api` directly for
cookies/ffmpeg/yt-dlp/app-update/backup while using stores for settings/templates/errorlog — mixed cookies/ffmpeg/yt-dlp/app-update/backup while using stores for settings/templates/errorlog — mixed
data-access in one component. data-access in one component.
- [ ] **L94 — `summarizeQueue` recomputed every render** in [DownloadsView.tsx](src/renderer/src/components/DownloadsView.tsx) - [x] **L94 — `summarizeQueue` recomputed every render** in [DownloadsView.tsx](src/renderer/src/components/DownloadsView.tsx)
(no `useMemo`) — and again on every store change in App's taskbar effect. Memoize/share. (no `useMemo`) — and again on every store change in App's taskbar effect. Memoize/share. *Fixed: a 1-entry
items-ref memo `queueSummaryOf` in [queueStats.ts](src/renderer/src/store/queueStats.ts) — the store hands
out a new items array per change, so both App's taskbar subscription and DownloadsView's render summarize
it once per change (the second caller hits the cache) instead of twice (also closes PERF4).*
- [ ] **L95 — "Sign in" hyphenation varies** in the Cookies card ("Sign in…", "Site to sign in to", "Sign-in window", "Signing in…"). - [ ] **L95 — "Sign in" hyphenation varies** in the Cookies card ("Sign in…", "Site to sign in to", "Sign-in window", "Signing in…").
- [ ] **L96 — Ellipsis-on-dialog-buttons inconsistent.** "Export backup…/Import backup…/Sign in…" use - [ ] **L96 — Ellipsis-on-dialog-buttons inconsistent.** "Export backup…/Import backup…/Sign in…" use
`…` (opens a dialog), but "Browse" / "Check for updates" / "Update now" don't. Adopt the `…` = opens-a-dialog convention. `…` (opens a dialog), but "Browse" / "Check for updates" / "Update now" don't. Adopt the `…` = opens-a-dialog convention.
@@ -859,8 +862,10 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
double-scroll or misalign the virtualized list's viewport. double-scroll or misalign the virtualized list's viewport.
- [ ] **L162 — Per-thumbnail store subscription.** Every `MediaThumb` calls `useResolvedDark()` (two store - [ ] **L162 — Per-thumbnail store subscription.** Every `MediaThumb` calls `useResolvedDark()` (two store
subscriptions) instead of receiving a resolved theme prop — N subscriptions per list. subscriptions) instead of receiving a resolved theme prop — N subscriptions per list.
- [ ] **L163 — QueueItem makes 9 separate `useDownloads` selector calls** instead of one destructured - [x] **L163 — QueueItem makes 9 separate `useDownloads` selector calls** instead of one destructured
selection — repeated boilerplate per row. selection — repeated boilerplate per row. *Fixed: one `useShallow` selection replaces the 10 individual
subscriptions in [QueueItem.tsx](src/renderer/src/components/QueueItem.tsx) (the actions are stable, so the
shallow compare never re-renders the row).*
*Round 8 (2026-06-29) — formatting micro-inconsistencies:* *Round 8 (2026-06-29) — formatting micro-inconsistencies:*
@@ -1698,12 +1703,16 @@ acceptable first:** memoized `QueueItem`s don't re-render when their item is unc
on every add/done/error/cancel/progress-driven change; with M33's unbounded channel enqueue (thousands of on every add/done/error/cancel/progress-driven change; with M33's unbounded channel enqueue (thousands of
items) every event becomes O(n), and several events fire per second during active downloads. **Fix:** items) every event becomes O(n), and several events fire per second during active downloads. **Fix:**
track running/queued counts + a pointer instead of rescanning. track running/queued counts + a pointer instead of rescanning.
- [ ] **PERF4 — `summarizeQueue` runs twice per progress tick.** Once in App's store subscription (which - [x] **PERF4 — `summarizeQueue` runs twice per progress tick.** Once in App's store subscription (which
also pushes a taskbar IPC every tick, L25) and once in DownloadsView's render (no `useMemo`, L94) — each also pushes a taskbar IPC every tick, L25) and once in DownloadsView's render (no `useMemo`, L94) — each
O(items), every ~second per active download. **Fix:** compute once, memoize, throttle the taskbar push. O(items), every ~second per active download. **Fix:** compute once, memoize, throttle the taskbar push.
- [ ] **PERF5 — `VirtualList` gets new function identities each render.** `estimateSize`/`getKey`/ *Fixed with L94: `queueSummaryOf` computes the aggregate once per items change (shared ref cache), so
App's subscription and DownloadsView's render no longer each run it; the taskbar IPC push was already
deduped by computed value (L25).*
- [x] **PERF5 — `VirtualList` gets new function identities each render.** `estimateSize`/`getKey`/
`renderItem` are passed as inline arrows, so the virtualizer can't rely on stable references (risking `renderItem` are passed as inline arrows, so the virtualizer can't rely on stable references (risking
re-measures/cache churn). **Fix:** `useCallback`/hoist them. re-measures/cache churn). **Fix:** `useCallback`/hoist them. *Fixed: the three callbacks are hoisted to
stable module-level functions in [DownloadsView.tsx](src/renderer/src/components/DownloadsView.tsx).*
- [ ] **PERF6 — Per-thumbnail store subscriptions.** Each `MediaThumb` calls `useResolvedDark()` (two - [ ] **PERF6 — Per-thumbnail store subscriptions.** Each `MediaThumb` calls `useResolvedDark()` (two
store subscriptions) (L162); a long list creates N×2 subscriptions. **Fix:** resolve once, pass as a prop. store subscriptions) (L162); a long list creates N×2 subscriptions. **Fix:** resolve once, pass as a prop.
- [x] **PERF7 — (ref R3) O(n²) media-items rewrites** dominate the main-process cost when downloading a - [x] **PERF7 — (ref R3) O(n²) media-items rewrites** dominate the main-process cost when downloading a
+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 // 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). // owns the eager load so it survives the views becoming lazy-loaded (PERF8).
import './store/sources' import './store/sources'
import { summarizeQueue } from './store/queueStats' import { queueSummaryOf } from './store/queueStats'
import { useResolvedDark } from './store/systemTheme' import { useResolvedDark } from './store/systemTheme'
import { logError } from './reportError' import { logError } from './reportError'
@@ -82,7 +82,7 @@ function App(): React.JSX.Element {
let lastMode = '' let lastMode = ''
let lastBadge = -1 let lastBadge = -1
function push(items: ReturnType<typeof useDownloads.getState>['items']): void { 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' const mode = s.active ? (s.failed > 0 ? 'error' : 'normal') : 'none'
if (s.progress === lastFraction && mode === lastMode && s.downloading === lastBadge) return if (s.progress === lastFraction && mode === lastMode && s.downloading === lastBadge) return
lastFraction = s.progress lastFraction = s.progress
+12 -6
View File
@@ -10,13 +10,19 @@
shorthands shorthands
} from '@fluentui/react-components' } from '@fluentui/react-components'
import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons' import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'
import { useDownloads } from '../store/downloads' import { useDownloads, type DownloadItem } from '../store/downloads'
import { summarizeQueue } from '../store/queueStats' import { queueSummaryOf } from '../store/queueStats'
import { DownloadBar } from './DownloadBar' import { DownloadBar } from './DownloadBar'
import { QueueItem } from './QueueItem' import { QueueItem } from './QueueItem'
import { VirtualList } from './VirtualList' import { VirtualList } from './VirtualList'
import { ScreenHeader, useScreenStyles } from './ui/Screen' 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({ const useStyles = makeStyles({
root: { root: {
// Fill the scroll area so the queue list below can flex to the remaining // 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 clearFinished = useDownloads((s) => s.clearFinished)
const retryAll = useDownloads((s) => s.retryAll) const retryAll = useDownloads((s) => s.retryAll)
const summary = summarizeQueue(items) const summary = queueSummaryOf(items)
const hasFinished = items.some( const hasFinished = items.some(
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled' (i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
) )
@@ -146,9 +152,9 @@ export function DownloadsView(): React.JSX.Element {
className={styles.listScroll} className={styles.listScroll}
gap={10} gap={10}
overscan={6} overscan={6}
estimateSize={() => 100} estimateSize={estimateRowSize}
getKey={(item) => item.id} getKey={getItemKey}
renderItem={(item) => <QueueItem item={item} />} renderItem={renderQueueRow}
/> />
)} )}
</div> </div>
+28 -10
View File
@@ -25,6 +25,7 @@ import {
ErrorCircleFilled, ErrorCircleFilled,
EyeOffRegular EyeOffRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import { useShallow } from 'zustand/react/shallow'
import { useDownloads, type DownloadItem } from '../store/downloads' import { useDownloads, type DownloadItem } from '../store/downloads'
import { thumbUrl } from '../thumb' import { thumbUrl } from '../thumb'
import { fmtSchedule } from '../datetime' import { fmtSchedule } from '../datetime'
@@ -115,16 +116,33 @@ export const QueueItem = memo(function QueueItem({
}): React.JSX.Element { }): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const focus = useFocusStyles() const focus = useFocusStyles()
const cancel = useDownloads((s) => s.cancel) // One shallow-compared selection instead of 10 separate subscriptions per row
const pause = useDownloads((s) => s.pause) // (L163). The actions are stable, so this never re-renders from the store.
const resume = useDownloads((s) => s.resume) const {
const prioritize = useDownloads((s) => s.prioritize) cancel,
const saveForLater = useDownloads((s) => s.saveForLater) pause,
const queueNow = useDownloads((s) => s.queueNow) resume,
const remove = useDownloads((s) => s.remove) prioritize,
const retry = useDownloads((s) => s.retry) saveForLater,
const openFile = useDownloads((s) => s.openFile) queueNow,
const showInFolder = useDownloads((s) => s.showInFolder) 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' 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 ---------------------------------------------------- // --- Duplicate detection ----------------------------------------------------
function normalizeUrl(url: string): string { function normalizeUrl(url: string): string {