diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 423665fc..23063056 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -3133,11 +3133,18 @@ export const createTeamSlice: StateCreator = (set, }, fetchCrossTeamTargets: async () => { + const requestScope = captureContextRequestScope(get); set({ crossTeamTargetsLoading: true }); try { const targets = await api.crossTeam.listTargets(); + if (!isContextRequestScopeCurrent(get, requestScope)) { + return; + } set({ crossTeamTargets: targets, crossTeamTargetsLoading: false }); } catch (error) { + if (!isContextRequestScopeCurrent(get, requestScope)) { + return; + } logger.error('fetchCrossTeamTargets failed', error); set({ crossTeamTargets: [], crossTeamTargetsLoading: false }); } diff --git a/test/renderer/store/teamSliceContextRace.test.ts b/test/renderer/store/teamSliceContextRace.test.ts index 6b430edd..c06aa7c2 100644 --- a/test/renderer/store/teamSliceContextRace.test.ts +++ b/test/renderer/store/teamSliceContextRace.test.ts @@ -26,6 +26,9 @@ const apiMock = vi.hoisted(() => ({ review: { invalidateTaskChangeSummaries: vi.fn(async () => undefined), }, + crossTeam: { + listTargets: vi.fn(), + }, })); interface TeamSummaryLike { @@ -63,6 +66,11 @@ interface TeamSnapshotLike { processes: []; } +interface CrossTeamTargetLike { + teamName: string; + displayName: string; +} + const teamSnapshot = ( teamName: string, projectPath: string, @@ -176,6 +184,7 @@ describe('team slice context races', () => { apiMock.teams.getTaskChangePresence.mockReset(); apiMock.teams.showMessageNotification.mockClear(); apiMock.review.invalidateTaskChangeSummaries.mockClear(); + apiMock.crossTeam.listTargets.mockReset(); }); afterEach(() => { @@ -323,6 +332,57 @@ describe('team slice context races', () => { expect(store.getState().globalTasksLoading).toBe(false); }); + it('ignores cross-team targets loaded for a previous context', async () => { + const store = createSliceStore(); + const localTargets = deferred(); + apiMock.crossTeam.listTargets.mockReturnValueOnce(localTargets.promise); + + const fetchPromise = store.getState().fetchCrossTeamTargets(); + expect(store.getState().crossTeamTargetsLoading).toBe(true); + + store.setState({ + activeContextId: 'ssh-dev', + crossTeamTargets: [], + crossTeamTargetsLoading: false, + }); + localTargets.resolve([ + { + teamName: 'local-target', + displayName: 'Local Target', + }, + ]); + await fetchPromise; + + expect(store.getState().crossTeamTargets).toEqual([]); + expect(store.getState().crossTeamTargetsLoading).toBe(false); + }); + + it('ignores cross-team targets loaded before a context epoch reset with the same context id', async () => { + const store = createSliceStore(); + const localTargets = deferred(); + apiMock.crossTeam.listTargets.mockReturnValueOnce(localTargets.promise); + + const fetchPromise = store.getState().fetchCrossTeamTargets(); + expect(store.getState().crossTeamTargetsLoading).toBe(true); + + invalidateContextScopedRequestEpoch(); + store.setState({ + activeContextId: 'local', + crossTeamTargets: [], + crossTeamTargetsLoading: false, + }); + localTargets.resolve([ + { + teamName: 'old-local-target', + displayName: 'Old Local Target', + }, + ]); + await fetchPromise; + + expect(store.getState().crossTeamTargets).toEqual([]); + expect(store.getState().crossTeamTargetsLoading).toBe(false); + }); + it('ignores selected team data loaded for a previous context', async () => { const store = createSliceStore(); const localData = deferred();