diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts index 187851a1..6c56f77d 100644 --- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -4,6 +4,10 @@ import { type DashboardRecentProject } from '@features/recent-projects/contracts import { api, isElectronMode } from '@renderer/api'; import { useStore } from '@renderer/store'; import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; +import { + captureContextScopedRequestEpoch, + isContextScopedRequestEpochCurrent, +} from '@renderer/store/utils/contextScopedRequestEpoch'; import { buildTaskCountsByProject } from '@renderer/utils/pathNormalize'; import { useShallow } from 'zustand/react/shallow'; @@ -134,6 +138,7 @@ export function useRecentProjectsSection( const reload = useCallback( async (options?: { force?: boolean }): Promise => { const requestContextId = activeContextId; + const requestContextEpoch = captureContextScopedRequestEpoch(); const hasVisibleProjects = recentProjectsRef.current.length > 0 || getRecentProjectsClientSnapshot(requestContextId) != null; @@ -148,19 +153,28 @@ export function useRecentProjectsSection( () => api.getDashboardRecentProjects(), options ); - if (activeContextIdRef.current !== requestContextId) { + if ( + activeContextIdRef.current !== requestContextId || + !isContextScopedRequestEpochCurrent(requestContextEpoch) + ) { return; } setRecentProjects(payload.projects); setRecentProjectsDegraded(payload.degraded); setDegradedRefreshCount((current) => (payload.degraded ? current + 1 : 0)); } catch (nextError) { - if (activeContextIdRef.current !== requestContextId) { + if ( + activeContextIdRef.current !== requestContextId || + !isContextScopedRequestEpochCurrent(requestContextEpoch) + ) { return; } setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects'); } finally { - if (activeContextIdRef.current === requestContextId) { + if ( + activeContextIdRef.current === requestContextId && + isContextScopedRequestEpochCurrent(requestContextEpoch) + ) { setLoading(false); } } @@ -220,11 +234,17 @@ export function useRecentProjectsSection( useEffect(() => { let cancelled = false; + const requestContextId = activeContextId; + const requestContextEpoch = captureContextScopedRequestEpoch(); void api.teams .aliveList() .then((teamNames) => { - if (!cancelled) { + if ( + !cancelled && + activeContextIdRef.current === requestContextId && + isContextScopedRequestEpochCurrent(requestContextEpoch) + ) { setAliveTeams(teamNames); } }) @@ -233,7 +253,7 @@ export function useRecentProjectsSection( return () => { cancelled = true; }; - }, [provisioningTeamNamesKey, teams]); + }, [activeContextId, provisioningTeamNamesKey, teams]); useEffect(() => { if (!searchQuery.trim()) { diff --git a/test/features/recent-projects/renderer/hooks/useRecentProjectsSection.test.tsx b/test/features/recent-projects/renderer/hooks/useRecentProjectsSection.test.tsx new file mode 100644 index 00000000..1f07d9a5 --- /dev/null +++ b/test/features/recent-projects/renderer/hooks/useRecentProjectsSection.test.tsx @@ -0,0 +1,263 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +import { useRecentProjectsSection } from '@features/recent-projects/renderer/hooks/useRecentProjectsSection'; +import { + __resetRecentProjectsClientCacheForTests, + loadRecentProjectsWithClientCache, +} from '@features/recent-projects/renderer/utils/recentProjectsClientCache'; +import { + invalidateContextScopedRequestEpoch, + resetContextScopedRequestEpochForTests, +} from '@renderer/store/utils/contextScopedRequestEpoch'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + DashboardRecentProject, + DashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; +import type { TeamSummary } from '@shared/types'; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +const apiMock = vi.hoisted(() => ({ + getDashboardRecentProjects: vi.fn(), + teams: { + aliveList: vi.fn(), + }, + config: { + addCustomProjectPath: vi.fn(), + selectFolders: vi.fn(), + }, + openPath: vi.fn(), +})); + +const storeState = vi.hoisted(() => ({ + globalTasks: [], + globalTasksInitialized: false, + globalTasksLoading: false, + fetchAllTasks: vi.fn(), + teams: [] as TeamSummary[], + activeContextId: 'local', + provisioningRuns: {}, + currentProvisioningRunIdByTeam: {}, + provisioningSnapshotByTeam: {}, + repositoryGroups: [], + fetchRepositoryGroups: vi.fn(), + openTeamsTab: vi.fn(), + fetchSessionsInitial: vi.fn(), +})); + +vi.mock('@renderer/api', () => ({ + api: apiMock, + isElectronMode: () => true, +})); + +vi.mock('@renderer/store', () => { + const useStore = Object.assign( + (selector: (state: typeof storeState) => unknown) => selector(storeState), + { + getState: () => storeState, + setState: vi.fn((patch: Partial) => { + Object.assign(storeState, patch); + }), + } + ); + return { useStore }; +}); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: T) => selector, +})); + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +function project(id: string, projectPath = `/tmp/${id}`): DashboardRecentProject { + return { + id, + name: id, + primaryPath: projectPath, + associatedPaths: [projectPath], + mostRecentActivity: Date.parse('2026-04-14T12:00:00.000Z'), + providerIds: ['anthropic'], + source: 'claude', + openTarget: { + type: 'synthetic-path', + path: projectPath, + }, + }; +} + +function payload(id: string, projectPath?: string): DashboardRecentProjectsPayload { + return { + projects: [project(id, projectPath)], + degraded: false, + }; +} + +function team(teamName: string, projectPath: string): TeamSummary { + return { + teamName, + displayName: teamName, + description: '', + memberCount: 1, + taskCount: 0, + lastActivity: null, + projectPath, + }; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('useRecentProjectsSection', () => { + let host: HTMLDivElement; + let root: Root; + let latest: ReturnType | null; + + function Harness(): React.JSX.Element | null { + latest = useRecentProjectsSection('', 20); + return null; + } + + async function renderHarness(): Promise { + await act(async () => { + root.render(React.createElement(Harness)); + await flushPromises(); + }); + } + + beforeEach(() => { + __resetRecentProjectsClientCacheForTests(); + resetContextScopedRequestEpochForTests(); + vi.clearAllMocks(); + latest = null; + storeState.globalTasks = []; + storeState.globalTasksInitialized = false; + storeState.globalTasksLoading = false; + storeState.teams = []; + storeState.activeContextId = 'local'; + storeState.provisioningRuns = {}; + storeState.currentProvisioningRunIdByTeam = {}; + storeState.provisioningSnapshotByTeam = {}; + storeState.repositoryGroups = []; + apiMock.teams.aliveList.mockResolvedValue([]); + + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + host.remove(); + __resetRecentProjectsClientCacheForTests(); + resetContextScopedRequestEpochForTests(); + }); + + it('ignores stale recent-project loads after the context epoch changes back to the same id', async () => { + const oldLocalRequest = deferred(); + const sshRequest = deferred(); + const freshLocalRequest = deferred(); + + apiMock.getDashboardRecentProjects + .mockReturnValueOnce(oldLocalRequest.promise) + .mockReturnValueOnce(sshRequest.promise) + .mockReturnValueOnce(freshLocalRequest.promise); + + await renderHarness(); + expect(apiMock.getDashboardRecentProjects).toHaveBeenCalledTimes(1); + + invalidateContextScopedRequestEpoch(); + storeState.activeContextId = 'ssh-dev'; + await renderHarness(); + expect(apiMock.getDashboardRecentProjects).toHaveBeenCalledTimes(2); + + invalidateContextScopedRequestEpoch(); + storeState.activeContextId = 'local'; + await renderHarness(); + expect(apiMock.getDashboardRecentProjects).toHaveBeenCalledTimes(3); + + await act(async () => { + oldLocalRequest.resolve(payload('old-local')); + await oldLocalRequest.promise; + await flushPromises(); + }); + + expect(latest?.cards.map((card) => card.name)).toEqual([]); + expect(latest?.loading).toBe(true); + + await act(async () => { + freshLocalRequest.resolve(payload('fresh-local')); + await freshLocalRequest.promise; + await flushPromises(); + }); + + expect(latest?.cards.map((card) => card.name)).toEqual(['fresh-local']); + expect(latest?.loading).toBe(false); + }); + + it('cancels stale alive-list responses when only the active context changes', async () => { + const oldAliveRequest = deferred(); + const sshAliveRequest = deferred(); + const freshAliveRequest = deferred(); + + await loadRecentProjectsWithClientCache('local', () => Promise.resolve(payload('alpha')), { + force: true, + }); + + apiMock.getDashboardRecentProjects.mockResolvedValue(payload('alpha')); + apiMock.teams.aliveList + .mockReturnValueOnce(oldAliveRequest.promise) + .mockReturnValueOnce(sshAliveRequest.promise) + .mockReturnValueOnce(freshAliveRequest.promise); + storeState.teams = [team('old-team', '/tmp/alpha'), team('fresh-team', '/tmp/alpha')]; + + await renderHarness(); + expect(apiMock.teams.aliveList).toHaveBeenCalledTimes(1); + + invalidateContextScopedRequestEpoch(); + storeState.activeContextId = 'ssh-dev'; + await renderHarness(); + expect(apiMock.teams.aliveList).toHaveBeenCalledTimes(2); + + invalidateContextScopedRequestEpoch(); + storeState.activeContextId = 'local'; + await renderHarness(); + expect(apiMock.teams.aliveList).toHaveBeenCalledTimes(3); + + await act(async () => { + freshAliveRequest.resolve(['fresh-team']); + await freshAliveRequest.promise; + await flushPromises(); + }); + + expect(latest?.cards[0]?.activeTeams?.map((activeTeam) => activeTeam.teamName)).toEqual([ + 'fresh-team', + ]); + + await act(async () => { + oldAliveRequest.resolve(['old-team']); + await oldAliveRequest.promise; + await flushPromises(); + }); + + expect(latest?.cards[0]?.activeTeams?.map((activeTeam) => activeTeam.teamName)).toEqual([ + 'fresh-team', + ]); + }); +});