From 41dd895e126644f3659c7ddfcd3b83a3a7998cb6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 14:20:21 -0500 Subject: [PATCH] perf(desktop): stop the statusbar re-rendering per streaming token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The statusbar subscribed to `$focusedSessionState` — a projection of `$sessionStates`, which is republished on every message delta — but reads only three fields off it. Every token therefore re-ran useStatusbarItems and rebuilt all ~9 item objects, and since StatusbarItemView was not memoized, one changed item (the running timer) re-rendered the whole bar. Adds `useStoreSelector` beside the existing `useSessionSlice`, the same narrowing idea for a scalar instead of a keyed array: subscribe to the store, but bail out unless the selected value changes. Applies it to the three fields the statusbar actually reads, and memoizes StatusbarItemView. Measured over five concurrent streaming tabs (`render-churn`): total renders 78,385 -> 21,701 (-72%) wasted renders 10,432 -> 6,112 (-41%) TooltipContent 2,304 -> 143 (-94%) StatusbarItemView 2,174 -> 0 --- .../app/shell/hooks/use-statusbar-items.tsx | 20 ++++++++--- .../src/app/shell/statusbar-controls.tsx | 18 ++++++++-- apps/desktop/src/lib/use-session-slice.ts | 34 ++++++++++++++++++- 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 8033e7f7e005..a2841a1bbe85 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -12,6 +12,7 @@ import { useI18n } from '@/i18n' import { Activity, AlertCircle, Clock, Command, FolderOpen, Globe, Hash, Loader2, Terminal } from '@/lib/icons' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' +import { useStoreSelector } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' import { copyFilePath, revealFile } from '@/store/file-actions' import { revealFileInTree } from '@/store/layout' @@ -117,19 +118,30 @@ export function useStatusbarItems({ // tile makes the statusbar describe THAT session. const focusedStoredSessionId = useStore($focusedStoredSessionId) const focusedRuntimeId = useStore($focusedRuntimeId) - const focusedState = useStore($focusedSessionState) + // `$focusedSessionState` is a projection of `$sessionStates`, which is + // republished on EVERY message delta — tens of times a second during a turn. + // Only three fields are read off it here, so subscribing to the whole object + // re-ran this hook (and re-created all ~9 statusbar items) per token. Select + // each field individually so an unchanged readout bails out instead. + const focusedBusy = useStoreSelector($focusedSessionState, state => Boolean(state?.busy)) + const focusedTurnStartedAt = useStoreSelector($focusedSessionState, state => state?.turnStartedAt ?? null) + // `usage` is an object, so it can't be compared as a scalar. It IS however + // replaced wholesale rather than mutated, and only changes when the backend + // reports new usage — far rarer than a delta — so its reference is a valid + // bail-out key on its own. + const focusedUsage = useStoreSelector($focusedSessionState, state => state?.usage ?? null) const sessions = useStore($sessions) const selectedStoredSessionId = useStore($selectedStoredSessionId) const primaryFocused = !focusedStoredSessionId || focusedStoredSessionId === selectedStoredSessionId const activeSessionId = primaryFocused ? primaryActiveSessionId : (focusedRuntimeId ?? null) - const busy = primaryFocused ? primaryBusy : Boolean(focusedState?.busy) + const busy = primaryFocused ? primaryBusy : focusedBusy // EMPTY_USAGE (module constant) keeps the fallback referentially stable — // a fresh `{...}` each render would bust the usage-label memos below. - const currentUsage = primaryFocused ? primaryUsage : (focusedState?.usage ?? EMPTY_USAGE) + const currentUsage = primaryFocused ? primaryUsage : (focusedUsage ?? EMPTY_USAGE) - const turnStartedAt = primaryFocused ? primaryTurnStartedAt : (focusedState?.turnStartedAt ?? null) + const turnStartedAt = primaryFocused ? primaryTurnStartedAt : focusedTurnStartedAt // A tile's session-start comes from its stored row (the cache only knows // runtime state); seconds → ms. diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx index 0b265bb3d3cf..58376559ce09 100644 --- a/apps/desktop/src/app/shell/statusbar-controls.tsx +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -1,4 +1,4 @@ -import { type ComponentProps, type ReactNode, useState } from 'react' +import { type ComponentProps, memo, type ReactNode, useState } from 'react' import { useNavigate } from 'react-router-dom' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' @@ -95,7 +95,19 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr ) } -function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType }) { +/** Memoized: `useStatusbarItems` rebuilds the item array whenever ANY of its + * inputs change, but each individual item object is usually identical across + * those rebuilds. Without this, one changed item (the running timer, say) + * re-rendered every other item in the bar — measured at 1,446 wasted renders + * of 2,174 during a five-tab streaming run. `navigate` is stable for the + * router's lifetime, so item identity is the only real input. */ +const StatusbarItemView = memo(function StatusbarItemView({ + item, + navigate +}: { + item: StatusbarItem + navigate: ReturnType +}) { const [menuOpen, setMenuOpen] = useState(false) // Render escape hatch: the contribution owns its own chrome/state/tooltip. @@ -230,4 +242,4 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ) -} +}) diff --git a/apps/desktop/src/lib/use-session-slice.ts b/apps/desktop/src/lib/use-session-slice.ts index 78ab03913379..f96d0017420f 100644 --- a/apps/desktop/src/lib/use-session-slice.ts +++ b/apps/desktop/src/lib/use-session-slice.ts @@ -1,4 +1,4 @@ -import { useSyncExternalStore } from 'react' +import { useCallback, useRef, useSyncExternalStore } from 'react' interface SliceStore { get(): Record @@ -29,3 +29,35 @@ export function useSessionSlice(store: SliceStore, key: string | null): T[ () => (key ? (store.get()[key] ?? (EMPTY as unknown as T[])) : (EMPTY as unknown as T[])) ) } + +interface ReadableStore { + get(): T + listen(listener: () => void): () => void +} + +/** + * Subscribe to a SCALAR derived from a hot store, re-rendering only when that + * scalar changes by `Object.is` — not on every write to the store it came from. + * + * `useStore($someHotStore)` bails out on reference equality alone, so a store + * republished per streaming token re-renders every consumer even when the two + * or three fields they actually read are identical. `$sessionStates` is the + * canonical case: it is republished on every message delta, so a component + * reading only `busy` or `turnStartedAt` off it pays for the whole transcript's + * churn. + * + * `select` must return a PRIMITIVE (or a referentially stable value). Returning + * a fresh object or array defeats the bail-out and reintroduces the churn this + * exists to remove — derive one scalar per call instead. + */ +export function useStoreSelector(store: ReadableStore, select: (value: T) => S): S { + // `select` is read through a ref so an inline arrow at the call site doesn't + // resubscribe on every render; useSyncExternalStore re-reads the snapshot on + // each render anyway, so the latest selector is always applied. + const selectRef = useRef(select) + selectRef.current = select + + const subscribe = useCallback((onChange: () => void) => store.listen(onChange), [store]) + + return useSyncExternalStore(subscribe, () => selectRef.current(store.get())) +}