From 66646bd62f7068892514b77f9af1db879bb5e3d9 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Fri, 10 Apr 2026 13:56:00 +0200 Subject: [PATCH] fix(dashboard): stabilize cached snapshot references Memoize cached dashboard snapshots so chart data stays referentially stable while live data loads. Add a regression test for cache-only parent rerenders to prevent the Recharts update loop when returning from canvas. --- hooks/use-dashboard-snapshot.ts | 11 ++- tests/use-dashboard-snapshot.test.ts | 119 +++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 tests/use-dashboard-snapshot.test.ts diff --git a/hooks/use-dashboard-snapshot.ts b/hooks/use-dashboard-snapshot.ts index 74167da..4163fc6 100644 --- a/hooks/use-dashboard-snapshot.ts +++ b/hooks/use-dashboard-snapshot.ts @@ -20,10 +20,13 @@ export function useDashboardSnapshot(userId?: string | null): { } { const [cacheEpoch, setCacheEpoch] = useState(0); const liveSnapshot = useAuthQuery(api.dashboard.getSnapshot, userId ? {} : "skip"); - const cachedSnapshot = - userId && cacheEpoch >= 0 - ? readDashboardSnapshotCache(userId)?.snapshot ?? null - : null; + const cachedSnapshot = useMemo(() => { + if (!userId || cacheEpoch < 0) { + return null; + } + + return readDashboardSnapshotCache(userId)?.snapshot ?? null; + }, [userId, cacheEpoch]); useEffect(() => { if (!userId || !liveSnapshot) return; diff --git a/tests/use-dashboard-snapshot.test.ts b/tests/use-dashboard-snapshot.test.ts new file mode 100644 index 0000000..5935c9a --- /dev/null +++ b/tests/use-dashboard-snapshot.test.ts @@ -0,0 +1,119 @@ +/* @vitest-environment jsdom */ + +import React, { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const useAuthQueryMock = vi.hoisted(() => vi.fn()); +const readDashboardSnapshotCacheMock = vi.hoisted(() => vi.fn()); +const writeDashboardSnapshotCacheMock = vi.hoisted(() => vi.fn()); +const clearDashboardSnapshotCacheMock = vi.hoisted(() => vi.fn()); +const getDashboardSnapshotCacheInvalidationSignalKeyMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/convex/_generated/api", () => ({ + api: { + dashboard: { getSnapshot: "dashboard.getSnapshot" }, + }, +})); + +vi.mock("@/hooks/use-auth-query", () => ({ + useAuthQuery: useAuthQueryMock, +})); + +vi.mock("@/lib/dashboard-snapshot-cache", () => ({ + readDashboardSnapshotCache: readDashboardSnapshotCacheMock, + writeDashboardSnapshotCache: writeDashboardSnapshotCacheMock, + clearDashboardSnapshotCache: clearDashboardSnapshotCacheMock, + getDashboardSnapshotCacheInvalidationSignalKey: + getDashboardSnapshotCacheInvalidationSignalKeyMock, +})); + +import { useDashboardSnapshot } from "@/hooks/use-dashboard-snapshot"; + +const latestHookValue: { + current: ReturnType | null; +} = { current: null }; + +function createCachedSnapshot() { + return { + balance: { available: 120 }, + subscription: null, + usageStats: null, + recentTransactions: [ + { + _id: "tx_1", + _creationTime: 1, + type: "usage", + status: "committed", + amount: -12, + }, + ], + canvases: [], + mediaPreview: [], + }; +} + +function HookHarness({ userId }: { userId: string | null }) { + const value = useDashboardSnapshot(userId); + + useEffect(() => { + latestHookValue.current = value; + return () => { + latestHookValue.current = null; + }; + }, [value]); + + return null; +} + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("useDashboardSnapshot", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount(); + }); + } + + container?.remove(); + container = null; + root = null; + latestHookValue.current = null; + useAuthQueryMock.mockReset(); + readDashboardSnapshotCacheMock.mockReset(); + writeDashboardSnapshotCacheMock.mockReset(); + clearDashboardSnapshotCacheMock.mockReset(); + getDashboardSnapshotCacheInvalidationSignalKeyMock.mockReset(); + }); + + it("keeps the cached snapshot reference stable across parent rerenders", async () => { + useAuthQueryMock.mockReturnValue(undefined); + getDashboardSnapshotCacheInvalidationSignalKeyMock.mockReturnValue("dashboard:invalidate"); + readDashboardSnapshotCacheMock.mockImplementation(() => ({ + snapshot: createCachedSnapshot(), + })); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(React.createElement(HookHarness, { userId: "user_1" })); + }); + + const firstSnapshot = latestHookValue.current?.snapshot; + + await act(async () => { + root?.render(React.createElement(HookHarness, { userId: "user_1" })); + }); + + const secondSnapshot = latestHookValue.current?.snapshot; + + expect(latestHookValue.current?.source).toBe("cache"); + expect(firstSnapshot).toBe(secondSnapshot); + }); +});