fix(recent-projects): scope client cache by context

This commit is contained in:
777genius 2026-05-26 16:35:28 +03:00
parent 72633daa6e
commit b46b53d667
4 changed files with 178 additions and 69 deletions

View file

@ -47,7 +47,9 @@ export function createRecentProjectsFeature(deps: {
return { return {
listDashboardRecentProjects: async () => { listDashboardRecentProjects: async () => {
const activeContext = deps.getActiveContext(); const activeContext = deps.getActiveContext();
const payload = await useCase.execute(`dashboard-recent-projects:${activeContext.id}`); const payload = await useCase.execute(
`dashboard-recent-projects:${activeContext.type}:${activeContext.id}`
);
return normalizeDashboardRecentProjectsPayload(payload) ?? { projects: [], degraded: true }; return normalizeDashboardRecentProjectsPayload(payload) ?? { projects: [], degraded: true };
}, },
}; };

View file

@ -21,7 +21,6 @@ import {
import { useOpenRecentProject } from './useOpenRecentProject'; import { useOpenRecentProject } from './useOpenRecentProject';
import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter'; import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter';
import type { TeamSummary } from '@shared/types';
const INITIAL_RECENT_PROJECTS = 11; const INITIAL_RECENT_PROJECTS = 11;
const LOAD_MORE_STEP = 8; const LOAD_MORE_STEP = 8;
@ -70,6 +69,7 @@ export function useRecentProjectsSection(
globalTasksLoading, globalTasksLoading,
fetchAllTasks, fetchAllTasks,
teams, teams,
activeContextId,
provisioningRuns, provisioningRuns,
currentProvisioningRunIdByTeam, currentProvisioningRunIdByTeam,
provisioningSnapshotByTeam, provisioningSnapshotByTeam,
@ -80,12 +80,16 @@ export function useRecentProjectsSection(
globalTasksLoading: state.globalTasksLoading, globalTasksLoading: state.globalTasksLoading,
fetchAllTasks: state.fetchAllTasks, fetchAllTasks: state.fetchAllTasks,
teams: state.teams, teams: state.teams,
activeContextId: state.activeContextId,
provisioningRuns: state.provisioningRuns, provisioningRuns: state.provisioningRuns,
currentProvisioningRunIdByTeam: state.currentProvisioningRunIdByTeam, currentProvisioningRunIdByTeam: state.currentProvisioningRunIdByTeam,
provisioningSnapshotByTeam: state.provisioningSnapshotByTeam, provisioningSnapshotByTeam: state.provisioningSnapshotByTeam,
})) }))
); );
const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []); const initialSnapshot = useMemo(
() => getRecentProjectsClientSnapshot(activeContextId),
[activeContextId]
);
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject(); const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>( const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
initialSnapshot?.payload.projects ?? [] initialSnapshot?.payload.projects ?? []
@ -105,6 +109,7 @@ export function useRecentProjectsSection(
const recentProjectsRef = useRef<DashboardRecentProject[]>( const recentProjectsRef = useRef<DashboardRecentProject[]>(
initialSnapshot?.payload.projects ?? [] initialSnapshot?.payload.projects ?? []
); );
const activeContextIdRef = useRef(activeContextId);
const provisioningState = useMemo( const provisioningState = useMemo(
() => ({ currentProvisioningRunIdByTeam, provisioningRuns }), () => ({ currentProvisioningRunIdByTeam, provisioningRuns }),
[currentProvisioningRunIdByTeam, provisioningRuns] [currentProvisioningRunIdByTeam, provisioningRuns]
@ -125,37 +130,67 @@ export function useRecentProjectsSection(
recentProjectsRef.current = recentProjects; recentProjectsRef.current = recentProjects;
}, [recentProjects]); }, [recentProjects]);
const reload = useCallback(async (options?: { force?: boolean }): Promise<void> => { useEffect(() => {
const hasVisibleProjects = activeContextIdRef.current = activeContextId;
recentProjectsRef.current.length > 0 || getRecentProjectsClientSnapshot() != null; }, [activeContextId]);
if (!hasVisibleProjects) { const reload = useCallback(
setLoading(true); async (options?: { force?: boolean }): Promise<void> => {
} const requestContextId = activeContextId;
setError(null); const hasVisibleProjects =
try { recentProjectsRef.current.length > 0 ||
const payload = await loadRecentProjectsWithClientCache( getRecentProjectsClientSnapshot(requestContextId) != null;
() => api.getDashboardRecentProjects(),
options if (!hasVisibleProjects) {
); setLoading(true);
setRecentProjects(payload.projects); }
setRecentProjectsDegraded(payload.degraded); setError(null);
setDegradedRefreshCount((current) => (payload.degraded ? current + 1 : 0)); try {
} catch (nextError) { const payload = await loadRecentProjectsWithClientCache(
setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects'); requestContextId,
} finally { () => api.getDashboardRecentProjects(),
setLoading(false); options
} );
}, []); if (activeContextIdRef.current !== requestContextId) {
return;
}
setRecentProjects(payload.projects);
setRecentProjectsDegraded(payload.degraded);
setDegradedRefreshCount((current) => (payload.degraded ? current + 1 : 0));
} catch (nextError) {
if (activeContextIdRef.current !== requestContextId) {
return;
}
setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects');
} finally {
if (activeContextIdRef.current === requestContextId) {
setLoading(false);
}
}
},
[activeContextId]
);
useEffect(() => { useEffect(() => {
const snapshot = getRecentProjectsClientSnapshot(); const snapshot = getRecentProjectsClientSnapshot(activeContextId);
if (snapshot) {
setRecentProjects(snapshot.payload.projects);
setRecentProjectsDegraded(snapshot.payload.degraded);
setDegradedRefreshCount(snapshot.payload.degraded ? 1 : 0);
setLoading(false);
} else {
setRecentProjects([]);
setRecentProjectsDegraded(false);
setDegradedRefreshCount(0);
setLoading(true);
}
if (snapshot && !snapshot.isStale) { if (snapshot && !snapshot.isStale) {
return; return;
} }
void reload({ force: snapshot != null }); void reload({ force: snapshot != null });
}, [reload]); }, [activeContextId, reload]);
useEffect(() => { useEffect(() => {
if (!recentProjectsDegraded) { if (!recentProjectsDegraded) {
@ -225,22 +260,21 @@ export function useRecentProjectsSection(
}); });
}, [aliveTeams, provisioningSnapshotByTeam, provisioningTeamNames, teams]); }, [aliveTeams, provisioningSnapshotByTeam, provisioningTeamNames, teams]);
const decoratedCards = useMemo( const decoratedCards = useMemo(() => {
() => void openHistoryVersion;
adaptRecentProjectsSection({ return adaptRecentProjectsSection({
projects: sortRecentProjectsByDisplayPriority(recentProjects), projects: sortRecentProjectsByDisplayPriority(recentProjects),
taskCountsByProject,
activeTeamsByProject,
tasksLoading: globalTasksLoading,
}),
[
activeTeamsByProject,
globalTasksLoading,
openHistoryVersion,
recentProjects,
taskCountsByProject, taskCountsByProject,
] activeTeamsByProject,
); tasksLoading: globalTasksLoading,
});
}, [
activeTeamsByProject,
globalTasksLoading,
openHistoryVersion,
recentProjects,
taskCountsByProject,
]);
const filteredCards = useMemo( const filteredCards = useMemo(
() => decoratedCards.filter((card) => matchesSearch(card.project, searchQuery)), () => decoratedCards.filter((card) => matchesSearch(card.project, searchQuery)),

View file

@ -9,8 +9,9 @@ const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000;
const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 30_000; const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 30_000;
let cachedPayload: DashboardRecentProjectsPayloadLike = null; let cachedPayload: DashboardRecentProjectsPayloadLike = null;
let cachedKey: string | null = null;
let cachedAt = 0; let cachedAt = 0;
let inFlightLoad: Promise<DashboardRecentProjectsPayload> | null = null; let inFlightLoad: { key: string; promise: Promise<DashboardRecentProjectsPayload> } | null = null;
export interface RecentProjectsClientSnapshot { export interface RecentProjectsClientSnapshot {
payload: DashboardRecentProjectsPayload; payload: DashboardRecentProjectsPayload;
@ -18,7 +19,13 @@ export interface RecentProjectsClientSnapshot {
isStale: boolean; isStale: boolean;
} }
export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot | null { export function getRecentProjectsClientSnapshot(
cacheKey: string
): RecentProjectsClientSnapshot | null {
if (cachedKey !== cacheKey) {
return null;
}
const normalizedPayload = normalizeDashboardRecentProjectsPayload(cachedPayload); const normalizedPayload = normalizeDashboardRecentProjectsPayload(cachedPayload);
if (!normalizedPayload) { if (!normalizedPayload) {
return null; return null;
@ -40,39 +47,44 @@ export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot
} }
export async function loadRecentProjectsWithClientCache( export async function loadRecentProjectsWithClientCache(
cacheKey: string,
loader: () => Promise<DashboardRecentProjectsPayloadLike>, loader: () => Promise<DashboardRecentProjectsPayloadLike>,
options?: { force?: boolean } options?: { force?: boolean }
): Promise<DashboardRecentProjectsPayload> { ): Promise<DashboardRecentProjectsPayload> {
const force = options?.force ?? false; const force = options?.force ?? false;
const snapshot = getRecentProjectsClientSnapshot(); const snapshot = getRecentProjectsClientSnapshot(cacheKey);
if (!force && snapshot && !snapshot.isStale) { if (!force && snapshot && !snapshot.isStale) {
return snapshot.payload; return snapshot.payload;
} }
if (inFlightLoad) { if (inFlightLoad?.key === cacheKey) {
return inFlightLoad; return inFlightLoad.promise;
} }
const request = loader() const request = loader()
.then((payloadLike) => { .then((payloadLike) => {
const normalizedPayload = normalizeDashboardRecentProjectsPayload(payloadLike); const normalizedPayload = normalizeDashboardRecentProjectsPayload(payloadLike);
cachedPayload = normalizedPayload; if (inFlightLoad?.key === cacheKey && inFlightLoad.promise === request) {
cachedAt = Date.now(); cachedKey = normalizedPayload ? cacheKey : null;
cachedPayload = normalizedPayload;
cachedAt = Date.now();
}
return normalizedPayload ?? { projects: [], degraded: true }; return normalizedPayload ?? { projects: [], degraded: true };
}) })
.finally(() => { .finally(() => {
if (inFlightLoad === request) { if (inFlightLoad?.promise === request) {
inFlightLoad = null; inFlightLoad = null;
} }
}); });
inFlightLoad = request; inFlightLoad = { key: cacheKey, promise: request };
return request; return request;
} }
export function __resetRecentProjectsClientCacheForTests(): void { export function __resetRecentProjectsClientCacheForTests(): void {
cachedPayload = null; cachedPayload = null;
cachedKey = null;
cachedAt = 0; cachedAt = 0;
inFlightLoad = null; inFlightLoad = null;
} }

View file

@ -1,10 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
__resetRecentProjectsClientCacheForTests, __resetRecentProjectsClientCacheForTests,
getRecentProjectsClientSnapshot, getRecentProjectsClientSnapshot,
loadRecentProjectsWithClientCache, loadRecentProjectsWithClientCache,
} from '@features/recent-projects/renderer/utils/recentProjectsClientCache'; } from '@features/recent-projects/renderer/utils/recentProjectsClientCache';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { import type {
DashboardRecentProject, DashboardRecentProject,
@ -33,6 +32,19 @@ const payload = (
degraded: false, degraded: false,
...overrides, ...overrides,
}); });
const LOCAL_CACHE_KEY = 'local';
const SSH_CACHE_KEY = 'ssh-dev';
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 };
}
describe('recentProjectsClientCache', () => { describe('recentProjectsClientCache', () => {
afterEach(() => { afterEach(() => {
@ -44,11 +56,15 @@ describe('recentProjectsClientCache', () => {
it('returns cached projects while the client cache is fresh', async () => { it('returns cached projects while the client cache is fresh', async () => {
const loader = vi.fn().mockResolvedValue(payload('alpha')); const loader = vi.fn().mockResolvedValue(payload('alpha'));
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha')); await expect(loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader)).resolves.toEqual(
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha')); payload('alpha')
);
await expect(loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader)).resolves.toEqual(
payload('alpha')
);
expect(loader).toHaveBeenCalledTimes(1); expect(loader).toHaveBeenCalledTimes(1);
expect(getRecentProjectsClientSnapshot()?.payload).toEqual(payload('alpha')); expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)?.payload).toEqual(payload('alpha'));
}); });
it('revalidates stale cache without dropping the previous snapshot', async () => { it('revalidates stale cache without dropping the previous snapshot', async () => {
@ -60,20 +76,20 @@ describe('recentProjectsClientCache', () => {
.mockResolvedValueOnce(payload('alpha')) .mockResolvedValueOnce(payload('alpha'))
.mockResolvedValueOnce(payload('beta')); .mockResolvedValueOnce(payload('beta'));
await loadRecentProjectsWithClientCache(loader); await loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader);
vi.setSystemTime(new Date('2026-04-14T12:00:16.000Z')); vi.setSystemTime(new Date('2026-04-14T12:00:16.000Z'));
expect(getRecentProjectsClientSnapshot()).toMatchObject({ expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toMatchObject({
payload: payload('alpha'), payload: payload('alpha'),
isStale: true, isStale: true,
}); });
await expect(loadRecentProjectsWithClientCache(loader, { force: true })).resolves.toEqual( await expect(
payload('beta') loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader, { force: true })
); ).resolves.toEqual(payload('beta'));
expect(loader).toHaveBeenCalledTimes(2); expect(loader).toHaveBeenCalledTimes(2);
expect(getRecentProjectsClientSnapshot()).toMatchObject({ expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toMatchObject({
payload: payload('beta'), payload: payload('beta'),
isStale: false, isStale: false,
}); });
@ -92,8 +108,8 @@ describe('recentProjectsClientCache', () => {
}) })
); );
const first = loadRecentProjectsWithClientCache(loader, { force: true }); const first = loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader, { force: true });
const second = loadRecentProjectsWithClientCache(loader, { force: true }); const second = loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader, { force: true });
expect(loader).toHaveBeenCalledTimes(1); expect(loader).toHaveBeenCalledTimes(1);
@ -111,24 +127,24 @@ describe('recentProjectsClientCache', () => {
.fn<() => Promise<DashboardRecentProjectsPayload>>() .fn<() => Promise<DashboardRecentProjectsPayload>>()
.mockResolvedValueOnce(payload('alpha', { degraded: true })); .mockResolvedValueOnce(payload('alpha', { degraded: true }));
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual( await expect(loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader)).resolves.toEqual(
payload('alpha', { degraded: true }) payload('alpha', { degraded: true })
); );
vi.setSystemTime(new Date('2026-04-14T12:00:01.000Z')); vi.setSystemTime(new Date('2026-04-14T12:00:01.000Z'));
expect(getRecentProjectsClientSnapshot()).toMatchObject({ expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toMatchObject({
payload: payload('alpha', { degraded: true }), payload: payload('alpha', { degraded: true }),
isStale: false, isStale: false,
}); });
vi.setSystemTime(new Date('2026-04-14T12:00:20.000Z')); vi.setSystemTime(new Date('2026-04-14T12:00:20.000Z'));
expect(getRecentProjectsClientSnapshot()).toMatchObject({ expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toMatchObject({
payload: payload('alpha', { degraded: true }), payload: payload('alpha', { degraded: true }),
isStale: false, isStale: false,
}); });
vi.setSystemTime(new Date('2026-04-14T12:00:31.000Z')); vi.setSystemTime(new Date('2026-04-14T12:00:31.000Z'));
expect(getRecentProjectsClientSnapshot()).toMatchObject({ expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toMatchObject({
payload: payload('alpha', { degraded: true }), payload: payload('alpha', { degraded: true }),
isStale: true, isStale: true,
}); });
@ -139,7 +155,52 @@ describe('recentProjectsClientCache', () => {
.fn<() => Promise<DashboardRecentProject[]>>() .fn<() => Promise<DashboardRecentProject[]>>()
.mockResolvedValue([project('alpha')]); .mockResolvedValue([project('alpha')]);
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha')); await expect(loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader)).resolves.toEqual(
expect(getRecentProjectsClientSnapshot()?.payload).toEqual(payload('alpha')); payload('alpha')
);
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)?.payload).toEqual(payload('alpha'));
});
it('does not serve a cached payload across active context keys', async () => {
const loader = vi
.fn<() => Promise<DashboardRecentProjectsPayload>>()
.mockResolvedValueOnce(payload('local-alpha'))
.mockResolvedValueOnce(payload('ssh-beta'));
await expect(loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader)).resolves.toEqual(
payload('local-alpha')
);
expect(getRecentProjectsClientSnapshot(SSH_CACHE_KEY)).toBeNull();
await expect(loadRecentProjectsWithClientCache(SSH_CACHE_KEY, loader)).resolves.toEqual(
payload('ssh-beta')
);
expect(loader).toHaveBeenCalledTimes(2);
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toBeNull();
expect(getRecentProjectsClientSnapshot(SSH_CACHE_KEY)?.payload).toEqual(payload('ssh-beta'));
});
it('does not reuse or cache an in-flight payload for a different context key', async () => {
const localRequest = deferred<DashboardRecentProjectsPayload>();
const sshRequest = deferred<DashboardRecentProjectsPayload>();
const loader = vi
.fn<() => Promise<DashboardRecentProjectsPayload>>()
.mockReturnValueOnce(localRequest.promise)
.mockReturnValueOnce(sshRequest.promise);
const localLoad = loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader, { force: true });
const sshLoad = loadRecentProjectsWithClientCache(SSH_CACHE_KEY, loader, { force: true });
expect(loader).toHaveBeenCalledTimes(2);
sshRequest.resolve(payload('ssh-beta'));
await expect(sshLoad).resolves.toEqual(payload('ssh-beta'));
expect(getRecentProjectsClientSnapshot(SSH_CACHE_KEY)?.payload).toEqual(payload('ssh-beta'));
localRequest.resolve(payload('local-alpha'));
await expect(localLoad).resolves.toEqual(payload('local-alpha'));
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toBeNull();
expect(getRecentProjectsClientSnapshot(SSH_CACHE_KEY)?.payload).toEqual(payload('ssh-beta'));
}); });
}); });