From 6cca9b83aaa637ef169f49a667dcdca4ab2122f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 16:15:24 +0200 Subject: [PATCH] fix(dashboard): hand the preloaded snapshot over as a promise, not a cache write Prefetching the snapshot into the query cache before hydration made the server-rendered skeleton disagree with the first client render. The early kickoff stays, but the response now commits through the mounted query itself, so the speedup keeps none of the mismatch. The dead nudge-resolver leftover goes with it. --- .../__tests__/use-dashboard-snapshot.test.tsx | 88 ++++++++++++++++++- src/lib/queries/use-dashboard-snapshot.ts | 83 +++++++++++++++-- src/lib/validations/notification-prefs.ts | 10 --- 3 files changed, 161 insertions(+), 20 deletions(-) diff --git a/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx b/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx index 01ee7955b..40b943879 100644 --- a/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx +++ b/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx @@ -27,13 +27,19 @@ vi.mock("@tanstack/react-query", () => ({ }), })); -import { useDashboardSnapshot } from "../use-dashboard-snapshot"; +import { + useDashboardSnapshot, + prefetchDashboardSnapshot, + _resetDashboardSnapshotPreloadForTests, +} from "../use-dashboard-snapshot"; import { queryKeys } from "@/lib/query-keys"; +import type { QueryClient } from "@tanstack/react-query"; afterEach(() => { useQueryMock.mockClear(); getQueryDataMock.mockReset(); setQueryDataMock.mockClear(); + _resetDashboardSnapshotPreloadForTests(); vi.unstubAllGlobals(); }); @@ -98,3 +104,83 @@ describe("useDashboardSnapshot — auto-refresh on an open page", () => { expect(setQueryDataMock).not.toHaveBeenCalled(); }); }); + +describe("prefetchDashboardSnapshot — hydration-safe promise handoff", () => { + function fakeQueryClient( + state?: { data: unknown; dataUpdatedAt: number } | undefined, + ): QueryClient { + return { + getQueryState: vi.fn().mockReturnValue(state), + getQueryData: getQueryDataMock, + setQueryData: setQueryDataMock, + } as unknown as QueryClient; + } + + function stubFetch(payload: unknown = { layout: { widgets: [] }, tiles: {} }) { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: payload }), + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + it("never writes the query cache itself — the response parks until the mounted queryFn consumes it", async () => { + const fetchMock = stubFetch(); + getQueryDataMock.mockReturnValue({ widgets: [{ id: "existing" }] }); + prefetchDashboardSnapshot(fakeQueryClient()); + expect(fetchMock).toHaveBeenCalledTimes(1); + // The prefetch must not commit data: committing pre-hydration is the + // React #418 mismatch the handoff exists to prevent. + expect(setQueryDataMock).not.toHaveBeenCalled(); + + useDashboardSnapshot(); + const opts = lastOptsOf(useQueryMock); + await (opts.queryFn as () => Promise)(); + // The mounted cell consumed the in-flight request — no second fetch. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("is a no-op while a handoff is already parked", () => { + const fetchMock = stubFetch(); + prefetchDashboardSnapshot(fakeQueryClient()); + prefetchDashboardSnapshot(fakeQueryClient()); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("skips the fetch when the snapshot cache is still fresh", () => { + const fetchMock = stubFetch(); + prefetchDashboardSnapshot( + fakeQueryClient({ data: { tiles: {} }, dataUpdatedAt: Date.now() }), + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("falls back to a fresh fetch when the preload failed — the mounted cell owns error surfacing", async () => { + const layout = { widgets: [] }; + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error("HTTP 401")) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { layout, tiles: {} } }), + }); + vi.stubGlobal("fetch", fetchMock); + getQueryDataMock.mockReturnValue({ widgets: [{ id: "existing" }] }); + + prefetchDashboardSnapshot(fakeQueryClient()); + useDashboardSnapshot(); + const opts = lastOptsOf(useQueryMock); + const snap = await (opts.queryFn as () => Promise<{ layout: unknown }>)(); + expect(snap.layout).toEqual(layout); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +function lastOptsOf( + mock: typeof useQueryMock, +): Record { + const call = mock.mock.calls.at(-1); + expect(call).toBeDefined(); + return call![0]; +} diff --git a/src/lib/queries/use-dashboard-snapshot.ts b/src/lib/queries/use-dashboard-snapshot.ts index 921f13119..0fad58c30 100644 --- a/src/lib/queries/use-dashboard-snapshot.ts +++ b/src/lib/queries/use-dashboard-snapshot.ts @@ -44,6 +44,28 @@ async function fetchDashboardSnapshot(): Promise { return json.data as DashboardSnapshot; } +/** Handoff freshness window — mirrors the hook's `staleTime`. */ +const PRELOAD_MAX_AGE_MS = 60_000; + +/** + * Module-level promise handoff between the preloader and the mounted + * hook. Deliberately OUTSIDE the query cache: writing the response into + * the cache before the dashboard page hydrates (what `prefetchQuery` + * did) makes the page's first client render paint tiles where the + * server HTML painted the skeleton — a React #418 hydration mismatch + * whenever the response beats the page chunk (fast LAN, e2e route + * mocks). The handoff keeps the request in flight while hydration runs + * and lets the mounted cell commit the data itself, post-hydration. + */ +let preloadedSnapshot: { + promise: Promise; + startedAt: number; +} | null = null; + +export function _resetDashboardSnapshotPreloadForTests(): void { + preloadedSnapshot = null; +} + /** * Kick the snapshot fetch off ahead of the dashboard page chunk. * @@ -53,22 +75,65 @@ async function fetchDashboardSnapshot(): Promise { * navigation commits (4G / 4x-CPU profile). The route-level preloader in * `providers.tsx` and the login submit handler call this the moment the * destination is known, so the request rides in parallel with the chunk - * download instead of behind it. `prefetchQuery` dedupes against the - * later `useQuery` mount (same key → shares the in-flight promise) and - * swallows errors by design — the mounted cell owns error surfacing. + * download instead of behind it. The response parks in the module-level + * handoff (NOT the query cache — see above) until the mounted cell's + * `queryFn` consumes it; preload errors are swallowed by design — the + * mounted cell owns error surfacing with its own fresh attempt. */ export function prefetchDashboardSnapshot(queryClient: QueryClient) { - void queryClient.prefetchQuery({ - queryKey: queryKeys.dashboardSnapshot(), - queryFn: makeSnapshotQueryFn(queryClient), - staleTime: 60_000, - retry: false, + // Expired handoff from a previous visit — drop it so a long-idle slot + // can never serve stale data. + if ( + preloadedSnapshot && + Date.now() - preloadedSnapshot.startedAt >= PRELOAD_MAX_AGE_MS + ) { + preloadedSnapshot = null; + } + // A fresh handoff is already in flight / parked. + if (preloadedSnapshot) return; + // Cache already warm (return-to-dashboard within staleTime) — the + // mounted cell serves it without ever calling `queryFn`; fetching here + // would be a wasted request parked in the slot. + const state = queryClient.getQueryState(queryKeys.dashboardSnapshot()); + if ( + state?.data !== undefined && + Date.now() - state.dataUpdatedAt < PRELOAD_MAX_AGE_MS + ) { + return; + } + const promise = fetchDashboardSnapshot(); + promise.catch(() => { + // Swallow + clear so an unconsumed failed preload (racing 401 on + // login, transient network) never becomes an unhandled rejection and + // never gets handed to a later mount. + if (preloadedSnapshot?.promise === promise) preloadedSnapshot = null; }); + preloadedSnapshot = { promise, startedAt: Date.now() }; +} + +/** Single-use consume of the preload handoff; `null` when absent/expired. */ +function takePreloadedSnapshot(): Promise | null { + if (!preloadedSnapshot) return null; + const { promise, startedAt } = preloadedSnapshot; + preloadedSnapshot = null; + return Date.now() - startedAt < PRELOAD_MAX_AGE_MS ? promise : null; } function makeSnapshotQueryFn(queryClient: QueryClient) { return async () => { - const snap = await fetchDashboardSnapshot(); + const preloaded = takePreloadedSnapshot(); + let snap: DashboardSnapshot; + if (preloaded) { + try { + snap = await preloaded; + } catch { + // Preload failed — this cell still owns a fresh attempt so a + // transient prefetch error never surfaces as the query error. + snap = await fetchDashboardSnapshot(); + } + } else { + snap = await fetchDashboardSnapshot(); + } // Cold-start de-waterfall: the snapshot already carries the // resolved widget layout, and the per-chart overlay-prefs hook // (`use-chart-overlay-prefs.ts`) fetches the SAME payload from diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts index 130b1ee1a..17bb1dc84 100644 --- a/src/lib/validations/notification-prefs.ts +++ b/src/lib/validations/notification-prefs.ts @@ -316,16 +316,6 @@ export function resolveMoodReminderHour(raw: unknown): number { return parseNotificationPrefs(raw).mood.reminderHour; } -/** - * v1.15.20 — cron-side helper. Returns `true` when the proactive Coach - * nudge may fire for this user (the default). Tolerates a null / - * drifted prefs row. The nudge cron layers this on top of the - * disableCoach / kill-switch / provider gates. - */ -export function resolveCoachNudgesEnabled(raw: unknown): boolean { - return parseNotificationPrefs(raw).coach.nudgesEnabled; -} - /** * v1.16.5 — fully-resolved Coach-nudge view for the cron: the master * switch, the three per-group toggles, and the frequency cap mapped to