From 9964d1ccbf24835986ecfc2b1158fcc98bbce3fe Mon Sep 17 00:00:00 2001 From: Mark Xian Date: Sat, 4 Jul 2026 16:34:40 +0800 Subject: [PATCH] perf: overlap sidebar-scope loads with worktree scan at startup (#7225) (#7306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf: overlap sidebar-scope loads with worktree scan at startup (#7225) Continues the renderer-chain parallelization proposed in #7225 (proposal 2), on top of the merged #7266: - Run project-groups → folder-workspaces concurrently with the per-repo `git worktree list` fan-out. Neither reads the repos store nor worktrees, so a slow remote host's 15s scope RPCs no longer queue ahead of the scan. - Raise worktree refresh concurrency 5 → 8 so multi-core machines run fewer sequential scan batches, still bounded so one moment can't launch every git probe at once. Co-Authored-By: Claude Opus 4.8 (1M context) * Test overlapping sidebar scope loads and worktree hydration at startup Verify that sidebar scope loads and worktree hydration operations run concurrently before session hydration. This protects against regressions in startup performance under the perf/startup-lag optimization. Additionally, document the rationale for the worktree refresh concurrency limit in the worktrees slice. * Clarify worktree refresh concurrency comment --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/renderer/src/App.tsx | 25 +++++++++++----- src/renderer/src/app-startup-routing.test.ts | 29 +++++++++++++++++-- .../src/store/slices/worktrees.test.ts | 9 +++--- src/renderer/src/store/slices/worktrees.ts | 5 +++- 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c7b40a929..72f93b7bd 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -878,19 +878,28 @@ function App(): React.JSX.Element { // active one) so a cold start that restored a remote workspace doesn't // hide local repos. The sidebar "All hosts" scope then shows them all. await timeRendererStartupStep('fetch-repos', () => actions.fetchReposForAllHosts()) - await timeRendererStartupStep('fetch-project-groups', () => - actions.fetchProjectGroupsForAllHosts() - ) - await timeRendererStartupStep('fetch-folder-workspaces', () => - actions.fetchFolderWorkspacesForAllHosts() - ) - // Why: both of these fan out `git worktree list` per repo on the main - // process. Running them concurrently lets the main process share one + // Why: project-groups/folder-workspaces read neither the repos store nor + // worktrees, so once repos land they can overlap the per-repo + // `git worktree list` fan-out (#7225) instead of queuing ahead of it — a + // slow remote host's 15s scope RPCs no longer block the scan. folders + // still follow project-groups (they read projectGroups); all settle + // before the hydrate steps below. + const projectScopeChain = (async () => { + await timeRendererStartupStep('fetch-project-groups', () => + actions.fetchProjectGroupsForAllHosts() + ) + await timeRendererStartupStep('fetch-folder-workspaces', () => + actions.fetchFolderWorkspacesForAllHosts() + ) + })() + // Why: worktrees + lineage both fan out `git worktree list` per repo on + // the main process. Running them concurrently lets it share one // in-flight scan per repo instead of paying the process-spawn fan-out // twice back-to-back — the dominant renderer-chain cost on Windows // (issue #7225). Lineage only reads settings + its own slice, so it // does not depend on the worktrees fetch having landed. await Promise.all([ + projectScopeChain, timeRendererStartupStep('fetch-worktrees', () => actions.fetchAllWorktrees()), timeRendererStartupStep('fetch-worktree-lineage', () => actions.fetchWorktreeLineage()) ]) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index ad2f480bd..81135b32c 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -6,9 +6,7 @@ describe('renderer startup runtime routing', () => { it('loads settings before repo and worktree hydration', () => { const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') const startupBlockStart = source.indexOf('void (async () => {') - const startupBlockEnd = source.indexOf( - "const persistedUI = await timeRendererStartupStep('ui-get'" - ) + const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise') const startupBlock = source.slice(startupBlockStart, startupBlockEnd) const settingsIndex = startupBlock.indexOf('actions.fetchSettings()') @@ -17,6 +15,31 @@ describe('renderer startup runtime routing', () => { expect(settingsIndex).toBeLessThan(startupBlock.indexOf('actions.fetchAllWorktrees()')) }) + it('overlaps sidebar scope loads with worktree hydration before session hydration', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const startupBlockStart = source.indexOf('void (async () => {') + const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise') + const startupBlock = source.slice(startupBlockStart, startupBlockEnd) + + const reposIndex = startupBlock.indexOf('actions.fetchReposForAllHosts()') + const scopeChainIndex = startupBlock.indexOf('const projectScopeChain = (async () => {') + const projectGroupsIndex = startupBlock.indexOf('actions.fetchProjectGroupsForAllHosts()') + const folderWorkspacesIndex = startupBlock.indexOf('actions.fetchFolderWorkspacesForAllHosts()') + const promiseAllIndex = startupBlock.indexOf('await Promise.all([') + const awaitedScopeChainIndex = startupBlock.indexOf('projectScopeChain', promiseAllIndex) + const worktreesIndex = startupBlock.indexOf('actions.fetchAllWorktrees()') + const lineageIndex = startupBlock.indexOf('actions.fetchWorktreeLineage()') + + expect(reposIndex).toBeGreaterThanOrEqual(0) + expect(scopeChainIndex).toBeGreaterThan(reposIndex) + expect(projectGroupsIndex).toBeGreaterThan(scopeChainIndex) + expect(folderWorkspacesIndex).toBeGreaterThan(projectGroupsIndex) + expect(promiseAllIndex).toBeGreaterThan(scopeChainIndex) + expect(awaitedScopeChainIndex).toBeGreaterThan(promiseAllIndex) + expect(worktreesIndex).toBeGreaterThan(promiseAllIndex) + expect(lineageIndex).toBeGreaterThan(promiseAllIndex) + }) + it('waits for first-window startup services before terminal reconnect', () => { const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') const reconnectIndex = source.indexOf('await actions.reconnectPersistedTerminals') diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index ce3ba3176..58dfa5cb7 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -91,6 +91,7 @@ const mockApi = { globalThis.window = { api: mockApi } import { + WORKTREE_REFRESH_CONCURRENCY, createWorktreeSlice, getHostedReviewLinkMutationGenerationForTests, getHostedReviewLinkWorktreeAliasCountForTests, @@ -5648,7 +5649,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { it('bounds concurrent repo scans during hydration-time refresh', async () => { const store = createTestStore() - const repos = Array.from({ length: 7 }, (_, index) => ({ + const repos = Array.from({ length: WORKTREE_REFRESH_CONCURRENCY + 2 }, (_, index) => ({ id: `repo-${index}`, path: `/repos/${index}`, displayName: `repo-${index}`, @@ -5670,14 +5671,14 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { await store.getState().fetchAllWorktrees() - expect(maxActiveScans).toBeLessThanOrEqual(5) + expect(maxActiveScans).toBeLessThanOrEqual(WORKTREE_REFRESH_CONCURRENCY) expect(mockApi.worktrees.list).toHaveBeenCalledTimes(repos.length) expect(store.getState().hasHydratedWorktreePurge).toBe(true) }) it('bounds concurrent repo scans after the hydration purge has run', async () => { const store = createTestStore() - const repos = Array.from({ length: 7 }, (_, index) => ({ + const repos = Array.from({ length: WORKTREE_REFRESH_CONCURRENCY + 2 }, (_, index) => ({ id: `repo-${index}`, path: `/repos/${index}`, displayName: `repo-${index}`, @@ -5702,7 +5703,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { await store.getState().fetchAllWorktrees() - expect(maxActiveScans).toBeLessThanOrEqual(5) + expect(maxActiveScans).toBeLessThanOrEqual(WORKTREE_REFRESH_CONCURRENCY) expect(mockApi.worktrees.list).toHaveBeenCalledTimes(repos.length) expect(store.getState().hasHydratedWorktreePurge).toBe(true) }) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 2ddc8100a..15d44711a 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -72,7 +72,10 @@ const REMOTE_WORKTREE_LIST_PARITY_LIMIT = 10_000 const ACTIVE_WORKTREE_TERMINAL_PREP_DELAY_MS = 300 const ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS = 450 const ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS = 180 -const WORKTREE_REFRESH_CONCURRENCY = 5 +// Why: each repo's `git worktree list` is an independent main-process child, so +// a higher ceiling cuts startup scan batches (#7225) while staying bounded so +// one UI moment can't launch every git probe at once. +export const WORKTREE_REFRESH_CONCURRENCY = 8 const pendingActivationTerminalPrepCancels = new Map void>() const detachedHeadAutoDerivedDisplayNames = new Map() const folderWorkspaceWorktreeCache = new WeakMap()