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