From 09c7ed5cfd83d43a8e96cf761dc48847f0c4e86e Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Tue, 12 May 2026 12:44:53 -0700 Subject: [PATCH] perf(session): gate session-write subscriber on relevant field changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The App-level Zustand subscriber that debounces buildWorkspaceSessionPayload fires on every store update (agent status, usage refreshes, runtime title ticks, …). Each fire reset the 150ms timer, and when the timer eventually expired the rebuild crossed 70-110ms with many tabs open, tripping setTimeout violation warnings. Add a shallow reference-equality gate over the fields actually consumed by the payload so the timer only resets when those fields change. The field list is co-located with WorkspaceSessionSnapshot and locked to it via a compile-time exhaustiveness check, so adding a future snapshot field will fail to typecheck rather than silently skip the gate. Co-authored-by: Orca --- src/renderer/src/App.tsx | 29 ++++++++++- .../src/lib/workspace-session.test.ts | 51 ++++++++++++++++++- src/renderer/src/lib/workspace-session.ts | 39 +++++++++++++- 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7e3db4e3a62..a4eaa7ccd54 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -34,7 +34,7 @@ import { } from './runtime/sync-runtime-graph' import { useGlobalFileDrop } from './hooks/useGlobalFileDrop' import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload' -import { buildWorkspaceSessionPayload } from './lib/workspace-session' +import { buildWorkspaceSessionPayload, SESSION_RELEVANT_FIELDS } from './lib/workspace-session' import { countWorkingAgents, getWorkingAgentsPerWorktree } from './lib/agent-status' import { activateAndRevealWorktree } from './lib/worktree-activation' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' @@ -385,10 +385,37 @@ function App(): React.JSX.Element { // App's render cycle, eliminating re-renders on every tab/file/browser change. useEffect(() => { let timer: number | null = null + // Why: the subscriber fires on every store update (agent status, usage + // refreshes, runtime title ticks, …). Without this gate each fire reset + // the debounce, and when it finally expired buildWorkspaceSessionPayload + // crossed 70-110ms with many tabs, tripping setTimeout violations. Compare + // each session-feeding field by reference against the prior snapshot and + // skip both the timer reset and the rebuild when none changed. `null` + // sentinel guarantees the very first fire always proceeds. + let prev: Record | null = null const unsub = useAppStore.subscribe((state) => { if (!state.workspaceSessionReady) { return } + let changed = false + if (prev === null) { + changed = true + } else { + for (const key of SESSION_RELEVANT_FIELDS) { + if (prev[key] !== state[key]) { + changed = true + break + } + } + } + if (!changed) { + return + } + const next: Record = {} + for (const key of SESSION_RELEVANT_FIELDS) { + next[key] = state[key] + } + prev = next if (timer) { window.clearTimeout(timer) } diff --git a/src/renderer/src/lib/workspace-session.test.ts b/src/renderer/src/lib/workspace-session.test.ts index b7643733f24..43a754128e4 100644 --- a/src/renderer/src/lib/workspace-session.test.ts +++ b/src/renderer/src/lib/workspace-session.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { buildWorkspaceSessionPayload } from './workspace-session' +import { + buildWorkspaceSessionPayload, + SESSION_RELEVANT_FIELDS, + type WorkspaceSessionSnapshot +} from './workspace-session' import type { AppState } from '../store' function createSnapshot(overrides: Partial = {}): AppState { @@ -140,3 +144,48 @@ describe('buildWorkspaceSessionPayload', () => { expect(payload.activeTabTypeByWorktree).toEqual({ 'wt-2': 'terminal' }) }) }) + +describe('SESSION_RELEVANT_FIELDS', () => { + // Why: this list gates the App-level session-write debounce subscriber. + // If a future field is added to WorkspaceSessionSnapshot but not to the + // gate, the subscriber would silently stop noticing changes to that field + // and persist stale data. Listing every key here as a fixture and asserting + // the gate covers them catches the drift at test time. The compile-time + // _exhaustive check in workspace-session.ts is the primary line of defense; + // this test is the runtime backstop. + const fixture: Record = { + activeRepoId: true, + activeWorktreeId: true, + activeTabId: true, + tabsByWorktree: true, + terminalLayoutsByTabId: true, + activeTabIdByWorktree: true, + openFiles: true, + activeFileIdByWorktree: true, + activeTabTypeByWorktree: true, + browserTabsByWorktree: true, + browserPagesByWorkspace: true, + activeBrowserTabIdByWorktree: true, + browserUrlHistory: true, + unifiedTabsByWorktree: true, + groupsByWorktree: true, + layoutByWorktree: true, + activeGroupIdByWorktree: true, + sshConnectionStates: true, + repos: true, + worktreesByRepo: true, + lastKnownRelayPtyIdByTabId: true + } + + it('contains every key of WorkspaceSessionSnapshot', () => { + const fixtureKeys = Object.keys(fixture) + expect( + fixtureKeys.every((k) => (SESSION_RELEVANT_FIELDS as readonly string[]).includes(k)) + ).toBe(true) + expect(SESSION_RELEVANT_FIELDS.length).toBe(fixtureKeys.length) + }) + + it('has no duplicate entries', () => { + expect(new Set(SESSION_RELEVANT_FIELDS).size).toBe(SESSION_RELEVANT_FIELDS.length) + }) +}) diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index fe24768de90..cdda3d14aae 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -8,7 +8,7 @@ import type { import type { AppState } from '../store' import type { OpenFile } from '../store/slices/editor' -type WorkspaceSessionSnapshot = Pick< +export type WorkspaceSessionSnapshot = Pick< AppState, | 'activeRepoId' | 'activeWorktreeId' @@ -33,6 +33,43 @@ type WorkspaceSessionSnapshot = Pick< | 'lastKnownRelayPtyIdByTabId' > +// Why: the App-level Zustand subscriber that debounces session writes uses +// this list as a shallow-equality gate so it only resets the timer when a +// field that actually feeds buildWorkspaceSessionPayload changes. Keeping +// the list co-located with WorkspaceSessionSnapshot means a future field +// added to the snapshot type fails the _exhaustive check below at compile +// time, preventing the gate from silently going stale. +export const SESSION_RELEVANT_FIELDS = [ + 'activeRepoId', + 'activeWorktreeId', + 'activeTabId', + 'tabsByWorktree', + 'terminalLayoutsByTabId', + 'activeTabIdByWorktree', + 'openFiles', + 'activeFileIdByWorktree', + 'activeTabTypeByWorktree', + 'browserTabsByWorktree', + 'browserPagesByWorkspace', + 'activeBrowserTabIdByWorktree', + 'browserUrlHistory', + 'unifiedTabsByWorktree', + 'groupsByWorktree', + 'layoutByWorktree', + 'activeGroupIdByWorktree', + 'sshConnectionStates', + 'repos', + 'worktreesByRepo', + 'lastKnownRelayPtyIdByTabId' +] as const satisfies readonly (keyof WorkspaceSessionSnapshot)[] + +type _MissingSessionField = Exclude< + keyof WorkspaceSessionSnapshot, + (typeof SESSION_RELEVANT_FIELDS)[number] +> +const _exhaustive: [_MissingSessionField] extends [never] ? true : never = true +void _exhaustive + /** Build the editor-file portion of the workspace session for persistence. * Only edit-mode files are saved — diffs and conflict views are transient. */ export function buildEditorSessionData(