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