diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts index 8b4af403..60fddcd5 100644 --- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -7,6 +7,10 @@ import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNor import { useShallow } from 'zustand/react/shallow'; import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter'; +import { + getRecentProjectsClientSnapshot, + loadRecentProjectsWithClientCache, +} from '../utils/recentProjectsClientCache'; import { useOpenRecentProject } from './useOpenRecentProject'; @@ -51,27 +55,45 @@ export function useRecentProjectsSection( openProjectPath: (projectPath: string) => Promise; selectProjectFolder: () => Promise; } { - const { globalTasks, globalTasksLoading, fetchAllTasks, teams } = useStore( - useShallow((state) => ({ - globalTasks: state.globalTasks, - globalTasksLoading: state.globalTasksLoading, - fetchAllTasks: state.fetchAllTasks, - teams: state.teams, - })) - ); + const { globalTasks, globalTasksInitialized, globalTasksLoading, fetchAllTasks, teams } = + useStore( + useShallow((state) => ({ + globalTasks: state.globalTasks, + globalTasksInitialized: state.globalTasksInitialized, + globalTasksLoading: state.globalTasksLoading, + fetchAllTasks: state.fetchAllTasks, + teams: state.teams, + })) + ); + const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []); const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject(); - const [recentProjects, setRecentProjects] = useState([]); - const [loading, setLoading] = useState(true); + const [recentProjects, setRecentProjects] = useState( + initialSnapshot?.projects ?? [] + ); + const [loading, setLoading] = useState(initialSnapshot == null); const [error, setError] = useState(null); const [visibleProjects, setVisibleProjects] = useState(maxProjects); const [aliveTeams, setAliveTeams] = useState([]); - const hasFetchedTasksRef = useRef(false); + const hasFetchedTasksRef = useRef(globalTasksInitialized); + const recentProjectsRef = useRef(initialSnapshot?.projects ?? []); - const reload = useCallback(async (): Promise => { - setLoading(true); + useEffect(() => { + recentProjectsRef.current = recentProjects; + }, [recentProjects]); + + const reload = useCallback(async (options?: { force?: boolean }): Promise => { + const hasVisibleProjects = + recentProjectsRef.current.length > 0 || getRecentProjectsClientSnapshot() != null; + + if (!hasVisibleProjects) { + setLoading(true); + } setError(null); try { - const projects = await api.getDashboardRecentProjects(); + const projects = await loadRecentProjectsWithClientCache( + () => api.getDashboardRecentProjects(), + options + ); setRecentProjects(projects); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects'); @@ -81,17 +103,23 @@ export function useRecentProjectsSection( }, []); useEffect(() => { - void reload(); + const snapshot = getRecentProjectsClientSnapshot(); + if (snapshot && !snapshot.isStale) { + return; + } + + void reload({ force: snapshot != null }); }, [reload]); useEffect(() => { - if (recentProjects.length === 0 || hasFetchedTasksRef.current) { + if (recentProjects.length === 0 || hasFetchedTasksRef.current || globalTasksInitialized) { + hasFetchedTasksRef.current = hasFetchedTasksRef.current || globalTasksInitialized; return; } hasFetchedTasksRef.current = true; void fetchAllTasks(); - }, [fetchAllTasks, recentProjects.length]); + }, [fetchAllTasks, globalTasksInitialized, recentProjects.length]); useEffect(() => { let cancelled = false; diff --git a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts new file mode 100644 index 00000000..e28d89b2 --- /dev/null +++ b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts @@ -0,0 +1,62 @@ +import type { DashboardRecentProject } from '@features/recent-projects/contracts'; + +const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000; + +let cachedProjects: DashboardRecentProject[] | null = null; +let cachedAt = 0; +let inFlightLoad: Promise | null = null; + +export interface RecentProjectsClientSnapshot { + projects: DashboardRecentProject[]; + fetchedAt: number; + isStale: boolean; +} + +export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot | null { + if (!cachedProjects) { + return null; + } + + return { + projects: cachedProjects, + fetchedAt: cachedAt, + isStale: Date.now() - cachedAt > RECENT_PROJECTS_CLIENT_CACHE_TTL_MS, + }; +} + +export async function loadRecentProjectsWithClientCache( + loader: () => Promise, + options?: { force?: boolean } +): Promise { + const force = options?.force ?? false; + const snapshot = getRecentProjectsClientSnapshot(); + + if (!force && snapshot && !snapshot.isStale) { + return snapshot.projects; + } + + if (inFlightLoad) { + return inFlightLoad; + } + + const request = loader() + .then((projects) => { + cachedProjects = projects; + cachedAt = Date.now(); + return projects; + }) + .finally(() => { + if (inFlightLoad === request) { + inFlightLoad = null; + } + }); + + inFlightLoad = request; + return request; +} + +export function __resetRecentProjectsClientCacheForTests(): void { + cachedProjects = 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 new file mode 100644 index 00000000..99bbfaca --- /dev/null +++ b/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + __resetRecentProjectsClientCacheForTests, + getRecentProjectsClientSnapshot, + loadRecentProjectsWithClientCache, +} from '@features/recent-projects/renderer/utils/recentProjectsClientCache'; + +import type { DashboardRecentProject } from '@features/recent-projects/contracts'; + +const project = (id: string): DashboardRecentProject => ({ + id, + name: id, + primaryPath: `/tmp/${id}`, + associatedPaths: [`/tmp/${id}`], + primaryBranch: null, + providerIds: ['anthropic'], + updatedAt: '2026-04-14T12:00:00.000Z', +}); + +describe('recentProjectsClientCache', () => { + afterEach(() => { + __resetRecentProjectsClientCacheForTests(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('returns cached projects while the client cache is fresh', async () => { + const loader = vi.fn().mockResolvedValue([project('alpha')]); + + await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual([project('alpha')]); + await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual([project('alpha')]); + + expect(loader).toHaveBeenCalledTimes(1); + expect(getRecentProjectsClientSnapshot()?.projects).toEqual([project('alpha')]); + }); + + it('revalidates stale cache without dropping the previous snapshot', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z')); + + const loader = vi + .fn<() => Promise>() + .mockResolvedValueOnce([project('alpha')]) + .mockResolvedValueOnce([project('beta')]); + + await loadRecentProjectsWithClientCache(loader); + vi.setSystemTime(new Date('2026-04-14T12:00:16.000Z')); + + expect(getRecentProjectsClientSnapshot()).toMatchObject({ + projects: [project('alpha')], + isStale: true, + }); + + await expect(loadRecentProjectsWithClientCache(loader, { force: true })).resolves.toEqual([ + project('beta'), + ]); + + expect(loader).toHaveBeenCalledTimes(2); + expect(getRecentProjectsClientSnapshot()).toMatchObject({ + projects: [project('beta')], + isStale: false, + }); + }); + + it('deduplicates concurrent client refreshes', async () => { + let resolveLoader: ((projects: DashboardRecentProject[]) => void) | null = null; + const loader = vi.fn( + () => + new Promise((resolve) => { + resolveLoader = resolve; + }) + ); + + const first = loadRecentProjectsWithClientCache(loader, { force: true }); + const second = loadRecentProjectsWithClientCache(loader, { force: true }); + + expect(loader).toHaveBeenCalledTimes(1); + + resolveLoader?.([project('alpha')]); + + await expect(first).resolves.toEqual([project('alpha')]); + await expect(second).resolves.toEqual([project('alpha')]); + }); +});