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 { 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<void> => {
|
||||
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()) {
|
||||
|
|
|
|||
|
|
@ -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