diff --git a/packages/dashboard/app/hooks/__tests__/useProjects.test.ts b/packages/dashboard/app/hooks/__tests__/useProjects.test.ts index 02aab8945..d66beee51 100644 --- a/packages/dashboard/app/hooks/__tests__/useProjects.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useProjects.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { useProjects } from "../useProjects"; import * as api from "../../api"; +import * as swrCache from "../../utils/swrCache"; import type { ProjectInfoWithSource } from "../../api"; vi.mock("../../api", () => ({ @@ -17,6 +18,9 @@ const mockRegisterProject = vi.mocked(api.registerProject); const mockUpdateProject = vi.mocked(api.updateProject); const mockUnregisterProject = vi.mocked(api.unregisterProject); const mockHasNodeMappingsSupport = vi.mocked(api.hasNodeMappingsSupport); +const mockReadCache = vi.spyOn(swrCache, "readCache"); +const mockWriteCache = vi.spyOn(swrCache, "writeCache"); +const mockClearCache = vi.spyOn(swrCache, "clearCache"); function makeProject(overrides: Partial = {}): ProjectInfoWithSource { return { @@ -44,12 +48,63 @@ describe("useProjects", () => { mockUpdateProject.mockReset(); mockUnregisterProject.mockReset(); mockHasNodeMappingsSupport.mockReset(); + mockReadCache.mockReset(); + mockWriteCache.mockReset(); + mockClearCache.mockReset(); + mockReadCache.mockReturnValue(null); }); afterEach(() => { vi.useRealTimers(); }); + it("hydrates from cache immediately while fetch revalidates", async () => { + let resolveFetch: ((projects: ProjectInfoWithSource[]) => void) | undefined; + mockFetchProjectsAcrossNodes.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + mockReadCache.mockReturnValueOnce([makeProject({ id: "cached-project" })]); + mockHasNodeMappingsSupport.mockReturnValue(false); + + const { result } = renderHook(() => useProjects()); + + expect(result.current.loading).toBe(false); + expect(result.current.projects[0]?.id).toBe("cached-project"); + + await act(async () => { + resolveFetch?.([makeProject({ id: "live-project" })]); + await flushPromises(); + }); + + expect(result.current.projects[0]?.id).toBe("live-project"); + }); + + it("cache miss keeps loading flow until fetch resolves", async () => { + let resolveFetch: ((projects: ProjectInfoWithSource[]) => void) | undefined; + mockFetchProjectsAcrossNodes.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + mockHasNodeMappingsSupport.mockReturnValue(false); + + const { result } = renderHook(() => useProjects()); + + expect(result.current.loading).toBe(true); + + await act(async () => { + resolveFetch?.([makeProject({ id: "live-project" })]); + await flushPromises(); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.projects[0]?.id).toBe("live-project"); + }); + it("normalizes mapping-enabled payloads into project.nodeMappings", async () => { mockHasNodeMappingsSupport.mockReturnValue(true); mockFetchProjectsAcrossNodes.mockResolvedValueOnce([ @@ -76,6 +131,10 @@ describe("useProjects", () => { expect(result.current.projects[1].nodeMappings).toEqual([ { nodeId: "node-b", path: "/mnt/b", available: false, nodeName: undefined }, ]); + expect(mockWriteCache).toHaveBeenCalledWith( + swrCache.SWR_CACHE_KEYS.PROJECTS, + expect.any(Array), + ); }); it("synthesizes a legacy fallback mapping from nodeId + path", async () => { @@ -118,5 +177,27 @@ describe("useProjects", () => { expect(result.current.projects[0].id).toBe("proj-2"); expect(result.current.projects[0].nodeMappings?.[0]?.nodeId).toBe("node-b"); + expect(mockWriteCache).toHaveBeenCalledWith( + swrCache.SWR_CACHE_KEYS.PROJECTS, + expect.any(Array), + ); + }); + + it("clears project task cache when unregistering", async () => { + mockHasNodeMappingsSupport.mockReturnValue(false); + mockFetchProjectsAcrossNodes.mockResolvedValueOnce([makeProject({ id: "proj-1" })]); + mockUnregisterProject.mockResolvedValueOnce(undefined); + + const { result } = renderHook(() => useProjects()); + + await act(async () => { + await flushPromises(); + }); + + await act(async () => { + await result.current.unregister("proj-1"); + }); + + expect(mockClearCache).toHaveBeenCalledWith(`${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`); }); }); diff --git a/packages/dashboard/app/hooks/useProjects.ts b/packages/dashboard/app/hooks/useProjects.ts index 63170e75b..cf7ae83af 100644 --- a/packages/dashboard/app/hooks/useProjects.ts +++ b/packages/dashboard/app/hooks/useProjects.ts @@ -10,6 +10,7 @@ import { type ProjectInfoWithSource, type ProjectNodeAvailability, } from "../api"; +import { SWR_CACHE_KEYS, clearCache, readCache, writeCache } from "../utils/swrCache"; export interface UseProjectsResult { /** List of all registered projects (local + remote) */ @@ -75,8 +76,14 @@ function normalizeProjects(projects: ProjectInfoWithSource[]): ProjectInfoWithSo * Provides optimistic updates for UI responsiveness. */ export function useProjects(): UseProjectsResult { - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(true); + const [projects, setProjects] = useState(() => { + const cached = readCache(SWR_CACHE_KEYS.PROJECTS); + if (!Array.isArray(cached)) { + return []; + } + return normalizeProjects(cached); + }); + const [loading, setLoading] = useState(() => projects.length === 0); const [error, setError] = useState(null); const intervalRef = useRef(null); const lastVisibilityRefreshRef = useRef(0); @@ -85,7 +92,9 @@ export function useProjects(): UseProjectsResult { try { setError(null); const data = await fetchProjectsAcrossNodes(); - setProjects(normalizeProjects(data)); + const normalizedData = normalizeProjects(data); + setProjects(normalizedData); + writeCache(SWR_CACHE_KEYS.PROJECTS, normalizedData); } catch (err) { setError(err instanceof Error ? err.message : "Failed to fetch projects"); // Don't clear existing projects on error - keep showing stale data @@ -97,7 +106,10 @@ export function useProjects(): UseProjectsResult { let cancelled = false; async function load() { - setLoading(true); + const hadCachedProjects = projects.length > 0; + if (!hadCachedProjects) { + setLoading(true); + } const t0 = performance.now(); try { const data = await fetchProjectsAcrossNodes(); @@ -107,6 +119,7 @@ export function useProjects(): UseProjectsResult { if (!cancelled) { setProjects(normalizedData); setError(null); + writeCache(SWR_CACHE_KEYS.PROJECTS, normalizedData); } } catch (err) { const elapsed = Math.round(performance.now() - t0); @@ -143,7 +156,7 @@ export function useProjects(): UseProjectsResult { cancelled = true; document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [refresh]); + }, [projects.length, refresh]); // Polling for updates useEffect(() => { @@ -181,7 +194,12 @@ export function useProjects(): UseProjectsResult { const unregister = useCallback(async (id: string): Promise => { await unregisterProject(id); // Optimistically remove from list - setProjects((prev) => prev.filter((p) => p.id !== id)); + setProjects((prev) => { + const nextProjects = prev.filter((p) => p.id !== id); + writeCache(SWR_CACHE_KEYS.PROJECTS, nextProjects); + return nextProjects; + }); + clearCache(`${SWR_CACHE_KEYS.TASKS_PREFIX}${id}`); }, []); return {