fix(recent-projects): guard context-scoped refreshes
This commit is contained in:
parent
d32db985b5
commit
7514bf05eb
2 changed files with 288 additions and 5 deletions
|
|
@ -4,6 +4,10 @@ import { type DashboardRecentProject } from '@features/recent-projects/contracts
|
||||||
import { api, isElectronMode } from '@renderer/api';
|
import { api, isElectronMode } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||||
|
import {
|
||||||
|
captureContextScopedRequestEpoch,
|
||||||
|
isContextScopedRequestEpochCurrent,
|
||||||
|
} from '@renderer/store/utils/contextScopedRequestEpoch';
|
||||||
import { buildTaskCountsByProject } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByProject } from '@renderer/utils/pathNormalize';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -134,6 +138,7 @@ export function useRecentProjectsSection(
|
||||||
const reload = useCallback(
|
const reload = useCallback(
|
||||||
async (options?: { force?: boolean }): Promise<void> => {
|
async (options?: { force?: boolean }): Promise<void> => {
|
||||||
const requestContextId = activeContextId;
|
const requestContextId = activeContextId;
|
||||||
|
const requestContextEpoch = captureContextScopedRequestEpoch();
|
||||||
const hasVisibleProjects =
|
const hasVisibleProjects =
|
||||||
recentProjectsRef.current.length > 0 ||
|
recentProjectsRef.current.length > 0 ||
|
||||||
getRecentProjectsClientSnapshot(requestContextId) != null;
|
getRecentProjectsClientSnapshot(requestContextId) != null;
|
||||||
|
|
@ -148,19 +153,28 @@ export function useRecentProjectsSection(
|
||||||
() => api.getDashboardRecentProjects(),
|
() => api.getDashboardRecentProjects(),
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
if (activeContextIdRef.current !== requestContextId) {
|
if (
|
||||||
|
activeContextIdRef.current !== requestContextId ||
|
||||||
|
!isContextScopedRequestEpochCurrent(requestContextEpoch)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRecentProjects(payload.projects);
|
setRecentProjects(payload.projects);
|
||||||
setRecentProjectsDegraded(payload.degraded);
|
setRecentProjectsDegraded(payload.degraded);
|
||||||
setDegradedRefreshCount((current) => (payload.degraded ? current + 1 : 0));
|
setDegradedRefreshCount((current) => (payload.degraded ? current + 1 : 0));
|
||||||
} catch (nextError) {
|
} catch (nextError) {
|
||||||
if (activeContextIdRef.current !== requestContextId) {
|
if (
|
||||||
|
activeContextIdRef.current !== requestContextId ||
|
||||||
|
!isContextScopedRequestEpochCurrent(requestContextEpoch)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects');
|
setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects');
|
||||||
} finally {
|
} finally {
|
||||||
if (activeContextIdRef.current === requestContextId) {
|
if (
|
||||||
|
activeContextIdRef.current === requestContextId &&
|
||||||
|
isContextScopedRequestEpochCurrent(requestContextEpoch)
|
||||||
|
) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -220,11 +234,17 @@ export function useRecentProjectsSection(
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const requestContextId = activeContextId;
|
||||||
|
const requestContextEpoch = captureContextScopedRequestEpoch();
|
||||||
|
|
||||||
void api.teams
|
void api.teams
|
||||||
.aliveList()
|
.aliveList()
|
||||||
.then((teamNames) => {
|
.then((teamNames) => {
|
||||||
if (!cancelled) {
|
if (
|
||||||
|
!cancelled &&
|
||||||
|
activeContextIdRef.current === requestContextId &&
|
||||||
|
isContextScopedRequestEpochCurrent(requestContextEpoch)
|
||||||
|
) {
|
||||||
setAliveTeams(teamNames);
|
setAliveTeams(teamNames);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -233,7 +253,7 @@ export function useRecentProjectsSection(
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [provisioningTeamNamesKey, teams]);
|
}, [activeContextId, provisioningTeamNamesKey, teams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
|
|
|
||||||
|
|
@ -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<typeof storeState>) => {
|
||||||
|
Object.assign(storeState, patch);
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return { useStore };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('zustand/react/shallow', () => ({
|
||||||
|
useShallow: <T,>(selector: T) => selector,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function deferred<T>(): {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
} {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((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<void> {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useRecentProjectsSection', () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let latest: ReturnType<typeof useRecentProjectsSection> | null;
|
||||||
|
|
||||||
|
function Harness(): React.JSX.Element | null {
|
||||||
|
latest = useRecentProjectsSection('', 20);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderHarness(): Promise<void> {
|
||||||
|
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<DashboardRecentProjectsPayload>();
|
||||||
|
const sshRequest = deferred<DashboardRecentProjectsPayload>();
|
||||||
|
const freshLocalRequest = deferred<DashboardRecentProjectsPayload>();
|
||||||
|
|
||||||
|
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<string[]>();
|
||||||
|
const sshAliveRequest = deferred<string[]>();
|
||||||
|
const freshAliveRequest = deferred<string[]>();
|
||||||
|
|
||||||
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue