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 {
listDashboardRecentProjects: async () => {
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 };
},
};

View file

@ -21,7 +21,6 @@ import {
import { useOpenRecentProject } from './useOpenRecentProject';
import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter';
import type { TeamSummary } from '@shared/types';
const INITIAL_RECENT_PROJECTS = 11;
const LOAD_MORE_STEP = 8;
@ -70,6 +69,7 @@ export function useRecentProjectsSection(
globalTasksLoading,
fetchAllTasks,
teams,
activeContextId,
provisioningRuns,
currentProvisioningRunIdByTeam,
provisioningSnapshotByTeam,
@ -80,12 +80,16 @@ export function useRecentProjectsSection(
globalTasksLoading: state.globalTasksLoading,
fetchAllTasks: state.fetchAllTasks,
teams: state.teams,
activeContextId: state.activeContextId,
provisioningRuns: state.provisioningRuns,
currentProvisioningRunIdByTeam: state.currentProvisioningRunIdByTeam,
provisioningSnapshotByTeam: state.provisioningSnapshotByTeam,
}))
);
const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []);
const initialSnapshot = useMemo(
() => getRecentProjectsClientSnapshot(activeContextId),
[activeContextId]
);
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
initialSnapshot?.payload.projects ?? []
@ -105,6 +109,7 @@ export function useRecentProjectsSection(
const recentProjectsRef = useRef<DashboardRecentProject[]>(
initialSnapshot?.payload.projects ?? []
);
const activeContextIdRef = useRef(activeContextId);
const provisioningState = useMemo(
() => ({ currentProvisioningRunIdByTeam, provisioningRuns }),
[currentProvisioningRunIdByTeam, provisioningRuns]
@ -125,37 +130,67 @@ export function useRecentProjectsSection(
recentProjectsRef.current = recentProjects;
}, [recentProjects]);
const reload = useCallback(async (options?: { force?: boolean }): Promise<void> => {
const hasVisibleProjects =
recentProjectsRef.current.length > 0 || getRecentProjectsClientSnapshot() != null;
useEffect(() => {
activeContextIdRef.current = activeContextId;
}, [activeContextId]);
if (!hasVisibleProjects) {
setLoading(true);
}
setError(null);
try {
const payload = await loadRecentProjectsWithClientCache(
() => api.getDashboardRecentProjects(),
options
);
setRecentProjects(payload.projects);
setRecentProjectsDegraded(payload.degraded);
setDegradedRefreshCount((current) => (payload.degraded ? current + 1 : 0));
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects');
} finally {
setLoading(false);
}
}, []);
const reload = useCallback(
async (options?: { force?: boolean }): Promise<void> => {
const requestContextId = activeContextId;
const hasVisibleProjects =
recentProjectsRef.current.length > 0 ||
getRecentProjectsClientSnapshot(requestContextId) != null;
if (!hasVisibleProjects) {
setLoading(true);
}
setError(null);
try {
const payload = await loadRecentProjectsWithClientCache(
requestContextId,
() => api.getDashboardRecentProjects(),
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(() => {
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) {
return;
}
void reload({ force: snapshot != null });
}, [reload]);
}, [activeContextId, reload]);
useEffect(() => {
if (!recentProjectsDegraded) {
@ -225,22 +260,21 @@ export function useRecentProjectsSection(
});
}, [aliveTeams, provisioningSnapshotByTeam, provisioningTeamNames, teams]);
const decoratedCards = useMemo(
() =>
adaptRecentProjectsSection({
projects: sortRecentProjectsByDisplayPriority(recentProjects),
taskCountsByProject,
activeTeamsByProject,
tasksLoading: globalTasksLoading,
}),
[
activeTeamsByProject,
globalTasksLoading,
openHistoryVersion,
recentProjects,
const decoratedCards = useMemo(() => {
void openHistoryVersion;
return adaptRecentProjectsSection({
projects: sortRecentProjectsByDisplayPriority(recentProjects),
taskCountsByProject,
]
);
activeTeamsByProject,
tasksLoading: globalTasksLoading,
});
}, [
activeTeamsByProject,
globalTasksLoading,
openHistoryVersion,
recentProjects,
taskCountsByProject,
]);
const filteredCards = useMemo(
() => 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;
let cachedPayload: DashboardRecentProjectsPayloadLike = null;
let cachedKey: string | null = null;
let cachedAt = 0;
let inFlightLoad: Promise<DashboardRecentProjectsPayload> | null = null;
let inFlightLoad: { key: string; promise: Promise<DashboardRecentProjectsPayload> } | null = null;
export interface RecentProjectsClientSnapshot {
payload: DashboardRecentProjectsPayload;
@ -18,7 +19,13 @@ export interface RecentProjectsClientSnapshot {
isStale: boolean;
}
export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot | null {
export function getRecentProjectsClientSnapshot(
cacheKey: string
): RecentProjectsClientSnapshot | null {
if (cachedKey !== cacheKey) {
return null;
}
const normalizedPayload = normalizeDashboardRecentProjectsPayload(cachedPayload);
if (!normalizedPayload) {
return null;
@ -40,39 +47,44 @@ export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot
}
export async function loadRecentProjectsWithClientCache(
cacheKey: string,
loader: () => Promise<DashboardRecentProjectsPayloadLike>,
options?: { force?: boolean }
): Promise<DashboardRecentProjectsPayload> {
const force = options?.force ?? false;
const snapshot = getRecentProjectsClientSnapshot();
const snapshot = getRecentProjectsClientSnapshot(cacheKey);
if (!force && snapshot && !snapshot.isStale) {
return snapshot.payload;
}
if (inFlightLoad) {
return inFlightLoad;
if (inFlightLoad?.key === cacheKey) {
return inFlightLoad.promise;
}
const request = loader()
.then((payloadLike) => {
const normalizedPayload = normalizeDashboardRecentProjectsPayload(payloadLike);
cachedPayload = normalizedPayload;
cachedAt = Date.now();
if (inFlightLoad?.key === cacheKey && inFlightLoad.promise === request) {
cachedKey = normalizedPayload ? cacheKey : null;
cachedPayload = normalizedPayload;
cachedAt = Date.now();
}
return normalizedPayload ?? { projects: [], degraded: true };
})
.finally(() => {
if (inFlightLoad === request) {
if (inFlightLoad?.promise === request) {
inFlightLoad = null;
}
});
inFlightLoad = request;
inFlightLoad = { key: cacheKey, promise: request };
return request;
}
export function __resetRecentProjectsClientCacheForTests(): void {
cachedPayload = null;
cachedKey = null;
cachedAt = 0;
inFlightLoad = null;
}

View file

@ -1,10 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
__resetRecentProjectsClientCacheForTests,
getRecentProjectsClientSnapshot,
loadRecentProjectsWithClientCache,
} from '@features/recent-projects/renderer/utils/recentProjectsClientCache';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
DashboardRecentProject,
@ -33,6 +32,19 @@ const payload = (
degraded: false,
...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', () => {
afterEach(() => {
@ -44,11 +56,15 @@ describe('recentProjectsClientCache', () => {
it('returns cached projects while the client cache is fresh', async () => {
const loader = vi.fn().mockResolvedValue(payload('alpha'));
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha'));
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha'));
await expect(loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader)).resolves.toEqual(
payload('alpha')
);
await expect(loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader)).resolves.toEqual(
payload('alpha')
);
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 () => {
@ -60,20 +76,20 @@ describe('recentProjectsClientCache', () => {
.mockResolvedValueOnce(payload('alpha'))
.mockResolvedValueOnce(payload('beta'));
await loadRecentProjectsWithClientCache(loader);
await loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader);
vi.setSystemTime(new Date('2026-04-14T12:00:16.000Z'));
expect(getRecentProjectsClientSnapshot()).toMatchObject({
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toMatchObject({
payload: payload('alpha'),
isStale: true,
});
await expect(loadRecentProjectsWithClientCache(loader, { force: true })).resolves.toEqual(
payload('beta')
);
await expect(
loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader, { force: true })
).resolves.toEqual(payload('beta'));
expect(loader).toHaveBeenCalledTimes(2);
expect(getRecentProjectsClientSnapshot()).toMatchObject({
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toMatchObject({
payload: payload('beta'),
isStale: false,
});
@ -92,8 +108,8 @@ describe('recentProjectsClientCache', () => {
})
);
const first = loadRecentProjectsWithClientCache(loader, { force: true });
const second = loadRecentProjectsWithClientCache(loader, { force: true });
const first = loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader, { force: true });
const second = loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader, { force: true });
expect(loader).toHaveBeenCalledTimes(1);
@ -111,24 +127,24 @@ describe('recentProjectsClientCache', () => {
.fn<() => Promise<DashboardRecentProjectsPayload>>()
.mockResolvedValueOnce(payload('alpha', { degraded: true }));
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(
await expect(loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader)).resolves.toEqual(
payload('alpha', { degraded: true })
);
vi.setSystemTime(new Date('2026-04-14T12:00:01.000Z'));
expect(getRecentProjectsClientSnapshot()).toMatchObject({
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toMatchObject({
payload: payload('alpha', { degraded: true }),
isStale: false,
});
vi.setSystemTime(new Date('2026-04-14T12:00:20.000Z'));
expect(getRecentProjectsClientSnapshot()).toMatchObject({
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toMatchObject({
payload: payload('alpha', { degraded: true }),
isStale: false,
});
vi.setSystemTime(new Date('2026-04-14T12:00:31.000Z'));
expect(getRecentProjectsClientSnapshot()).toMatchObject({
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toMatchObject({
payload: payload('alpha', { degraded: true }),
isStale: true,
});
@ -139,7 +155,52 @@ describe('recentProjectsClientCache', () => {
.fn<() => Promise<DashboardRecentProject[]>>()
.mockResolvedValue([project('alpha')]);
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha'));
expect(getRecentProjectsClientSnapshot()?.payload).toEqual(payload('alpha'));
await expect(loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader)).resolves.toEqual(
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'));
});
});