From b46b53d667d046224866ef250f0ec277f71b0126 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 26 May 2026 16:35:28 +0300 Subject: [PATCH] fix(recent-projects): scope client cache by context --- .../createRecentProjectsFeature.ts | 4 +- .../hooks/useRecentProjectsSection.ts | 114 ++++++++++++------ .../utils/recentProjectsClientCache.ts | 30 +++-- .../utils/recentProjectsClientCache.test.ts | 99 ++++++++++++--- 4 files changed, 178 insertions(+), 69 deletions(-) diff --git a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts index c85173d0..d447e8d4 100644 --- a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts +++ b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts @@ -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 }; }, }; diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts index 27f7c5d9..3eddb523 100644 --- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -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( initialSnapshot?.payload.projects ?? [] @@ -105,6 +109,7 @@ export function useRecentProjectsSection( const recentProjectsRef = useRef( 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 => { - 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 => { + 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)), diff --git a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts index e6c18df8..a01f40ae 100644 --- a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts +++ b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts @@ -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 | null = null; +let inFlightLoad: { key: string; promise: Promise } | 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, options?: { force?: boolean } ): Promise { 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; } diff --git a/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts b/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts index bf14e3b1..c2ab0268 100644 --- a/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts +++ b/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts @@ -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(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((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>() .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>() .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>() + .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(); + const sshRequest = deferred(); + const loader = vi + .fn<() => Promise>() + .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')); }); });