perf(renderer): keep the repo filter array identity across no-op refetches

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 <help@stably.ai>
This commit is contained in:
Neil
2026-08-12 19:46:43 -07:00
co-authored by Orca
parent 34f490432f
commit cf5d9d32a6
6 changed files with 21 additions and 8 deletions
+3 -1
View File
@@ -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
@@ -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
@@ -186,7 +186,7 @@ export function computeVisibleWorktreeIds(
worktreesByRepo: Record<string, Worktree[]>,
sortedIds: string[],
opts: {
filterRepoIds: string[]
filterRepoIds: readonly string[]
showSleepingWorkspaces: boolean
tabsByWorktree: Record<string, Pick<TerminalTab, 'id'>[]> | null
ptyIdsByTabId: Record<string, string[]> | null
@@ -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<string>
): readonly string[] {
return filterRepoIds.every((repoId) => validRepoIds.has(repoId))
? filterRepoIds
: filterRepoIds.filter((repoId) => validRepoIds.has(repoId))
}
+4 -3
View File
@@ -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<AppState, [], [], RepoSlice> = (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<AppState, [], [], RepoSlice> = (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<AppState, [], [], RepoSlice> = (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
+2 -2
View File
@@ -904,8 +904,8 @@ export type UISlice = {
showDotfilesByWorktree: Record<string, boolean>
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<string>
toggleCollapsedGroup: (key: string) => void
worktreeCardProperties: WorktreeCardProperty[]