From b44df2e9f1abb565dd5e865d97983b8e2b8cf1e6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:42:27 -0700 Subject: [PATCH] perf(renderer): index GitHub resume lookups (#13698) * perf(renderer): index GitHub resume lookups * perf(renderer): skip the resume cache sweep publication when nothing changed Every eviction helper already returns its input reference untouched when it evicts nothing, so the opening sweep in refreshAllGitHub can compare identities and return the previous state. Window resume then stops waking every store subscriber on a no-op sweep. Folds in the remaining unique optimization from #13711, which this PR supersedes on the lookup-indexing side. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../slices/github-repo-lookup-index.test.ts | 66 +++ .../store/slices/github-repo-lookup-index.ts | 72 +++ src/renderer/src/store/slices/github.test.ts | 441 ++++++++++++++++++ src/renderer/src/store/slices/github.ts | 98 ++-- 4 files changed, 646 insertions(+), 31 deletions(-) create mode 100644 src/renderer/src/store/slices/github-repo-lookup-index.test.ts create mode 100644 src/renderer/src/store/slices/github-repo-lookup-index.ts diff --git a/src/renderer/src/store/slices/github-repo-lookup-index.test.ts b/src/renderer/src/store/slices/github-repo-lookup-index.test.ts new file mode 100644 index 00000000000..41918b28c07 --- /dev/null +++ b/src/renderer/src/store/slices/github-repo-lookup-index.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { getGitHubRepoLookupIndex } from './github-repo-lookup-index' + +function makeRepo(id: string, path: string): Repo { + return { id, path, displayName: id, badgeColor: 'blue', addedAt: 1, kind: 'git' } +} + +describe('getGitHubRepoLookupIndex', () => { + it('retains the first duplicate after indexing later rows', () => { + const first = makeRepo('duplicate', '/first') + const second = makeRepo('duplicate', '/second') + const later = makeRepo('later', '/later') + const lookup = getGitHubRepoLookupIndex([first, second, later]) + + expect(lookup.findById('duplicate')).toBe(first) + expect(lookup.findById('later')).toBe(later) + expect(lookup.findById('duplicate')).toBe(first) + }) + + it('matches Array.find ordering for combined ID and path lookups', () => { + const pathMatch = makeRepo('other', '/target') + const idMatch = makeRepo('target', '/other') + const lookup = getGitHubRepoLookupIndex([pathMatch, idMatch]) + + expect(lookup.findByIdOrPath('target', '/target')).toBe(pathMatch) + }) + + it('scans each repo once across repeated misses', () => { + let idReads = 0 + const repos = Array.from({ length: 32 }, (_, index) => { + const repo = makeRepo(`repo-${index}`, `/repo-${index}`) + return Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + idReads += 1 + return `repo-${index}` + } + }) + }) + const lookup = getGitHubRepoLookupIndex(repos) + + expect(lookup.findById('missing-first')).toBeUndefined() + expect(lookup.findById('missing-second')).toBeUndefined() + expect(idReads).toBe(repos.length) + }) + + it('stops after a sparse first-row match', () => { + let idReads = 0 + const repos = Array.from({ length: 32 }, (_, index) => { + const repo = makeRepo(`repo-${index}`, `/repo-${index}`) + return Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + idReads += 1 + return `repo-${index}` + } + }) + }) + + expect(getGitHubRepoLookupIndex(repos).findById('repo-0')).toBe(repos[0]) + expect(idReads).toBe(1) + }) +}) diff --git a/src/renderer/src/store/slices/github-repo-lookup-index.ts b/src/renderer/src/store/slices/github-repo-lookup-index.ts new file mode 100644 index 00000000000..0c21a2fbd50 --- /dev/null +++ b/src/renderer/src/store/slices/github-repo-lookup-index.ts @@ -0,0 +1,72 @@ +import type { Repo } from '../../../../shared/types' + +type IndexedRepo = { + index: number + repo: Repo +} + +export type GitHubRepoLookupIndex = { + findById: (repoId: string) => Repo | undefined + findByPath: (repoPath: string) => Repo | undefined + findByIdOrPath: (repoId: string | undefined, repoPath: string) => Repo | undefined +} + +// Why: repo identity/path updates replace this array, while weak ownership avoids retaining superseded snapshots. +const lookupByRepos = new WeakMap() +const EMPTY_REPOS: readonly Repo[] = [] + +export function getGitHubRepoLookupIndex( + repos: readonly Repo[] | undefined +): GitHubRepoLookupIndex { + const repoRows = repos ?? EMPTY_REPOS + const cached = lookupByRepos.get(repoRows) + if (cached) { + return cached + } + + const byId = new Map() + const byPath = new Map() + let indexedCount = 0 + + const scanUntil = (matches: (repoId: string, repoPath: string) => boolean): Repo | undefined => { + while (indexedCount < repoRows.length) { + const index = indexedCount + const repo = repoRows[index] + indexedCount += 1 + const repoId = repo.id + const repoPath = repo.path + if (!byId.has(repoId)) { + byId.set(repoId, { index, repo }) + } + if (!byPath.has(repoPath)) { + byPath.set(repoPath, { index, repo }) + } + if (matches(repoId, repoPath)) { + return repo + } + } + return undefined + } + + const lookup: GitHubRepoLookupIndex = { + findById: (repoId) => byId.get(repoId)?.repo ?? scanUntil((id) => id === repoId), + findByPath: (repoPath) => + byPath.get(repoPath)?.repo ?? scanUntil((_id, path) => path === repoPath), + findByIdOrPath: (repoId, repoPath) => { + if (!repoId) { + return lookup.findByPath(repoPath) + } + const idMatch = byId.get(repoId) + const pathMatch = byPath.get(repoPath) + if (idMatch || pathMatch) { + if (!pathMatch || (idMatch && idMatch.index < pathMatch.index)) { + return idMatch?.repo + } + return pathMatch.repo + } + return scanUntil((id, path) => id === repoId || path === repoPath) + } + } + lookupByRepos.set(repoRows, lookup) + return lookup +} diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index cadbc5acb8f..4b7d19ad284 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -5425,6 +5425,447 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { describe('createGitHubSlice.refreshAllGitHub', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() + }) + + it('publishes no store update when the cache sweep changes nothing', () => { + const store = createTestStore() + store.setState({ + repos: [{ id: 'repo-1', path: '/repo', name: 'repo', kind: 'git' }], + groupBy: 'repo', + worktreeCardProperties: ['comment'], + rightSidebarOpen: false, + worktreesByRepo: { 'repo-1': [makePRRefreshWorktree()] } + } as unknown as Partial) + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + store.getState().refreshAllGitHub() + unsubscribe() + + expect(publications).toBe(0) + }) + + it('still clears populated comments when no workspace refresh is needed', () => { + const store = createTestStore() + store.setState({ + commentsCache: { cached: { data: [], fetchedAt: 1 } }, + groupBy: 'repo', + worktreeCardProperties: ['comment'], + rightSidebarOpen: false + } as unknown as Partial) + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + store.getState().refreshAllGitHub() + unsubscribe() + + expect(store.getState().commentsCache).toEqual({}) + expect(publications).toBe(1) + }) + + it('skips repo and worktree identity reads when no GitHub decoration is visible', () => { + const store = createTestStore() + const repoId = 'repo-idle' + let repoIdentityReads = 0 + let worktreeRepoIdentityReads = 0 + const repo = { id: repoId, path: '/idle', name: 'idle', kind: 'git' as const } + const worktree = makePRRefreshWorktree({ + id: 'wt-idle', + repoId, + path: '/idle/worktrees/idle', + branch: 'feature/idle' + }) + Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + Object.defineProperty(worktree, 'repoId', { + configurable: true, + enumerable: true, + get: () => { + worktreeRepoIdentityReads += 1 + return repoId + } + }) + store.setState({ + repos: [repo], + groupBy: 'repo', + worktreeCardProperties: ['comment'], + rightSidebarOpen: false, + commentsCache: { cached: { data: [], fetchedAt: 1 } }, + worktreesByRepo: { [repoId]: [worktree] } + } as unknown as Partial) + repoIdentityReads = 0 + worktreeRepoIdentityReads = 0 + + store.getState().refreshAllGitHub() + + expect(store.getState().commentsCache).toEqual({}) + expect(repoIdentityReads).toBe(0) + expect(worktreeRepoIdentityReads).toBe(0) + expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled() + expect(mockApi.gh.issue).not.toHaveBeenCalled() + }) + + it('bounds stale PR repo identity reads to a constant amount per repo and worktree', () => { + const store = createTestStore() + const repoCount = 128 + let repoIdentityReads = 0 + const repoIds = Array.from({ length: repoCount }, (_, index) => `repo-${index}`) + const repos = repoIds.map((repoId) => { + const repo = { id: repoId, path: `/${repoId}`, name: repoId, kind: 'git' as const } + return Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + }) + const worktreesByRepo = Object.fromEntries( + repoIds.map((repoId, index) => [ + repoId, + [ + makePRRefreshWorktree({ + id: `wt-${index}`, + repoId, + path: `/${repoId}/worktrees/feature`, + branch: `feature/${index}`, + lastActivityAt: index + }) + ] + ]) + ) + store.setState({ + repos, + groupBy: 'repo', + worktreeCardProperties: ['pr'], + rightSidebarOpen: false, + worktreesByRepo + } as unknown as Partial) + repoIdentityReads = 0 + + store.getState().refreshAllGitHub() + + expect(repoIdentityReads).toBeLessThanOrEqual(repoCount * 5) + expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledTimes(5) + }) + + it('stops repo indexing after a sparse enabled match', () => { + const store = createTestStore() + const repoCount = 128 + let repoIdentityReads = 0 + const repos = Array.from({ length: repoCount }, (_, index) => { + const repoId = `repo-${index}` + const repo = { id: repoId, path: `/${repoId}`, name: repoId, kind: 'git' as const } + return Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + }) + store.setState({ + repos, + groupBy: 'repo', + worktreeCardProperties: ['pr'], + rightSidebarOpen: false, + worktreesByRepo: { + 'repo-0': [ + makePRRefreshWorktree({ + id: 'wt-sparse', + repoId: 'repo-0', + path: '/repo-0/worktrees/feature', + branch: 'feature/sparse' + }) + ] + } + } as unknown as Partial) + repoIdentityReads = 0 + + store.getState().refreshAllGitHub() + + expect(repoIdentityReads).toBeLessThanOrEqual(5) + expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledOnce() + }) + + it('does not restart repo indexing for repeated missing IDs', () => { + const store = createTestStore() + const repoCount = 128 + let repoIdentityReads = 0 + const repos = Array.from({ length: repoCount }, (_, index) => { + const repoId = `repo-${index}` + const repo = { id: repoId, path: `/${repoId}`, name: repoId, kind: 'git' as const } + return Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + }) + const missingWorktrees = Array.from({ length: repoCount }, (_, index) => + makePRRefreshWorktree({ + id: `wt-missing-${index}`, + repoId: `missing-${index}`, + branch: `feature/missing-${index}` + }) + ) + store.setState({ + repos, + groupBy: 'repo', + worktreeCardProperties: ['pr'], + rightSidebarOpen: false, + worktreesByRepo: { missing: missingWorktrees } + } as unknown as Partial) + repoIdentityReads = 0 + + store.getState().refreshAllGitHub() + + expect(repoIdentityReads).toBeLessThanOrEqual(repoCount) + expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled() + }) + + it('keeps runtime PR dispatch identity reads linear in PR-status grouping', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-linear-pr', + ok: true, + result: makePR({ number: 12 }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoCount = 128 + let repoIdentityReads = 0 + let worktreeIdentityReads = 0 + const repos = Array.from({ length: repoCount }, (_, index) => { + const repoId = `runtime-repo-${index}` + const repoPath = `/runtime/repo-${index}` + const repo = { + id: repoId, + path: repoPath, + name: repoId, + kind: 'git' as const, + executionHostId: 'runtime:env-1' + } + Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + return Object.defineProperty(repo, 'path', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoPath + } + }) + }) + const worktreesByRepo = Object.fromEntries( + repos.map((repo, index) => { + const worktreeId = `runtime-wt-${index}` + const worktree = makePRRefreshWorktree({ + id: worktreeId, + repoId: repo.id, + path: `${repo.path}/worktrees/feature`, + branch: `feature/${index}`, + linkedPR: 12, + lastActivityAt: index + }) + Object.defineProperty(worktree, 'id', { + configurable: true, + enumerable: true, + get: () => { + worktreeIdentityReads += 1 + return worktreeId + } + }) + return [repo.id, [worktree]] + }) + ) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos, + groupBy: 'pr-status', + worktreeCardProperties: ['comment'], + worktreesByRepo + } as unknown as Partial) + repoIdentityReads = 0 + worktreeIdentityReads = 0 + + store.getState().refreshAllGitHub() + + await vi.waitFor(() => expect(Object.keys(store.getState().prCache)).toHaveLength(repoCount)) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(repoCount) + expect(repoIdentityReads).toBeLessThanOrEqual(repoCount * 20) + expect(worktreeIdentityReads).toBeLessThanOrEqual(repoCount * 5) + }) + + it('keeps runtime issue dispatch repo identity reads linear', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-linear-issue', + ok: true, + result: null, + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoCount = 128 + let repoIdentityReads = 0 + const repos = Array.from({ length: repoCount }, (_, index) => { + const repoId = `issue-repo-${index}` + const repoPath = `/runtime/issues-${index}` + const repo = { + id: repoId, + path: repoPath, + name: repoId, + kind: 'git' as const, + executionHostId: 'runtime:env-1' + } + Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + return Object.defineProperty(repo, 'path', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoPath + } + }) + }) + const worktreesByRepo = Object.fromEntries( + repos.map((repo, index) => [ + repo.id, + [ + makePRRefreshWorktree({ + id: `issue-wt-${index}`, + repoId: repo.id, + path: `${repo.path}/worktrees/feature`, + linkedIssue: index + 1 + }) + ] + ]) + ) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos, + groupBy: 'repo', + worktreeCardProperties: ['issue'], + worktreesByRepo + } as unknown as Partial) + repoIdentityReads = 0 + + store.getState().refreshAllGitHub() + + await vi.waitFor(() => expect(Object.keys(store.getState().issueCache)).toHaveLength(repoCount)) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(repoCount) + expect(repoIdentityReads).toBeLessThanOrEqual(repoCount * 15) + }) + + it('keeps the first repo owner when duplicate IDs span hosts', () => { + const store = createTestStore() + const repoId = 'duplicate-repo' + const firstRepo = { + id: repoId, + path: '/first', + name: 'first', + kind: 'git' as const, + connectionId: 'first', + executionHostId: 'ssh:first' + } + const secondRepo = { + id: repoId, + path: '/second', + name: 'second', + kind: 'git' as const, + connectionId: 'second', + executionHostId: 'ssh:second' + } + const laterRepo = { + id: 'later-repo', + path: '/later', + name: 'later', + kind: 'git' as const + } + store.setState({ + repos: [firstRepo, secondRepo, laterRepo], + groupBy: 'repo', + worktreeCardProperties: ['pr'], + rightSidebarOpen: false, + sshConnectionStates: new Map([ + ['first', { status: 'connected' }], + ['second', { status: 'connected' }] + ]), + worktreesByRepo: { + first: [ + makePRRefreshWorktree({ + id: 'wt-duplicate-first', + repoId, + path: '/first/worktrees/feature', + branch: 'feature/duplicate-first' + }) + ], + middle: [ + makePRRefreshWorktree({ + id: 'wt-later', + repoId: laterRepo.id, + path: '/later/worktrees/feature', + branch: 'feature/later' + }) + ], + last: [ + makePRRefreshWorktree({ + id: 'wt-duplicate-last', + repoId, + path: '/first/worktrees/last', + branch: 'feature/duplicate-last' + }) + ] + } + } as unknown as Partial) + + store.getState().refreshAllGitHub() + + const duplicateCandidates = mockApi.gh.enqueuePRRefresh.mock.calls + .map(([call]) => call.candidate) + .filter((candidate) => candidate.repoId === repoId) + expect(duplicateCandidates).toHaveLength(2) + expect(duplicateCandidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + repoPath: '/first', + connectionId: 'first', + executionHostId: 'ssh:first' + }), + expect.objectContaining({ + repoPath: '/first', + connectionId: 'first', + executionHostId: 'ssh:first' + }) + ]) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) it('refreshes stale PR data when source control is the visible PR surface', () => { diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 21b66f2e9bd..5b16b72796c 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -76,6 +76,7 @@ import { } from '../../../../shared/task-source-context' import { normalizeGitHubPRForBranchOutcome } from '../../../../shared/github-pr-for-branch-outcome' import { restoreReactionOnSubject, setReactionOnSubject } from '@/lib/pr-comment-reactions' +import { getGitHubRepoLookupIndex } from './github-repo-lookup-index' // ─── ProjectV2 cache types ──────────────────────────────────────────── // Why: separate from CacheEntry — project-view has a single GraphQL source (no issue/PR fallback) and a distinct error union. @@ -125,7 +126,7 @@ function getRuntimeRepoTarget( if (target.kind !== 'environment') { return null } - const repo = state.repos.find((candidate) => candidate.path === repoPath) + const repo = getGitHubRepoLookupIndex(state.repos).findByPath(repoPath) return repo ? { target, repo } : null } @@ -249,9 +250,9 @@ function findRepoForGitHubOwner( repoId: string | undefined, repoPath: string ): Repo | undefined { - return (state.repos ?? []).find((candidate) => - repoId ? candidate.id === repoId || candidate.path === repoPath : candidate.path === repoPath - ) + return state.repos + ? getGitHubRepoLookupIndex(state.repos).findByIdOrPath(repoId, repoPath) + : undefined } function getGitHubFocusedRepoOwnerHostId( @@ -962,13 +963,7 @@ function getPRChecksCacheTtl(entry: CacheEntry | undefined): nu } function findWorktreeById(state: AppState, worktreeId: string): Worktree | null { - for (const worktrees of Object.values(state.worktreesByRepo)) { - const worktree = worktrees.find((w) => w.id === worktreeId) - if (worktree) { - return worktree - } - } - return null + return getWorktreeLookupIndex(state).byId.get(worktreeId)?.first ?? null } type WorktreeLookupEntry = { @@ -981,9 +976,12 @@ type WorktreeLookupIndex = { repoHostIdsByRepoId: Map> } +const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {} +const EMPTY_WORKTREE_REPOS: AppState['repos'] = [] + function buildWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { const byId = new Map() - for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktrees of Object.values(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO)) { for (const worktree of worktrees) { const worktreeId = worktree.id const existing = byId.get(worktreeId) @@ -1007,11 +1005,29 @@ function buildWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { return { byId, repoHostIdsByRepoId } } +// Why: worktree/owner updates replace these snapshots, while weak ownership avoids retaining superseded state. +const worktreeLookupIndexes = new WeakMap< + AppState['worktreesByRepo'], + { repos: AppState['repos']; index: WorktreeLookupIndex } +>() + +function getWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { + const worktreesByRepo = state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO + const repos = state.repos ?? EMPTY_WORKTREE_REPOS + const cached = worktreeLookupIndexes.get(worktreesByRepo) + if (cached && cached.repos === repos) { + return cached.index + } + const index = buildWorktreeLookupIndex(state) + worktreeLookupIndexes.set(worktreesByRepo, { repos, index }) + return index +} + function findUniqueWorktreeById( state: AppState, worktreeId: string, executionHostId?: string, - lookupIndex = buildWorktreeLookupIndex(state) + lookupIndex = getWorktreeLookupIndex(state) ): Worktree | null { const match = lookupIndex.byId.get(worktreeId)?.unique ?? null // Why: metadata persistence is keyed only by worktree id; an id owned by two hosts is non-unique so destructive clears fail closed. @@ -1130,9 +1146,10 @@ function shouldApplyBranchMismatchedLinkedPRClear(args: { function buildPRRefreshCandidate( state: AppState, worktree: Worktree, - repoPath?: string + repoPath?: string, + repoOverride?: Repo ): GitHubPRRefreshCandidate | null { - const repo = state.repos.find((r) => r.id === worktree.repoId) + const repo = repoOverride ?? getGitHubRepoLookupIndex(state.repos).findById(worktree.repoId) if (!repo) { return null } @@ -2989,9 +3006,10 @@ export const createGitHubSlice: StateCreator = (s }, fetchPRForBranch: async (repoPath, branch, options): Promise => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) + const repoLookup = getGitHubRepoLookupIndex(get().repos) + const repo = options?.repoId + ? repoLookup.findById(options.repoId) + : repoLookup.findByPath(repoPath) const repoId = options?.repoId ?? repo?.id const requestSettings = settingsForGitHubRepoOwner(get().settings, repo) const cacheKey = prCacheKey( @@ -4415,22 +4433,33 @@ export const createGitHubSlice: StateCreator = (s refreshAllGitHub: () => { // Clear comments cache; evict stale entries to bound long-session growth across repos/branches. - set((s) => ({ - commentsCache: {}, - prCache: evictStaleEntries(s.prCache), - issueCache: evictStaleEntries(s.issueCache), - checksCache: evictStaleEntries(s.checksCache), - workItemsCache: evictStaleEntries(s.workItemsCache), - projectViewCache: evictStaleEntries(s.projectViewCache), - prRefreshStates: pruneExpiredPRRefreshStates(s.prRefreshStates) - })) + set((s) => { + const next = { + commentsCache: Object.keys(s.commentsCache).length === 0 ? s.commentsCache : {}, + prCache: evictStaleEntries(s.prCache), + issueCache: evictStaleEntries(s.issueCache), + checksCache: evictStaleEntries(s.checksCache), + workItemsCache: evictStaleEntries(s.workItemsCache), + projectViewCache: evictStaleEntries(s.projectViewCache), + prRefreshStates: pruneExpiredPRRefreshStates(s.prRefreshStates) + } + // Why: each eviction helper returns its input untouched when nothing changed, so an + // unchanged sweep can return `s` and avoid waking every subscriber on window resume. + return next.commentsCache === s.commentsCache && + next.prCache === s.prCache && + next.issueCache === s.issueCache && + next.checksCache === s.checksCache && + next.workItemsCache === s.workItemsCache && + next.projectViewCache === s.projectViewCache && + next.prRefreshStates === s.prRefreshStates + ? s + : next + }) // Why: don't prune prRequestGenerations here — deleting a live generation makes its response look stale. // Only re-fetch PR/issue entries that are already stale — skip fresh ones const state = get() - const now = Date.now() - const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = [] const cardProps = state.worktreeCardProperties ?? [] const rawCardProps = cardProps as readonly string[] const shouldRefreshIssues = shouldRefreshIssueDecorations(state) @@ -4442,10 +4471,17 @@ export const createGitHubSlice: StateCreator = (s (state.settings?.experimentalNewWorktreeCardStyle === true ? cardProps.includes('status') : cardProps.includes('pr') || rawCardProps.includes('ci')) + if (!shouldRefreshPRs && !shouldRefreshIssues) { + return + } + + const now = Date.now() + const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = [] + const repoLookup = getGitHubRepoLookupIndex(state.repos) for (const worktrees of Object.values(state.worktreesByRepo)) { for (const wt of worktrees) { - const repo = state.repos.find((r) => r.id === wt.repoId) + const repo = repoLookup.findById(wt.repoId) if (!repo) { continue } @@ -4463,7 +4499,7 @@ export const createGitHubSlice: StateCreator = (s ) const prEntry = state.prCache[prKey] if (!prEntry || now - prEntry.fetchedAt >= CACHE_TTL) { - const candidate = buildPRRefreshCandidate(state, wt) + const candidate = buildPRRefreshCandidate(state, wt, undefined, repo) if (candidate) { stalePRCandidates.push({ candidate,