perf: overlap sidebar-scope loads with worktree scan at startup (#7225) (#7306)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
Mark Xian 2026-07-04 16:34:40 +08:00 committed by GitHub
parent 0d8205ae73
commit 9964d1ccbf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 52 additions and 16 deletions

View File

@ -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())
])

View File

@ -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')

View File

@ -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)
})

View File

@ -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<string, () => void>()
const detachedHeadAutoDerivedDisplayNames = new Map<string, string>()
const folderWorkspaceWorktreeCache = new WeakMap<FolderWorkspace, Worktree>()