fix(context): ignore stale team request scopes
This commit is contained in:
parent
255fa5aa47
commit
c04a259cea
6 changed files with 643 additions and 43 deletions
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
|
||||||
|
import { invalidateContextScopedRequestEpoch } from '../utils/contextScopedRequestEpoch';
|
||||||
import { getContextScopedTeamResetState, getFullResetState } from '../utils/stateResetHelpers';
|
import { getContextScopedTeamResetState, getFullResetState } from '../utils/stateResetHelpers';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
|
|
@ -71,6 +72,9 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await api.ssh.connect(config);
|
const status = await api.ssh.connect(config);
|
||||||
|
if (status.state === 'connected') {
|
||||||
|
invalidateContextScopedRequestEpoch();
|
||||||
|
}
|
||||||
set({
|
set({
|
||||||
connectionMode: status.state === 'connected' ? 'ssh' : 'local',
|
connectionMode: status.state === 'connected' ? 'ssh' : 'local',
|
||||||
connectionState: status.state,
|
connectionState: status.state,
|
||||||
|
|
@ -134,6 +138,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
||||||
disconnectSsh: async (): Promise<void> => {
|
disconnectSsh: async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const status = await api.ssh.disconnect();
|
const status = await api.ssh.disconnect();
|
||||||
|
invalidateContextScopedRequestEpoch();
|
||||||
set({
|
set({
|
||||||
connectionMode: 'local',
|
connectionMode: 'local',
|
||||||
connectionState: status.state,
|
connectionState: status.state,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ import { api } from '@renderer/api';
|
||||||
import { contextStorage } from '@renderer/services/contextStorage';
|
import { contextStorage } from '@renderer/services/contextStorage';
|
||||||
import { draftStorage } from '@renderer/services/draftStorage';
|
import { draftStorage } from '@renderer/services/draftStorage';
|
||||||
|
|
||||||
|
import {
|
||||||
|
captureContextScopedRequestEpoch,
|
||||||
|
invalidateContextScopedRequestEpoch,
|
||||||
|
isContextScopedRequestEpochCurrent,
|
||||||
|
} from '../utils/contextScopedRequestEpoch';
|
||||||
import { getContextScopedTeamResetState, getFullResetState } from '../utils/stateResetHelpers';
|
import { getContextScopedTeamResetState, getFullResetState } from '../utils/stateResetHelpers';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
|
|
@ -78,6 +83,16 @@ function getEmptyContextState(): Partial<AppState> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isContextSwitchRequestCurrent(
|
||||||
|
get: () => AppState,
|
||||||
|
targetContextId: string,
|
||||||
|
requestEpoch: number
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
get().activeContextId === targetContextId && isContextScopedRequestEpochCurrent(requestEpoch)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate snapshot against fresh data from target context.
|
* Validate snapshot against fresh data from target context.
|
||||||
* Filters invalid tabs, selections, and ensures at-least-one-pane invariant.
|
* Filters invalid tabs, selections, and ensures at-least-one-pane invariant.
|
||||||
|
|
@ -261,13 +276,17 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
||||||
// Fetch active context from main process
|
// Fetch active context from main process
|
||||||
const activeContextId = await api.context.getActive();
|
const activeContextId = await api.context.getActive();
|
||||||
const previousContextId = get().activeContextId;
|
const previousContextId = get().activeContextId;
|
||||||
|
const contextChanged = activeContextId !== previousContextId;
|
||||||
|
if (contextChanged) {
|
||||||
|
invalidateContextScopedRequestEpoch();
|
||||||
|
}
|
||||||
|
|
||||||
set({
|
set({
|
||||||
...(activeContextId !== previousContextId ? getContextScopedTeamResetState() : {}),
|
...(contextChanged ? getContextScopedTeamResetState() : {}),
|
||||||
contextSnapshotsReady: true,
|
contextSnapshotsReady: true,
|
||||||
activeContextId,
|
activeContextId,
|
||||||
});
|
});
|
||||||
if (activeContextId !== previousContextId) {
|
if (contextChanged) {
|
||||||
void get().fetchTeams();
|
void get().fetchTeams();
|
||||||
void get().fetchAllTasks();
|
void get().fetchAllTasks();
|
||||||
}
|
}
|
||||||
|
|
@ -320,6 +339,7 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
||||||
contextStorage.loadSnapshot(targetContextId),
|
contextStorage.loadSnapshot(targetContextId),
|
||||||
api.context.switch(targetContextId),
|
api.context.switch(targetContextId),
|
||||||
]);
|
]);
|
||||||
|
invalidateContextScopedRequestEpoch();
|
||||||
|
|
||||||
// Step 2: Apply cached snapshot immediately for instant visual feedback
|
// Step 2: Apply cached snapshot immediately for instant visual feedback
|
||||||
if (targetSnapshot) {
|
if (targetSnapshot) {
|
||||||
|
|
@ -364,6 +384,7 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
||||||
targetContextId,
|
targetContextId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const switchRequestEpoch = captureContextScopedRequestEpoch();
|
||||||
|
|
||||||
// Step 3: Fetch fresh data in background (slow over SSH)
|
// Step 3: Fetch fresh data in background (slow over SSH)
|
||||||
// Wrapped in try/catch so fetch failures don't wipe valid snapshot data.
|
// Wrapped in try/catch so fetch failures don't wipe valid snapshot data.
|
||||||
|
|
@ -373,6 +394,9 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
||||||
api.getProjects(),
|
api.getProjects(),
|
||||||
api.getRepositoryGroups(),
|
api.getRepositoryGroups(),
|
||||||
]);
|
]);
|
||||||
|
if (!isContextSwitchRequestCurrent(get, targetContextId, switchRequestEpoch)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (targetSnapshot) {
|
if (targetSnapshot) {
|
||||||
// Guard: don't overwrite snapshot data if fetch returned empty
|
// Guard: don't overwrite snapshot data if fetch returned empty
|
||||||
|
|
@ -403,6 +427,9 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
||||||
}
|
}
|
||||||
} catch (fetchError) {
|
} catch (fetchError) {
|
||||||
console.error('[contextSlice] Background data refresh failed:', fetchError);
|
console.error('[contextSlice] Background data refresh failed:', fetchError);
|
||||||
|
if (!isContextSwitchRequestCurrent(get, targetContextId, switchRequestEpoch)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Keep snapshot data as fallback — don't wipe user's view
|
// Keep snapshot data as fallback — don't wipe user's view
|
||||||
if (!targetSnapshot) {
|
if (!targetSnapshot) {
|
||||||
// No snapshot and fetch failed — finalize switch with empty state
|
// No snapshot and fetch failed — finalize switch with empty state
|
||||||
|
|
@ -416,6 +443,9 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Fetch notifications in background
|
// Step 4: Fetch notifications in background
|
||||||
|
if (!isContextSwitchRequestCurrent(get, targetContextId, switchRequestEpoch)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
void get().fetchNotifications();
|
void get().fetchNotifications();
|
||||||
void get().fetchTeams();
|
void get().fetchTeams();
|
||||||
void get().fetchAllTasks();
|
void get().fetchAllTasks();
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,11 @@ import {
|
||||||
import { structurallyShareTeamSnapshot } from '../team/teamSnapshotStructuralSharing';
|
import { structurallyShareTeamSnapshot } from '../team/teamSnapshotStructuralSharing';
|
||||||
import { parseToolApprovalSettings } from '../team/teamToolApprovalSettings';
|
import { parseToolApprovalSettings } from '../team/teamToolApprovalSettings';
|
||||||
import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics';
|
import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics';
|
||||||
|
import {
|
||||||
|
captureContextScopedRequestEpoch,
|
||||||
|
isContextScopedRequestEpochCurrent,
|
||||||
|
resetContextScopedRequestEpochForTests,
|
||||||
|
} from '../utils/contextScopedRequestEpoch';
|
||||||
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -293,6 +298,7 @@ export function __resetTeamSliceModuleStateForTests(): void {
|
||||||
clearAllPendingReplyRefreshWaits();
|
clearAllPendingReplyRefreshWaits();
|
||||||
clearAllLastResolvedTeamDataRefreshes();
|
clearAllLastResolvedTeamDataRefreshes();
|
||||||
clearAllTeamLocalStateEpochs();
|
clearAllTeamLocalStateEpochs();
|
||||||
|
resetContextScopedRequestEpochForTests();
|
||||||
clearAllMemberSpawnStatusesIpcBackoffs();
|
clearAllMemberSpawnStatusesIpcBackoffs();
|
||||||
clearAllTeamRefreshBurstDiagnostics();
|
clearAllTeamRefreshBurstDiagnostics();
|
||||||
clearAllMemberSpawnUiEqualLastWarns();
|
clearAllMemberSpawnUiEqualLastWarns();
|
||||||
|
|
@ -427,15 +433,56 @@ function drainQueuedFullRefreshAfterThinSettles(teamName: string, get: () => Tea
|
||||||
void get().refreshTeamData(teamName, { withDedup: true });
|
void get().refreshTeamData(teamName, { withDedup: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ContextRequestScope {
|
||||||
|
contextId: string;
|
||||||
|
contextEpoch: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamRequestScope extends ContextRequestScope {
|
||||||
|
teamStateEpoch: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureContextRequestScope(get: () => AppState): ContextRequestScope {
|
||||||
|
return {
|
||||||
|
contextId: get().activeContextId,
|
||||||
|
contextEpoch: captureContextScopedRequestEpoch(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContextRequestScopeCurrent(get: () => AppState, scope: ContextRequestScope): boolean {
|
||||||
|
return (
|
||||||
|
get().activeContextId === scope.contextId &&
|
||||||
|
isContextScopedRequestEpochCurrent(scope.contextEpoch)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureTeamRequestScope(get: () => AppState, teamName: string): TeamRequestScope {
|
||||||
|
return {
|
||||||
|
...captureContextRequestScope(get),
|
||||||
|
teamStateEpoch: captureTeamLocalStateEpoch(teamName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTeamRequestScopeCurrent(
|
||||||
|
get: () => AppState,
|
||||||
|
teamName: string,
|
||||||
|
scope: TeamRequestScope
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
isContextRequestScopeCurrent(get, scope) &&
|
||||||
|
isTeamLocalStateEpochCurrent(teamName, scope.teamStateEpoch)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isSelectedTeamLoadStillCurrent(
|
function isSelectedTeamLoadStillCurrent(
|
||||||
get: () => TeamSlice,
|
get: () => AppState,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
requestNonce: number,
|
requestNonce: number,
|
||||||
teamStateEpoch: number
|
requestScope: TeamRequestScope
|
||||||
): boolean {
|
): boolean {
|
||||||
const state = get();
|
const state = get();
|
||||||
return (
|
return (
|
||||||
isTeamLocalStateEpochCurrent(teamName, teamStateEpoch) &&
|
isTeamRequestScopeCurrent(get, teamName, requestScope) &&
|
||||||
state.selectedTeamName === teamName &&
|
state.selectedTeamName === teamName &&
|
||||||
state.selectedTeamLoadNonce === requestNonce &&
|
state.selectedTeamLoadNonce === requestNonce &&
|
||||||
state.selectedTeamData?.teamName === teamName
|
state.selectedTeamData?.teamName === teamName
|
||||||
|
|
@ -445,10 +492,10 @@ function isSelectedTeamLoadStillCurrent(
|
||||||
function schedulePostPaintTeamEnrichments(params: {
|
function schedulePostPaintTeamEnrichments(params: {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
requestNonce: number;
|
requestNonce: number;
|
||||||
teamStateEpoch: number;
|
requestScope: TeamRequestScope;
|
||||||
get: () => TeamSlice;
|
get: () => AppState;
|
||||||
}): void {
|
}): void {
|
||||||
const { teamName, requestNonce, teamStateEpoch, get } = params;
|
const { teamName, requestNonce, requestScope, get } = params;
|
||||||
|
|
||||||
cancelPostPaintTeamEnrichments(teamName);
|
cancelPostPaintTeamEnrichments(teamName);
|
||||||
|
|
||||||
|
|
@ -459,7 +506,7 @@ function schedulePostPaintTeamEnrichments(params: {
|
||||||
postPaintTeamEnrichmentTimers.delete(teamName);
|
postPaintTeamEnrichmentTimers.delete(teamName);
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
|
||||||
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
|
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -485,7 +532,7 @@ function schedulePostPaintTeamEnrichments(params: {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headResult = await get().refreshTeamMessagesHead(teamName);
|
const headResult = await get().refreshTeamMessagesHead(teamName);
|
||||||
if (!isSelectedTeamLoadStillCurrent(get, teamName, requestNonce, teamStateEpoch)) {
|
if (!isSelectedTeamLoadStillCurrent(get, teamName, requestNonce, requestScope)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (headResult.feedChanged || isMemberActivityMetaStale(get(), teamName)) {
|
if (headResult.feedChanged || isMemberActivityMetaStale(get(), teamName)) {
|
||||||
|
|
@ -1228,8 +1275,12 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (isMemberSpawnStatusesIpcBackoffActive(teamName)) {
|
if (isMemberSpawnStatusesIpcBackoffActive(teamName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const requestScope = captureTeamRequestScope(get, teamName);
|
||||||
try {
|
try {
|
||||||
const snapshot = await api.teams.getMemberSpawnStatuses(teamName);
|
const snapshot = await api.teams.getMemberSpawnStatuses(teamName);
|
||||||
|
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
clearMemberSpawnStatusesIpcBackoff(teamName);
|
clearMemberSpawnStatusesIpcBackoff(teamName);
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
if (snapshot.runId != null && prev.ignoredRuntimeRunIds[snapshot.runId] === teamName) {
|
if (snapshot.runId != null && prev.ignoredRuntimeRunIds[snapshot.runId] === teamName) {
|
||||||
|
|
@ -1292,6 +1343,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
if (message.includes("No handler registered for 'team:memberSpawnStatuses'")) {
|
if (message.includes("No handler registered for 'team:memberSpawnStatuses'")) {
|
||||||
recordMemberSpawnStatusesIpcRetryBackoff(
|
recordMemberSpawnStatusesIpcRetryBackoff(
|
||||||
|
|
@ -1304,8 +1358,12 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
},
|
},
|
||||||
fetchTeamAgentRuntime: async (teamName: string) => {
|
fetchTeamAgentRuntime: async (teamName: string) => {
|
||||||
if (!api.teams?.getTeamAgentRuntime) return;
|
if (!api.teams?.getTeamAgentRuntime) return;
|
||||||
|
const requestScope = captureTeamRequestScope(get, teamName);
|
||||||
try {
|
try {
|
||||||
const snapshot = await api.teams.getTeamAgentRuntime(teamName);
|
const snapshot = await api.teams.getTeamAgentRuntime(teamName);
|
||||||
|
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
if (snapshot.runId != null && prev.ignoredRuntimeRunIds[snapshot.runId] === teamName) {
|
if (snapshot.runId != null && prev.ignoredRuntimeRunIds[snapshot.runId] === teamName) {
|
||||||
return {};
|
return {};
|
||||||
|
|
@ -1405,7 +1463,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
// Only effective during initial load (when teamsLoading is set to true below).
|
// Only effective during initial load (when teamsLoading is set to true below).
|
||||||
// Refreshes are already serialized by the throttle timer in onTeamChange.
|
// Refreshes are already serialized by the throttle timer in onTeamChange.
|
||||||
if (get().teamsLoading) return;
|
if (get().teamsLoading) return;
|
||||||
const requestContextId = get().activeContextId;
|
const requestScope = captureContextRequestScope(get);
|
||||||
const requestId = ++latestTeamsFetchRequestId;
|
const requestId = ++latestTeamsFetchRequestId;
|
||||||
// Only show loading spinner on initial load — avoids flickering when refreshing
|
// Only show loading spinner on initial load — avoids flickering when refreshing
|
||||||
const isInitialLoad = get().teams.length === 0;
|
const isInitialLoad = get().teams.length === 0;
|
||||||
|
|
@ -1418,7 +1476,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
TEAM_FETCH_TIMEOUT_MS,
|
TEAM_FETCH_TIMEOUT_MS,
|
||||||
'fetchTeams'
|
'fetchTeams'
|
||||||
);
|
);
|
||||||
if (get().activeContextId !== requestContextId || latestTeamsFetchRequestId !== requestId) {
|
if (
|
||||||
|
!isContextRequestScopeCurrent(get, requestScope) ||
|
||||||
|
latestTeamsFetchRequestId !== requestId
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const teamByName: Record<string, TeamSummary> = {};
|
const teamByName: Record<string, TeamSummary> = {};
|
||||||
|
|
@ -1453,7 +1514,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (get().activeContextId !== requestContextId || latestTeamsFetchRequestId !== requestId) {
|
if (
|
||||||
|
!isContextRequestScopeCurrent(get, requestScope) ||
|
||||||
|
latestTeamsFetchRequestId !== requestId
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// On refresh failure, keep existing teams visible
|
// On refresh failure, keep existing teams visible
|
||||||
|
|
@ -1487,7 +1551,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
set({ globalTasksLoading: true, globalTasksError: null });
|
set({ globalTasksLoading: true, globalTasksError: null });
|
||||||
}
|
}
|
||||||
const requestContextId = get().activeContextId;
|
const requestScope = captureContextRequestScope(get);
|
||||||
const oldTasks = get().globalTasks;
|
const oldTasks = get().globalTasks;
|
||||||
try {
|
try {
|
||||||
const tasks = await withTimeout(
|
const tasks = await withTimeout(
|
||||||
|
|
@ -1495,7 +1559,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
TEAM_FETCH_TIMEOUT_MS,
|
TEAM_FETCH_TIMEOUT_MS,
|
||||||
'fetchAllTasks'
|
'fetchAllTasks'
|
||||||
);
|
);
|
||||||
if (get().activeContextId !== requestContextId) {
|
if (!isContextRequestScopeCurrent(get, requestScope)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const notificationState = get();
|
const notificationState = get();
|
||||||
|
|
@ -1515,7 +1579,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
globalTasksError: null,
|
globalTasksError: null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (get().activeContextId !== requestContextId) {
|
if (!isContextRequestScopeCurrent(get, requestScope)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
set({
|
set({
|
||||||
|
|
@ -2043,6 +2107,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshTeamChangePresence: async (teamName: string) => {
|
refreshTeamChangePresence: async (teamName: string) => {
|
||||||
|
const requestScope = captureTeamRequestScope(get, teamName);
|
||||||
const currentTeamData = selectTeamDataForName(get(), teamName);
|
const currentTeamData = selectTeamDataForName(get(), teamName);
|
||||||
if (!currentTeamData) {
|
if (!currentTeamData) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -2052,6 +2117,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const presenceByTaskId = await unwrapIpc('team:getTaskChangePresence', () =>
|
const presenceByTaskId = await unwrapIpc('team:getTaskChangePresence', () =>
|
||||||
api.teams.getTaskChangePresence(teamName)
|
api.teams.getTaskChangePresence(teamName)
|
||||||
);
|
);
|
||||||
|
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const teamData = selectTeamDataForName(state, teamName);
|
const teamData = selectTeamDataForName(state, teamName);
|
||||||
|
|
@ -2099,7 +2167,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
},
|
},
|
||||||
|
|
||||||
selectTeam: async (teamName: string, opts) => {
|
selectTeam: async (teamName: string, opts) => {
|
||||||
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
const requestScope = captureTeamRequestScope(get, teamName);
|
||||||
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
|
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
|
||||||
// Guard: prevent duplicate in-flight fetches for the same team.
|
// Guard: prevent duplicate in-flight fetches for the same team.
|
||||||
// GlobalTaskDetailDialog + tab navigation can call selectTeam() in quick succession.
|
// GlobalTaskDetailDialog + tab navigation can call selectTeam() in quick succession.
|
||||||
|
|
@ -2132,7 +2200,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const data = await fetchTeamDataDeduped(teamName, {
|
const data = await fetchTeamDataDeduped(teamName, {
|
||||||
includeMemberBranches: false,
|
includeMemberBranches: false,
|
||||||
});
|
});
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
|
||||||
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
|
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -2266,7 +2334,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (
|
if (
|
||||||
!opts?.skipProjectAutoSelect &&
|
!opts?.skipProjectAutoSelect &&
|
||||||
projectPath &&
|
projectPath &&
|
||||||
isSelectedTeamLoadStillCurrent(get, teamName, requestNonce, teamStateEpoch)
|
isSelectedTeamLoadStillCurrent(get, teamName, requestNonce, requestScope)
|
||||||
) {
|
) {
|
||||||
const state = get();
|
const state = get();
|
||||||
const normalizedTeamPath = normalizePath(projectPath);
|
const normalizedTeamPath = normalizePath(projectPath);
|
||||||
|
|
@ -2305,7 +2373,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
schedulePostPaintTeamEnrichments({
|
schedulePostPaintTeamEnrichments({
|
||||||
teamName,
|
teamName,
|
||||||
requestNonce,
|
requestNonce,
|
||||||
teamStateEpoch,
|
requestScope,
|
||||||
get,
|
get,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -2316,7 +2384,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
|
||||||
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
|
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -2398,7 +2466,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
const requestScope = captureTeamRequestScope(get, teamName);
|
||||||
const refreshToken = beginInFlightTeamDataRefresh(teamName);
|
const refreshToken = beginInFlightTeamDataRefresh(teamName);
|
||||||
// Silent refresh — update data without showing loading skeleton.
|
// Silent refresh — update data without showing loading skeleton.
|
||||||
// Only selectTeam() sets loading: true (for initial load).
|
// Only selectTeam() sets loading: true (for initial load).
|
||||||
|
|
@ -2411,7 +2479,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const data = opts?.withDedup
|
const data = opts?.withDedup
|
||||||
? await fetchTeamDataDeduped(teamName)
|
? await fetchTeamDataDeduped(teamName)
|
||||||
: await fetchTeamDataFresh(teamName);
|
: await fetchTeamDataFresh(teamName);
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const projectedTeamData = previousData
|
const projectedTeamData = previousData
|
||||||
|
|
@ -2462,7 +2530,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const msg =
|
const msg =
|
||||||
|
|
@ -2524,7 +2592,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
set({ selectedTeamError: msg });
|
set({ selectedTeamError: msg });
|
||||||
} finally {
|
} finally {
|
||||||
endInFlightTeamDataRefresh(teamName, refreshToken);
|
endInFlightTeamDataRefresh(teamName, refreshToken);
|
||||||
if (reusedInFlightRequest && pendingFreshTeamDataRefreshes.delete(teamName)) {
|
if (
|
||||||
|
reusedInFlightRequest &&
|
||||||
|
pendingFreshTeamDataRefreshes.delete(teamName) &&
|
||||||
|
isTeamRequestScopeCurrent(get, teamName, requestScope)
|
||||||
|
) {
|
||||||
void get().refreshTeamData(teamName);
|
void get().refreshTeamData(teamName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2543,10 +2615,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
const existingOlderRequest = inFlightTeamMessagesOlderRequests.get(teamName);
|
const existingOlderRequest = inFlightTeamMessagesOlderRequests.get(teamName);
|
||||||
if (existingOlderRequest) {
|
if (existingOlderRequest) {
|
||||||
const queuedEpoch = captureTeamLocalStateEpoch(teamName);
|
const queuedScope = captureTeamRequestScope(get, teamName);
|
||||||
const queuedRequest: Promise<RefreshTeamMessagesHeadResult> = existingOlderRequest
|
const queuedRequest: Promise<RefreshTeamMessagesHeadResult> = existingOlderRequest
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, queuedEpoch)) {
|
if (!isTeamRequestScopeCurrent(get, teamName, queuedScope)) {
|
||||||
return {
|
return {
|
||||||
feedChanged: false,
|
feedChanged: false,
|
||||||
headChanged: false,
|
headChanged: false,
|
||||||
|
|
@ -2577,7 +2649,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
current: null,
|
current: null,
|
||||||
};
|
};
|
||||||
requestRef.current = (async (): Promise<RefreshTeamMessagesHeadResult> => {
|
requestRef.current = (async (): Promise<RefreshTeamMessagesHeadResult> => {
|
||||||
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
const requestScope = captureTeamRequestScope(get, teamName);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
teamMessagesByName: {
|
teamMessagesByName: {
|
||||||
...state.teamMessagesByName,
|
...state.teamMessagesByName,
|
||||||
|
|
@ -2592,7 +2664,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const page = await unwrapIpc('team:getMessagesPage', () =>
|
const page = await unwrapIpc('team:getMessagesPage', () =>
|
||||||
api.teams.getMessagesPage(teamName, { limit: 50 })
|
api.teams.getMessagesPage(teamName, { limit: 50 })
|
||||||
);
|
);
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
|
||||||
return {
|
return {
|
||||||
feedChanged: false,
|
feedChanged: false,
|
||||||
headChanged: false,
|
headChanged: false,
|
||||||
|
|
@ -2648,7 +2720,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
feedRevision: page.feedRevision,
|
feedRevision: page.feedRevision,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
|
||||||
return {
|
return {
|
||||||
feedChanged: false,
|
feedChanged: false,
|
||||||
headChanged: false,
|
headChanged: false,
|
||||||
|
|
@ -2668,7 +2740,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
} finally {
|
} finally {
|
||||||
if (inFlightTeamMessagesHeadRequests.get(teamName) === requestRef.current) {
|
if (inFlightTeamMessagesHeadRequests.get(teamName) === requestRef.current) {
|
||||||
inFlightTeamMessagesHeadRequests.delete(teamName);
|
inFlightTeamMessagesHeadRequests.delete(teamName);
|
||||||
if (pendingFreshTeamMessagesHeadRefreshes.delete(teamName)) {
|
if (
|
||||||
|
pendingFreshTeamMessagesHeadRefreshes.delete(teamName) &&
|
||||||
|
isTeamRequestScopeCurrent(get, teamName, requestScope)
|
||||||
|
) {
|
||||||
void get().refreshTeamMessagesHead(teamName);
|
void get().refreshTeamMessagesHead(teamName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2681,7 +2756,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
},
|
},
|
||||||
|
|
||||||
loadOlderTeamMessages: async (teamName: string) => {
|
loadOlderTeamMessages: async (teamName: string) => {
|
||||||
const requestedEpoch = captureTeamLocalStateEpoch(teamName);
|
const requestedScope = captureTeamRequestScope(get, teamName);
|
||||||
const existingRequest = inFlightTeamMessagesOlderRequests.get(teamName);
|
const existingRequest = inFlightTeamMessagesOlderRequests.get(teamName);
|
||||||
if (existingRequest) {
|
if (existingRequest) {
|
||||||
return existingRequest;
|
return existingRequest;
|
||||||
|
|
@ -2690,7 +2765,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const existingHeadRequest = inFlightTeamMessagesHeadRequests.get(teamName);
|
const existingHeadRequest = inFlightTeamMessagesHeadRequests.get(teamName);
|
||||||
if (existingHeadRequest) {
|
if (existingHeadRequest) {
|
||||||
await existingHeadRequest;
|
await existingHeadRequest;
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, requestedEpoch)) {
|
if (!isTeamRequestScopeCurrent(get, teamName, requestedScope)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2698,7 +2773,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
let entry = getTeamMessagesCacheEntry(get(), teamName);
|
let entry = getTeamMessagesCacheEntry(get(), teamName);
|
||||||
if (!entry.headHydrated) {
|
if (!entry.headHydrated) {
|
||||||
await get().refreshTeamMessagesHead(teamName);
|
await get().refreshTeamMessagesHead(teamName);
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, requestedEpoch)) {
|
if (!isTeamRequestScopeCurrent(get, teamName, requestedScope)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
entry = getTeamMessagesCacheEntry(get(), teamName);
|
entry = getTeamMessagesCacheEntry(get(), teamName);
|
||||||
|
|
@ -2710,7 +2785,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
const requestRef: { current: Promise<void> | null } = { current: null };
|
const requestRef: { current: Promise<void> | null } = { current: null };
|
||||||
requestRef.current = (async (): Promise<void> => {
|
requestRef.current = (async (): Promise<void> => {
|
||||||
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
const requestScope = captureTeamRequestScope(get, teamName);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
teamMessagesByName: {
|
teamMessagesByName: {
|
||||||
...state.teamMessagesByName,
|
...state.teamMessagesByName,
|
||||||
|
|
@ -2729,7 +2804,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2780,7 +2855,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
|
@ -2818,12 +2893,12 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
const requestRef: { current: Promise<void> | null } = { current: null };
|
const requestRef: { current: Promise<void> | null } = { current: null };
|
||||||
requestRef.current = (async (): Promise<void> => {
|
requestRef.current = (async (): Promise<void> => {
|
||||||
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
const requestScope = captureTeamRequestScope(get, teamName);
|
||||||
try {
|
try {
|
||||||
const meta = await unwrapIpc('team:getMemberActivityMeta', () =>
|
const meta = await unwrapIpc('team:getMemberActivityMeta', () =>
|
||||||
api.teams.getMemberActivityMeta(teamName)
|
api.teams.getMemberActivityMeta(teamName)
|
||||||
);
|
);
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2857,14 +2932,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamRequestScopeCurrent(get, teamName, requestScope)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (inFlightTeamMemberActivityMetaRequests.get(teamName) === requestRef.current) {
|
if (inFlightTeamMemberActivityMetaRequests.get(teamName) === requestRef.current) {
|
||||||
inFlightTeamMemberActivityMetaRequests.delete(teamName);
|
inFlightTeamMemberActivityMetaRequests.delete(teamName);
|
||||||
if (pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName)) {
|
if (
|
||||||
|
pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName) &&
|
||||||
|
isTeamRequestScopeCurrent(get, teamName, requestScope)
|
||||||
|
) {
|
||||||
void get().refreshMemberActivityMeta(teamName);
|
void get().refreshMemberActivityMeta(teamName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
src/renderer/store/utils/contextScopedRequestEpoch.ts
Normal file
17
src/renderer/store/utils/contextScopedRequestEpoch.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
let contextScopedRequestEpoch = 0;
|
||||||
|
|
||||||
|
export function captureContextScopedRequestEpoch(): number {
|
||||||
|
return contextScopedRequestEpoch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isContextScopedRequestEpochCurrent(epoch: number): boolean {
|
||||||
|
return contextScopedRequestEpoch === epoch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateContextScopedRequestEpoch(): void {
|
||||||
|
contextScopedRequestEpoch += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetContextScopedRequestEpochForTests(): void {
|
||||||
|
contextScopedRequestEpoch = 0;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
invalidateContextScopedRequestEpoch,
|
||||||
|
resetContextScopedRequestEpochForTests,
|
||||||
|
} from '../../../src/renderer/store/utils/contextScopedRequestEpoch';
|
||||||
|
|
||||||
import { createTestStore } from './storeTestUtils';
|
import { createTestStore } from './storeTestUtils';
|
||||||
|
|
||||||
const apiMock = vi.hoisted(() => ({
|
const apiMock = vi.hoisted(() => ({
|
||||||
|
|
@ -119,6 +124,7 @@ function deferred<T>(): {
|
||||||
describe('context slice team/task reset', () => {
|
describe('context slice team/task reset', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
resetContextScopedRequestEpochForTests();
|
||||||
contextStorageMock.loadSnapshot.mockResolvedValue(targetSnapshot());
|
contextStorageMock.loadSnapshot.mockResolvedValue(targetSnapshot());
|
||||||
apiMock.context.getActive.mockResolvedValue('local');
|
apiMock.context.getActive.mockResolvedValue('local');
|
||||||
apiMock.getProjects.mockResolvedValue(targetSnapshot().projects);
|
apiMock.getProjects.mockResolvedValue(targetSnapshot().projects);
|
||||||
|
|
@ -128,6 +134,7 @@ describe('context slice team/task reset', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
resetContextScopedRequestEpochForTests();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -225,6 +232,53 @@ describe('context slice team/task reset', () => {
|
||||||
expect(store.getState().isContextSwitching).toBe(false);
|
expect(store.getState().isContextSwitching).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not apply a slow background project refresh after the context epoch changes again', async () => {
|
||||||
|
const projectScan = deferred<unknown[]>();
|
||||||
|
apiMock.getProjects.mockReturnValue(projectScan.promise);
|
||||||
|
apiMock.getRepositoryGroups.mockResolvedValue([]);
|
||||||
|
const store = createTestStore();
|
||||||
|
const localProject = {
|
||||||
|
id: 'local-project',
|
||||||
|
name: 'Local Project',
|
||||||
|
path: '/local/project',
|
||||||
|
sessions: [],
|
||||||
|
createdAt: new Date(0).toISOString(),
|
||||||
|
updatedAt: new Date(0).toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchPromise = store.getState().switchContext('ssh-dev');
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(store.getState().activeContextId).toBe('ssh-dev');
|
||||||
|
expect(store.getState().isContextSwitching).toBe(false);
|
||||||
|
|
||||||
|
invalidateContextScopedRequestEpoch();
|
||||||
|
store.setState({
|
||||||
|
activeContextId: 'local',
|
||||||
|
projects: [localProject],
|
||||||
|
repositoryGroups: [],
|
||||||
|
isContextSwitching: false,
|
||||||
|
targetContextId: null,
|
||||||
|
} as never);
|
||||||
|
projectScan.resolve([
|
||||||
|
{
|
||||||
|
id: 'late-ssh-project',
|
||||||
|
name: 'Late SSH Project',
|
||||||
|
path: '/ssh/late',
|
||||||
|
sessions: [],
|
||||||
|
createdAt: new Date(0).toISOString(),
|
||||||
|
updatedAt: new Date(0).toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await switchPromise;
|
||||||
|
|
||||||
|
expect(store.getState().activeContextId).toBe('local');
|
||||||
|
expect(store.getState().projects).toEqual([localProject]);
|
||||||
|
expect(apiMock.teams.list).not.toHaveBeenCalled();
|
||||||
|
expect(apiMock.teams.getAllTasks).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('drops previous-context team and task caches when lazy context initialization changes context', async () => {
|
it('drops previous-context team and task caches when lazy context initialization changes context', async () => {
|
||||||
apiMock.context.getActive.mockResolvedValue('ssh-dev');
|
apiMock.context.getActive.mockResolvedValue('ssh-dev');
|
||||||
const store = createTestStore();
|
const store = createTestStore();
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
__getTeamScopedTransientStateForTests,
|
||||||
__resetTeamSliceModuleStateForTests,
|
__resetTeamSliceModuleStateForTests,
|
||||||
createTeamSlice,
|
createTeamSlice,
|
||||||
} from '../../../src/renderer/store/slices/teamSlice';
|
} from '../../../src/renderer/store/slices/teamSlice';
|
||||||
|
import { invalidateTeamLocalStateEpoch } from '../../../src/renderer/store/team/teamLocalStateEpoch';
|
||||||
|
import { invalidateContextScopedRequestEpoch } from '../../../src/renderer/store/utils/contextScopedRequestEpoch';
|
||||||
|
|
||||||
import type { AppState } from '../../../src/renderer/store/types';
|
import type { AppState } from '../../../src/renderer/store/types';
|
||||||
|
|
||||||
|
|
@ -12,8 +15,17 @@ const apiMock = vi.hoisted(() => ({
|
||||||
teams: {
|
teams: {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
getAllTasks: vi.fn(),
|
getAllTasks: vi.fn(),
|
||||||
|
getData: vi.fn(),
|
||||||
|
getMessagesPage: vi.fn(),
|
||||||
|
getMemberActivityMeta: vi.fn(),
|
||||||
|
getMemberSpawnStatuses: vi.fn(),
|
||||||
|
getTeamAgentRuntime: vi.fn(),
|
||||||
|
getTaskChangePresence: vi.fn(),
|
||||||
showMessageNotification: vi.fn(async () => undefined),
|
showMessageNotification: vi.fn(async () => undefined),
|
||||||
},
|
},
|
||||||
|
review: {
|
||||||
|
invalidateTaskChangeSummaries: vi.fn(async () => undefined),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface TeamSummaryLike {
|
interface TeamSummaryLike {
|
||||||
|
|
@ -32,6 +44,69 @@ interface GlobalTaskLike {
|
||||||
comments: [];
|
comments: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TeamSnapshotLike {
|
||||||
|
teamName: string;
|
||||||
|
config: {
|
||||||
|
name: string;
|
||||||
|
projectPath: string;
|
||||||
|
};
|
||||||
|
tasks: Array<{
|
||||||
|
id: string;
|
||||||
|
changePresence?: string;
|
||||||
|
}>;
|
||||||
|
members: [];
|
||||||
|
kanbanState: {
|
||||||
|
teamName: string;
|
||||||
|
reviewers: [];
|
||||||
|
tasks: Record<string, never>;
|
||||||
|
};
|
||||||
|
processes: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamSnapshot = (
|
||||||
|
teamName: string,
|
||||||
|
projectPath: string,
|
||||||
|
tasks: TeamSnapshotLike['tasks'] = []
|
||||||
|
): TeamSnapshotLike => ({
|
||||||
|
teamName,
|
||||||
|
config: {
|
||||||
|
name: teamName,
|
||||||
|
projectPath,
|
||||||
|
},
|
||||||
|
tasks,
|
||||||
|
members: [],
|
||||||
|
kanbanState: {
|
||||||
|
teamName,
|
||||||
|
reviewers: [],
|
||||||
|
tasks: {},
|
||||||
|
},
|
||||||
|
processes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberSpawnSnapshot = {
|
||||||
|
runId: 'runtime-run',
|
||||||
|
statuses: {
|
||||||
|
lead: {
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtimeSnapshot = {
|
||||||
|
teamName: 'shared-team',
|
||||||
|
updatedAt: '2026-03-12T10:00:00.000Z',
|
||||||
|
runId: 'runtime-run',
|
||||||
|
members: {
|
||||||
|
lead: {
|
||||||
|
memberName: 'lead',
|
||||||
|
alive: true,
|
||||||
|
restartable: true,
|
||||||
|
updatedAt: '2026-03-12T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock('@renderer/api', () => ({
|
vi.mock('@renderer/api', () => ({
|
||||||
api: apiMock,
|
api: apiMock,
|
||||||
}));
|
}));
|
||||||
|
|
@ -39,12 +114,21 @@ vi.mock('@renderer/api', () => ({
|
||||||
function deferred<T>(): {
|
function deferred<T>(): {
|
||||||
promise: Promise<T>;
|
promise: Promise<T>;
|
||||||
resolve: (value: T) => void;
|
resolve: (value: T) => void;
|
||||||
|
reject: (reason?: unknown) => void;
|
||||||
} {
|
} {
|
||||||
let resolve!: (value: T) => void;
|
let resolve!: (value: T) => void;
|
||||||
const promise = new Promise<T>((innerResolve) => {
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((innerResolve, innerReject) => {
|
||||||
resolve = innerResolve;
|
resolve = innerResolve;
|
||||||
|
reject = innerReject;
|
||||||
});
|
});
|
||||||
return { promise, resolve };
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks(): Promise<void> {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSliceStore() {
|
function createSliceStore() {
|
||||||
|
|
@ -70,6 +154,11 @@ function createSliceStore() {
|
||||||
getAllPaneTabs: vi.fn(() => []),
|
getAllPaneTabs: vi.fn(() => []),
|
||||||
warmTaskChangeSummaries: vi.fn(async () => undefined),
|
warmTaskChangeSummaries: vi.fn(async () => undefined),
|
||||||
invalidateTaskChangePresence: vi.fn(),
|
invalidateTaskChangePresence: vi.fn(),
|
||||||
|
projects: [],
|
||||||
|
repositoryGroups: [],
|
||||||
|
selectedProjectId: null,
|
||||||
|
selectedWorktreeId: null,
|
||||||
|
fetchSessionsInitial: vi.fn(async () => undefined),
|
||||||
}) as unknown as AppState
|
}) as unknown as AppState
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +168,14 @@ describe('team slice context races', () => {
|
||||||
__resetTeamSliceModuleStateForTests();
|
__resetTeamSliceModuleStateForTests();
|
||||||
apiMock.teams.list.mockReset();
|
apiMock.teams.list.mockReset();
|
||||||
apiMock.teams.getAllTasks.mockReset();
|
apiMock.teams.getAllTasks.mockReset();
|
||||||
|
apiMock.teams.getData.mockReset();
|
||||||
|
apiMock.teams.getMessagesPage.mockReset();
|
||||||
|
apiMock.teams.getMemberActivityMeta.mockReset();
|
||||||
|
apiMock.teams.getMemberSpawnStatuses.mockReset();
|
||||||
|
apiMock.teams.getTeamAgentRuntime.mockReset();
|
||||||
|
apiMock.teams.getTaskChangePresence.mockReset();
|
||||||
apiMock.teams.showMessageNotification.mockClear();
|
apiMock.teams.showMessageNotification.mockClear();
|
||||||
|
apiMock.review.invalidateTaskChangeSummaries.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -116,6 +212,36 @@ describe('team slice context races', () => {
|
||||||
expect(store.getState().teamsLoading).toBe(false);
|
expect(store.getState().teamsLoading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores a team list response loaded before a context epoch reset with the same context id', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const localList = deferred<TeamSummaryLike[]>();
|
||||||
|
apiMock.teams.list.mockReturnValueOnce(localList.promise);
|
||||||
|
|
||||||
|
const fetchPromise = store.getState().fetchTeams();
|
||||||
|
expect(store.getState().teamsLoading).toBe(true);
|
||||||
|
|
||||||
|
invalidateContextScopedRequestEpoch();
|
||||||
|
store.setState({
|
||||||
|
activeContextId: 'local',
|
||||||
|
teams: [],
|
||||||
|
teamByName: {},
|
||||||
|
teamBySessionId: {},
|
||||||
|
teamsLoading: false,
|
||||||
|
});
|
||||||
|
localList.resolve([
|
||||||
|
{
|
||||||
|
teamName: 'old-local-team',
|
||||||
|
displayName: 'Old Local Team',
|
||||||
|
projectPath: '/old-local/project',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await fetchPromise;
|
||||||
|
|
||||||
|
expect(store.getState().teams).toEqual([]);
|
||||||
|
expect(store.getState().teamByName).toEqual({});
|
||||||
|
expect(store.getState().teamsLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('reruns a pending global task refresh for the current context instead of applying stale tasks', async () => {
|
it('reruns a pending global task refresh for the current context instead of applying stale tasks', async () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
const localTasks = deferred<GlobalTaskLike[]>();
|
const localTasks = deferred<GlobalTaskLike[]>();
|
||||||
|
|
@ -163,4 +289,294 @@ describe('team slice context races', () => {
|
||||||
expect(store.getState().globalTasksInitialized).toBe(true);
|
expect(store.getState().globalTasksInitialized).toBe(true);
|
||||||
expect(store.getState().globalTasksLoading).toBe(false);
|
expect(store.getState().globalTasksLoading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores global tasks loaded before a context epoch reset with the same context id', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const localTasks = deferred<GlobalTaskLike[]>();
|
||||||
|
apiMock.teams.getAllTasks.mockReturnValueOnce(localTasks.promise);
|
||||||
|
|
||||||
|
const fetchPromise = store.getState().fetchAllTasks();
|
||||||
|
expect(store.getState().globalTasksLoading).toBe(true);
|
||||||
|
|
||||||
|
invalidateContextScopedRequestEpoch();
|
||||||
|
store.setState({
|
||||||
|
activeContextId: 'local',
|
||||||
|
globalTasks: [],
|
||||||
|
globalTasksLoading: false,
|
||||||
|
globalTasksInitialized: false,
|
||||||
|
});
|
||||||
|
localTasks.resolve([
|
||||||
|
{
|
||||||
|
id: 'old-local-task',
|
||||||
|
subject: 'Old local task',
|
||||||
|
status: 'todo',
|
||||||
|
teamName: 'old-local-team',
|
||||||
|
teamDisplayName: 'Old Local Team',
|
||||||
|
projectPath: '/old-local/project',
|
||||||
|
comments: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await fetchPromise;
|
||||||
|
|
||||||
|
expect(store.getState().globalTasks).toEqual([]);
|
||||||
|
expect(store.getState().globalTasksInitialized).toBe(false);
|
||||||
|
expect(store.getState().globalTasksLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores selected team data loaded for a previous context', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const localData = deferred<TeamSnapshotLike>();
|
||||||
|
apiMock.teams.getData.mockReturnValueOnce(localData.promise);
|
||||||
|
|
||||||
|
const selectPromise = store.getState().selectTeam('shared-team');
|
||||||
|
expect(store.getState().selectedTeamName).toBe('shared-team');
|
||||||
|
|
||||||
|
store.setState({
|
||||||
|
activeContextId: 'ssh-dev',
|
||||||
|
selectedTeamName: null,
|
||||||
|
selectedTeamData: null,
|
||||||
|
selectedTeamLoading: false,
|
||||||
|
teamDataCacheByName: {},
|
||||||
|
});
|
||||||
|
localData.resolve(teamSnapshot('shared-team', '/local/project'));
|
||||||
|
await selectPromise;
|
||||||
|
|
||||||
|
expect(store.getState().selectedTeamName).toBeNull();
|
||||||
|
expect(store.getState().selectedTeamData).toBeNull();
|
||||||
|
expect(store.getState().teamDataCacheByName).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores selected team data loaded before a context epoch reset with the same context id', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const localData = deferred<TeamSnapshotLike>();
|
||||||
|
apiMock.teams.getData.mockReturnValueOnce(localData.promise);
|
||||||
|
|
||||||
|
const selectPromise = store.getState().selectTeam('shared-team');
|
||||||
|
expect(store.getState().selectedTeamName).toBe('shared-team');
|
||||||
|
|
||||||
|
invalidateContextScopedRequestEpoch();
|
||||||
|
store.setState({
|
||||||
|
activeContextId: 'local',
|
||||||
|
selectedTeamName: null,
|
||||||
|
selectedTeamData: null,
|
||||||
|
selectedTeamLoading: false,
|
||||||
|
teamDataCacheByName: {},
|
||||||
|
});
|
||||||
|
localData.resolve(teamSnapshot('shared-team', '/old-local/project'));
|
||||||
|
await selectPromise;
|
||||||
|
|
||||||
|
expect(store.getState().selectedTeamName).toBeNull();
|
||||||
|
expect(store.getState().selectedTeamData).toBeNull();
|
||||||
|
expect(store.getState().teamDataCacheByName).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a stale silent team refresh overwrite the current context cache', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const sshData = teamSnapshot('shared-team', '/ssh/project');
|
||||||
|
const localData = deferred<TeamSnapshotLike>();
|
||||||
|
apiMock.teams.getData.mockReturnValueOnce(localData.promise);
|
||||||
|
|
||||||
|
const refreshPromise = store.getState().refreshTeamData('shared-team');
|
||||||
|
store.setState({
|
||||||
|
activeContextId: 'ssh-dev',
|
||||||
|
teamDataCacheByName: {
|
||||||
|
'shared-team': sshData,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
localData.resolve(teamSnapshot('shared-team', '/local/project'));
|
||||||
|
await refreshPromise;
|
||||||
|
|
||||||
|
expect(store.getState().teamDataCacheByName['shared-team']).toBe(sshData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores message head pages loaded for a previous context', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const localMessages = deferred<{
|
||||||
|
messages: [];
|
||||||
|
feedRevision: string;
|
||||||
|
nextCursor: null;
|
||||||
|
hasMore: false;
|
||||||
|
}>();
|
||||||
|
apiMock.teams.getMessagesPage.mockReturnValueOnce(localMessages.promise);
|
||||||
|
|
||||||
|
const refreshPromise = store.getState().refreshTeamMessagesHead('shared-team');
|
||||||
|
expect(store.getState().teamMessagesByName['shared-team']).toBeDefined();
|
||||||
|
|
||||||
|
store.setState({
|
||||||
|
activeContextId: 'ssh-dev',
|
||||||
|
teamMessagesByName: {},
|
||||||
|
});
|
||||||
|
localMessages.resolve({
|
||||||
|
messages: [],
|
||||||
|
feedRevision: 'local-feed',
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
});
|
||||||
|
await refreshPromise;
|
||||||
|
|
||||||
|
expect(store.getState().teamMessagesByName).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores member spawn statuses loaded before a same-context team reset', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const localStatuses = deferred<typeof memberSpawnSnapshot>();
|
||||||
|
apiMock.teams.getMemberSpawnStatuses.mockReturnValueOnce(localStatuses.promise);
|
||||||
|
|
||||||
|
const refreshPromise = store.getState().fetchMemberSpawnStatuses('shared-team');
|
||||||
|
invalidateTeamLocalStateEpoch('shared-team');
|
||||||
|
localStatuses.resolve(memberSpawnSnapshot);
|
||||||
|
await refreshPromise;
|
||||||
|
|
||||||
|
expect(store.getState().memberSpawnStatusesByTeam).toEqual({});
|
||||||
|
expect(store.getState().memberSpawnSnapshotsByTeam).toEqual({});
|
||||||
|
expect(store.getState().currentRuntimeRunIdByTeam).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let stale member spawn IPC failures poison the next team scope', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const staleFailure = deferred<never>();
|
||||||
|
apiMock.teams.getMemberSpawnStatuses.mockReturnValueOnce(staleFailure.promise);
|
||||||
|
|
||||||
|
const refreshPromise = store.getState().fetchMemberSpawnStatuses('shared-team');
|
||||||
|
invalidateTeamLocalStateEpoch('shared-team');
|
||||||
|
staleFailure.reject(new Error("No handler registered for 'team:memberSpawnStatuses'"));
|
||||||
|
await refreshPromise;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
__getTeamScopedTransientStateForTests('shared-team').hasMemberSpawnStatusesIpcBackoff
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores agent runtime snapshots loaded before a same-context team reset', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const localRuntime = deferred<typeof runtimeSnapshot>();
|
||||||
|
apiMock.teams.getTeamAgentRuntime.mockReturnValueOnce(localRuntime.promise);
|
||||||
|
|
||||||
|
const refreshPromise = store.getState().fetchTeamAgentRuntime('shared-team');
|
||||||
|
invalidateTeamLocalStateEpoch('shared-team');
|
||||||
|
localRuntime.resolve(runtimeSnapshot);
|
||||||
|
await refreshPromise;
|
||||||
|
|
||||||
|
expect(store.getState().teamAgentRuntimeByTeam).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores change presence loaded before a same-context team reset', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const staleData = teamSnapshot('shared-team', '/local/project', [
|
||||||
|
{ id: 'task-1', changePresence: 'unknown' },
|
||||||
|
]);
|
||||||
|
const localPresence = deferred<{ 'task-1': 'has_changes' }>();
|
||||||
|
apiMock.teams.getTaskChangePresence.mockReturnValueOnce(localPresence.promise);
|
||||||
|
store.setState({
|
||||||
|
selectedTeamName: 'shared-team',
|
||||||
|
selectedTeamData: staleData,
|
||||||
|
teamDataCacheByName: {
|
||||||
|
'shared-team': staleData,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const refreshPromise = store.getState().refreshTeamChangePresence('shared-team');
|
||||||
|
invalidateTeamLocalStateEpoch('shared-team');
|
||||||
|
localPresence.resolve({ 'task-1': 'has_changes' });
|
||||||
|
await refreshPromise;
|
||||||
|
|
||||||
|
expect(store.getState().selectedTeamData).toBe(staleData);
|
||||||
|
expect(store.getState().teamDataCacheByName['shared-team']).toBe(staleData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not rerun pending full team data refreshes from a stale scope', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const localData = deferred<TeamSnapshotLike>();
|
||||||
|
apiMock.teams.getData
|
||||||
|
.mockReturnValueOnce(localData.promise)
|
||||||
|
.mockResolvedValueOnce(teamSnapshot('shared-team', '/unexpected/project'));
|
||||||
|
|
||||||
|
const firstRefresh = store.getState().refreshTeamData('shared-team', { withDedup: true });
|
||||||
|
const secondRefresh = store.getState().refreshTeamData('shared-team', { withDedup: true });
|
||||||
|
invalidateTeamLocalStateEpoch('shared-team');
|
||||||
|
store.setState({ teamDataCacheByName: {} });
|
||||||
|
localData.resolve(teamSnapshot('shared-team', '/local/project'));
|
||||||
|
await Promise.all([firstRefresh, secondRefresh]);
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.teams.getData).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not rerun pending message head refreshes from a stale scope', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const localMessages = deferred<{
|
||||||
|
messages: [];
|
||||||
|
feedRevision: string;
|
||||||
|
nextCursor: null;
|
||||||
|
hasMore: false;
|
||||||
|
}>();
|
||||||
|
apiMock.teams.getMessagesPage.mockReturnValueOnce(localMessages.promise).mockResolvedValueOnce({
|
||||||
|
messages: [],
|
||||||
|
feedRevision: 'unexpected-feed',
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRefresh = store.getState().refreshTeamMessagesHead('shared-team');
|
||||||
|
const secondRefresh = store.getState().refreshTeamMessagesHead('shared-team');
|
||||||
|
invalidateTeamLocalStateEpoch('shared-team');
|
||||||
|
store.setState({ teamMessagesByName: {} });
|
||||||
|
localMessages.resolve({
|
||||||
|
messages: [],
|
||||||
|
feedRevision: 'local-feed',
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
});
|
||||||
|
await Promise.all([firstRefresh, secondRefresh]);
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.teams.getMessagesPage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not rerun pending member activity meta refreshes from a stale scope', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const localMeta = deferred<{
|
||||||
|
teamName: string;
|
||||||
|
computedAt: string;
|
||||||
|
feedRevision: string;
|
||||||
|
members: Record<string, never>;
|
||||||
|
}>();
|
||||||
|
apiMock.teams.getMemberActivityMeta.mockReturnValueOnce(localMeta.promise).mockResolvedValueOnce({
|
||||||
|
teamName: 'shared-team',
|
||||||
|
computedAt: '2026-03-12T10:00:01.000Z',
|
||||||
|
feedRevision: 'unexpected-feed',
|
||||||
|
members: {},
|
||||||
|
});
|
||||||
|
store.setState({
|
||||||
|
teamMessagesByName: {
|
||||||
|
'shared-team': {
|
||||||
|
canonicalMessages: [],
|
||||||
|
optimisticMessages: [],
|
||||||
|
feedRevision: 'feed-1',
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
lastFetchedAt: 0,
|
||||||
|
loadingHead: false,
|
||||||
|
loadingOlder: false,
|
||||||
|
headHydrated: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const firstRefresh = store.getState().refreshMemberActivityMeta('shared-team');
|
||||||
|
const secondRefresh = store.getState().refreshMemberActivityMeta('shared-team');
|
||||||
|
invalidateTeamLocalStateEpoch('shared-team');
|
||||||
|
store.setState({ teamMessagesByName: {}, memberActivityMetaByTeam: {} });
|
||||||
|
localMeta.resolve({
|
||||||
|
teamName: 'shared-team',
|
||||||
|
computedAt: '2026-03-12T10:00:00.000Z',
|
||||||
|
feedRevision: 'feed-1',
|
||||||
|
members: {},
|
||||||
|
});
|
||||||
|
await Promise.all([firstRefresh, secondRefresh]);
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.teams.getMemberActivityMeta).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue