From 2fdbf301b4d8cb144de6537c424a1205f5562911 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 26 May 2026 18:33:47 +0300 Subject: [PATCH] fix(context): guard project fetches by scope --- src/renderer/store/slices/projectSlice.ts | 18 ++ src/renderer/store/slices/repositorySlice.ts | 18 ++ .../projectRepositoryContextRace.test.ts | 204 ++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 test/renderer/store/projectRepositoryContextRace.test.ts diff --git a/src/renderer/store/slices/projectSlice.ts b/src/renderer/store/slices/projectSlice.ts index 6e74b25d..09bccd6e 100644 --- a/src/renderer/store/slices/projectSlice.ts +++ b/src/renderer/store/slices/projectSlice.ts @@ -4,6 +4,10 @@ import { api } from '@renderer/api'; +import { + captureContextScopedRequestEpoch, + isContextScopedRequestEpochCurrent, +} from '../utils/contextScopedRequestEpoch'; import { getSessionResetState } from '../utils/stateResetHelpers'; import type { AppState } from '../types'; @@ -43,15 +47,29 @@ export const createProjectSlice: StateCreator = fetchProjects: async () => { // Guard: prevent concurrent fetches (component mount + centralized init chain) if (get().projectsLoading) return; + const requestContextId = get().activeContextId; + const requestContextEpoch = captureContextScopedRequestEpoch(); set({ projectsLoading: true, projectsError: null }); try { const projects = await api.getProjects(); + if ( + get().activeContextId !== requestContextId || + !isContextScopedRequestEpochCurrent(requestContextEpoch) + ) { + return; + } // Sort by most recent session (descending) const sorted = [...projects].sort( (a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0) ); set({ projects: sorted, projectsLoading: false, projectsInitialized: true }); } catch (error) { + if ( + get().activeContextId !== requestContextId || + !isContextScopedRequestEpochCurrent(requestContextEpoch) + ) { + return; + } set({ projectsError: error instanceof Error ? error.message : 'Failed to fetch projects', projectsLoading: false, diff --git a/src/renderer/store/slices/repositorySlice.ts b/src/renderer/store/slices/repositorySlice.ts index 0d439c68..425f708e 100644 --- a/src/renderer/store/slices/repositorySlice.ts +++ b/src/renderer/store/slices/repositorySlice.ts @@ -5,6 +5,10 @@ import { api } from '@renderer/api'; import { createLogger } from '@shared/utils/logger'; +import { + captureContextScopedRequestEpoch, + isContextScopedRequestEpochCurrent, +} from '../utils/contextScopedRequestEpoch'; import { getSessionResetState } from '../utils/stateResetHelpers'; import type { AppState } from '../types'; @@ -71,6 +75,8 @@ export const createRepositorySlice: StateCreator { // Guard: prevent concurrent fetches (component mount + centralized init chain) if (get().repositoryGroupsLoading) return; + const requestContextId = get().activeContextId; + const requestContextEpoch = captureContextScopedRequestEpoch(); const startedAt = Date.now(); set({ repositoryGroupsLoading: true, repositoryGroupsError: null }); try { @@ -79,6 +85,12 @@ export const createRepositorySlice: StateCreator ({ + getProjects: vi.fn(), + getRepositoryGroups: vi.fn(), +})); + +vi.mock('@renderer/api', () => ({ + api: apiMock, +})); + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + +function project(id: string, path = `/${id}`): Project { + return { + id, + path, + name: id, + sessions: [], + totalSessions: 0, + createdAt: 0, + mostRecentSession: 0, + }; +} + +function repositoryGroup(id: string, path = `/${id}`): RepositoryGroup { + return { + id, + identity: null, + name: id, + totalSessions: 0, + mostRecentSession: 0, + worktrees: [ + { + id: `${id}-worktree`, + path, + name: id, + isMainWorktree: true, + source: 'unknown', + sessions: [], + totalSessions: 0, + createdAt: 0, + mostRecentSession: 0, + }, + ], + }; +} + +function createProjectRepositoryStore() { + return create()((set, get, store) => + ({ + ...createProjectSlice(set as never, get as never, store as never), + ...createRepositorySlice(set as never, get as never, store as never), + activeContextId: 'local', + activeProjectId: null, + fetchSessionsInitial: vi.fn(async () => undefined), + }) as unknown as AppState + ); +} + +describe('project and repository context races', () => { + beforeEach(() => { + resetContextScopedRequestEpochForTests(); + apiMock.getProjects.mockReset(); + apiMock.getRepositoryGroups.mockReset(); + }); + + afterEach(() => { + resetContextScopedRequestEpochForTests(); + vi.restoreAllMocks(); + }); + + it('applies current-context project loads', async () => { + const store = createProjectRepositoryStore(); + apiMock.getProjects.mockResolvedValue([project('current-project')]); + + await store.getState().fetchProjects(); + + expect(store.getState().projects).toEqual([project('current-project')]); + expect(store.getState().projectsInitialized).toBe(true); + expect(store.getState().projectsLoading).toBe(false); + }); + + it('ignores project loads resolved for a previous context', async () => { + const store = createProjectRepositoryStore(); + const localProjects = deferred(); + const currentProjects = [project('ssh-project', '/ssh/project')]; + apiMock.getProjects.mockReturnValueOnce(localProjects.promise); + + const fetchPromise = store.getState().fetchProjects(); + expect(store.getState().projectsLoading).toBe(true); + + store.setState({ + activeContextId: 'ssh-dev', + projects: currentProjects, + projectsLoading: false, + projectsInitialized: true, + }); + localProjects.resolve([project('local-project', '/local/project')]); + await fetchPromise; + + expect(store.getState().projects).toBe(currentProjects); + expect(store.getState().projectsLoading).toBe(false); + }); + + it('ignores project loads resolved before a same-context epoch reset', async () => { + const store = createProjectRepositoryStore(); + const oldLocalProjects = deferred(); + const currentProjects = [project('fresh-local-project', '/fresh-local/project')]; + apiMock.getProjects.mockReturnValueOnce(oldLocalProjects.promise); + + const fetchPromise = store.getState().fetchProjects(); + expect(store.getState().projectsLoading).toBe(true); + + invalidateContextScopedRequestEpoch(); + store.setState({ + activeContextId: 'local', + projects: currentProjects, + projectsLoading: false, + projectsInitialized: true, + }); + oldLocalProjects.resolve([project('old-local-project', '/old-local/project')]); + await fetchPromise; + + expect(store.getState().projects).toBe(currentProjects); + expect(store.getState().projectsLoading).toBe(false); + }); + + it('applies current-context repository group loads', async () => { + const store = createProjectRepositoryStore(); + apiMock.getRepositoryGroups.mockResolvedValue([repositoryGroup('current-repo')]); + + await store.getState().fetchRepositoryGroups(); + + expect(store.getState().repositoryGroups).toEqual([repositoryGroup('current-repo')]); + expect(store.getState().repositoryGroupsInitialized).toBe(true); + expect(store.getState().repositoryGroupsLoading).toBe(false); + }); + + it('ignores repository group loads resolved for a previous context', async () => { + const store = createProjectRepositoryStore(); + const localGroups = deferred(); + const currentGroups = [repositoryGroup('ssh-repo', '/ssh/repo')]; + apiMock.getRepositoryGroups.mockReturnValueOnce(localGroups.promise); + + const fetchPromise = store.getState().fetchRepositoryGroups(); + expect(store.getState().repositoryGroupsLoading).toBe(true); + + store.setState({ + activeContextId: 'ssh-dev', + repositoryGroups: currentGroups, + repositoryGroupsLoading: false, + repositoryGroupsInitialized: true, + }); + localGroups.resolve([repositoryGroup('local-repo', '/local/repo')]); + await fetchPromise; + + expect(store.getState().repositoryGroups).toBe(currentGroups); + expect(store.getState().repositoryGroupsLoading).toBe(false); + }); + + it('ignores repository group loads resolved before a same-context epoch reset', async () => { + const store = createProjectRepositoryStore(); + const oldLocalGroups = deferred(); + const currentGroups = [repositoryGroup('fresh-local-repo', '/fresh-local/repo')]; + apiMock.getRepositoryGroups.mockReturnValueOnce(oldLocalGroups.promise); + + const fetchPromise = store.getState().fetchRepositoryGroups(); + expect(store.getState().repositoryGroupsLoading).toBe(true); + + invalidateContextScopedRequestEpoch(); + store.setState({ + activeContextId: 'local', + repositoryGroups: currentGroups, + repositoryGroupsLoading: false, + repositoryGroupsInitialized: true, + }); + oldLocalGroups.resolve([repositoryGroup('old-local-repo', '/old-local/repo')]); + await fetchPromise; + + expect(store.getState().repositoryGroups).toBe(currentGroups); + expect(store.getState().repositoryGroupsLoading).toBe(false); + }); +});