From cf5d9d32a60f32ec5d6889ec079e5baea2fcdfe6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:21:59 -0700 Subject: [PATCH] perf(renderer): keep the repo filter array identity across no-op refetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fetch sites unconditionally reallocated filterRepoIds (`s.filterRepoIds.filter(...)`) on every repo catalog refresh, even when nothing was pruned. Six identity-sensitive subscribers select this array, including App.tsx at the root, so every refresh woke the whole tree. retainValidFilterRepoIds returns the input when every id is still valid; `.every` short-circuits so the common case allocates nothing at all. It lives in its own module rather than ui.ts: repos.ts does not import ui.ts and this codebase has a documented circular-slice-import hazard. The store type widens to `readonly string[]`, which takes three more declaration edits (visible-worktrees, add-repo-skip-finalization, the setter). PersistedUI.filterRepoIds deliberately stays `string[]`: main owns that array, and widening it would fail the ui-state schema-parity assertion unless the zod schema gained `.readonly()`, which Object.freeze()s every parsed `ui.set` payload arriving from a paired client. Copy at the App.tsx IPC boundary instead — one allocation per debounced 150ms persist, not per render. Co-authored-by: Orca --- src/renderer/src/App.tsx | 4 +++- .../components/sidebar/add-repo-skip-finalization.ts | 2 +- .../src/components/sidebar/visible-worktrees.ts | 2 +- src/renderer/src/store/slices/repo-filter-selection.ts | 10 ++++++++++ src/renderer/src/store/slices/repos.ts | 7 ++++--- src/renderer/src/store/slices/ui.ts | 4 ++-- 6 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/store/slices/repo-filter-selection.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 643b6497e5d..0795d6221a3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1416,7 +1416,9 @@ function App(): React.JSX.Element { hideWorkspacesFromOtherDevices, alwaysShowDefaultBranchWorkspace, showDotfilesByWorktree, - filterRepoIds, + // Why: the store keeps this readonly for identity stability, but PersistedUI crosses to + // main, which owns a mutable array — copy at the boundary rather than widening the wire type. + filterRepoIds: [...filterRepoIds], // Why (#9002): activeView is deliberately NOT included here. It used to // ride this same 150ms writer (#8265), which meant every top-level view // switch scheduled a full durable-state save. The narrow preference diff --git a/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts b/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts index 4161f3bb6f9..7c9c69357ce 100644 --- a/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts +++ b/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts @@ -3,7 +3,7 @@ import { isDefaultBranchWorkspace } from './visible-worktrees' export type AddRepoSkipFinalizationState = { activeRepoId: string | null - filterRepoIds: string[] + filterRepoIds: readonly string[] showActiveOnly: boolean hideDefaultBranchWorkspace: boolean showSleepingWorkspaces: boolean diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index f85083db406..206d81dbe38 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -186,7 +186,7 @@ export function computeVisibleWorktreeIds( worktreesByRepo: Record, sortedIds: string[], opts: { - filterRepoIds: string[] + filterRepoIds: readonly string[] showSleepingWorkspaces: boolean tabsByWorktree: Record[]> | null ptyIdsByTabId: Record | null diff --git a/src/renderer/src/store/slices/repo-filter-selection.ts b/src/renderer/src/store/slices/repo-filter-selection.ts new file mode 100644 index 00000000000..65ae6d344a8 --- /dev/null +++ b/src/renderer/src/store/slices/repo-filter-selection.ts @@ -0,0 +1,10 @@ +// Why: catalog refreshes re-filter this on every fetch; six identity-sensitive subscribers +// (App.tsx at the root among them) re-render on a new array even when nothing was pruned. +export function retainValidFilterRepoIds( + filterRepoIds: readonly string[], + validRepoIds: ReadonlySet +): readonly string[] { + return filterRepoIds.every((repoId) => validRepoIds.has(repoId)) + ? filterRepoIds + : filterRepoIds.filter((repoId) => validRepoIds.has(repoId)) +} diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 25c53d3b481..01800b02896 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -48,6 +48,7 @@ import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' import { reconcileFetchedRepos } from './repo-identity-reconcile' +import { retainValidFilterRepoIds } from './repo-filter-selection' import { mergeSshRepoReadoptions, reconcileReadoptedSshRepoRows, @@ -2102,7 +2103,7 @@ export const createRepoSlice: StateCreator = (set, ? {} : { folderWorkspacePathStatuses: {} }), activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, - filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), + filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( s.setupScriptPromptDismissedRepoIds, validRepoHostIdentities @@ -2186,7 +2187,7 @@ export const createRepoSlice: StateCreator = (set, ...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions), ...mergedProjectCompatibility, activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, - filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), + filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( s.setupScriptPromptDismissedRepoIds, validRepoHostIdentities @@ -2268,7 +2269,7 @@ export const createRepoSlice: StateCreator = (set, const validRepoHostIdentities = new Set(s.repos.map(getRepoHostIdentity)) return { activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, - filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), + filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( s.setupScriptPromptDismissedRepoIds, validRepoHostIdentities diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 1bfa1d68d77..dbbc2977965 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -904,8 +904,8 @@ export type UISlice = { showDotfilesByWorktree: Record setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void toggleShowDotfilesForWorktree: (worktreeId: string) => void - filterRepoIds: string[] - setFilterRepoIds: (ids: string[]) => void + filterRepoIds: readonly string[] + setFilterRepoIds: (ids: readonly string[]) => void collapsedGroups: Set toggleCollapsedGroup: (key: string) => void worktreeCardProperties: WorktreeCardProperty[]