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:
+16
-7
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user