diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index e480b868d..c2ab886d7 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2888, - "total": 9205, + "total": 9204, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1277,7 +1277,7 @@ "components/onboarding/welcome-screen.tsx": 1, "components/profile/__tests__/height-entry-surfaces.test.tsx": 1, "components/profile/height-field-control.tsx": 1, - "components/providers.tsx": 3, + "components/providers.tsx": 2, "components/records/about-me-note-manager.tsx": 3, "components/records/ai-profile-inclusion-manager.tsx": 1, "components/records/allergy-form.tsx": 1, diff --git a/src/components/providers.tsx b/src/components/providers.tsx index de8dbec17..09eafb194 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -278,18 +278,18 @@ function QueryVisibilityRefreshBridge() { return null; } -// ── Offline query persistence ──────────────────────── +// ── Dashboard snapshot persistence ─────────────────── // -// v1.18.6 — hydrate the last-synced query cache from IndexedDB before the -// first authenticated paint, then debounce-persist successful reads back. -// Combined with the service worker's allowlisted stale-while-revalidate API -// branch, an installed PWA opened offline renders last-known data instead of -// empty skeletons. Build-version + age gated; cleared on logout. +// Fill the dashboard reads from the IndexedDB snapshot as a warm start, then +// debounce-persist them back. The restore waits for `/api/auth/me`: the +// snapshot belongs to one account, and only a known account can be compared +// with its owner. Build-version + age gated; cleared at every session end. function QueryPersistenceBridge() { const queryClient = useQueryClient(); const transition = useRecordSessionTransition(); + const userId = useAuth().user?.id ?? null; useEffect(() => { - if (transition.phase !== "ready") return; + if (transition.phase !== "ready" || userId === null) return; let stop: (() => void) | undefined; let cancelled = false; void restorePersistedQueryCache( @@ -305,7 +305,7 @@ function QueryPersistenceBridge() { cancelled = true; stop?.(); }; - }, [queryClient, transition.phase]); + }, [queryClient, transition.phase, userId]); return null; } diff --git a/src/lib/pwa/__tests__/query-persister-shared-record.test.ts b/src/lib/pwa/__tests__/query-persister-shared-record.test.ts index 9b7626450..a97e5e05e 100644 --- a/src/lib/pwa/__tests__/query-persister-shared-record.test.ts +++ b/src/lib/pwa/__tests__/query-persister-shared-record.test.ts @@ -133,8 +133,13 @@ afterEach(() => { } }); +/** A client signed in as the caller: the persister binds to that account. */ function appClient(): QueryClient { - return new QueryClient({ defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS }); + const client = new QueryClient({ + defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS, + }); + client.setQueryData(queryKeys.authMe(), { id: "acct-delegate" }); + return client; } describe("persisting while inside another record", () => { @@ -207,7 +212,13 @@ describe("restoring while inside another record", () => { // hash already makes a snapshot written in one record unreadable in // another; asserting only that would be a check that cannot fail for the // control it names. This one counts what landed in the cache, so removing - // the guard leaves exactly one entry here and turns it red. - expect(reader.getQueryCache().getAll()).toHaveLength(0); + // the guard leaves exactly one entry here and turns it red. The only + // entry is the reader's own `/api/auth/me`. + expect( + reader + .getQueryCache() + .getAll() + .map((q) => q.queryKey[0]), + ).toEqual(["auth"]); }); }); diff --git a/src/lib/pwa/__tests__/query-persister.test.ts b/src/lib/pwa/__tests__/query-persister.test.ts index e8451186e..b4da283be 100644 --- a/src/lib/pwa/__tests__/query-persister.test.ts +++ b/src/lib/pwa/__tests__/query-persister.test.ts @@ -193,6 +193,7 @@ describe("persister round-trips through a fake IndexedDB", () => { }; const source = new QueryClient(); + source.setQueryData(["auth", "me"], { id: "user-a" }); source.setQueryData(["dashboard", "snapshot"], { weightKg: 81 }); const stop = startPersistingQueryCache(source, "v1.18.6"); // Force a flush by advancing past the 1s debounce. The trigger write uses @@ -205,6 +206,7 @@ describe("persister round-trips through a fake IndexedDB", () => { stop(); const matchingBuild = new QueryClient(); + matchingBuild.setQueryData(["auth", "me"], { id: "user-a" }); await restorePersistedQueryCache(matchingBuild, "v1.18.6"); expect(matchingBuild.getQueryData(["dashboard", "snapshot"])).toEqual({ weightKg: 81, @@ -220,6 +222,7 @@ describe("persister round-trips through a fake IndexedDB", () => { expect(restored?.isStale()).toBe(true); const foreignBuild = new QueryClient(); + foreignBuild.setQueryData(["auth", "me"], { id: "user-a" }); await restorePersistedQueryCache(foreignBuild, "v9.9.9"); expect( foreignBuild.getQueryData(["dashboard", "snapshot"]), @@ -278,6 +281,7 @@ describe("persister round-trips through a fake IndexedDB", () => { // Persist a STALE (empty) chart series under an allowlisted family. const source = new QueryClient(); + source.setQueryData(["auth", "me"], { id: "user-a" }); source.setQueryData(["chart-data", "WEIGHT"], []); const stop = startPersistingQueryCache(source, "v1.18.6"); vi.useFakeTimers(); @@ -289,6 +293,7 @@ describe("persister round-trips through a fake IndexedDB", () => { // The live client already fetched the FRESH (post-mutation) series. const live = new QueryClient(); + live.setQueryData(["auth", "me"], { id: "user-a" }); live.setQueryData(["chart-data", "WEIGHT"], [{ id: "fresh-row" }]); await restorePersistedQueryCache(live, "v1.18.6"); @@ -297,6 +302,15 @@ describe("persister round-trips through a fake IndexedDB", () => { expect(live.getQueryData(["chart-data", "WEIGHT"])).toEqual([ { id: "fresh-row" }, ]); + + // A query the page has started but not answered has no data: filled. + const mounting = new QueryClient(); + mounting.setQueryData(["auth", "me"], { id: "user-a" }); + mounting + .getQueryCache() + .build(mounting, { queryKey: ["chart-data", "WEIGHT"] }); + await restorePersistedQueryCache(mounting, "v1.18.6"); + expect(mounting.getQueryData(["chart-data", "WEIGHT"])).toEqual([]); }); it("discards a snapshot stamped with a different account id", async () => { @@ -370,8 +384,96 @@ describe("persister round-trips through a fake IndexedDB", () => { // restore can't resurrect it either. expect(store.get("react-query")).toBeUndefined(); }); + + it("restores nothing until the live account is known", async () => { + installRoundTripIdb(); + const source = new QueryClient(); + source.setQueryData(["auth", "me"], { id: "user-a" }); + await persistOnce(source); + + // A cold start: the snapshot is on disk, `/api/auth/me` has not answered. + const booting = new QueryClient(); + await restorePersistedQueryCache(booting, "v1.18.6"); + expect(booting.getQueryData(["dashboard", "snapshot"])).toBeUndefined(); + // Not discarded either: the account may well turn out to be its owner. + expect(roundTripStore.get("react-query")).toBeDefined(); + + booting.setQueryData(["auth", "me"], { id: "user-a" }); + await restorePersistedQueryCache(booting, "v1.18.6"); + expect(booting.getQueryData(["dashboard", "snapshot"])).toEqual({ + weightKg: 81, + }); + }); + + it("discards a snapshot written before any account was known", async () => { + installRoundTripIdb(); + await persistOnce(new QueryClient()); + + const live = new QueryClient(); + live.setQueryData(["auth", "me"], { id: "user-b" }); + await restorePersistedQueryCache(live, "v1.18.6"); + expect(live.getQueryData(["dashboard", "snapshot"])).toBeUndefined(); + expect(roundTripStore.get("react-query")).toBeUndefined(); + }); }); +// ── shared fake IndexedDB for the account cases above ─────────────────────── +const roundTripStore = new Map(); + +function installRoundTripIdb(): void { + roundTripStore.clear(); + const makeReq = (result?: unknown) => { + const req: Record = { result }; + queueMicrotask(() => (req.onsuccess as (() => void) | undefined)?.()); + return req; + }; + const fakeDb = { + close() {}, + transaction() { + return { + objectStore() { + return { + put(value: unknown, key: string) { + roundTripStore.set(key, value); + return makeReq(); + }, + get(key: string) { + return makeReq(roundTripStore.get(key)); + }, + delete(key: string) { + roundTripStore.delete(key); + return makeReq(); + }, + }; + }, + set oncomplete(fn: () => void) { + queueMicrotask(fn); + }, + set onerror(_fn: () => void) {}, + }; + }, + }; + (globalThis as { indexedDB?: unknown }).indexedDB = { + open() { + const req: Record = { result: fakeDb }; + queueMicrotask(() => (req.onsuccess as () => void)?.()); + return req; + }, + }; +} + +/** Write one dashboard snapshot from `source` through the 1 s debounce. */ +async function persistOnce(source: QueryClient): Promise { + source.setQueryData(["dashboard", "snapshot"], { weightKg: 81 }); + const stop = startPersistingQueryCache(source, "v1.18.6"); + vi.useFakeTimers(); + source.setQueryData(["chart-data", "WEIGHT"], [{ v: 81 }]); + vi.advanceTimersByTime(1100); + vi.useRealTimers(); + await new Promise((r) => setTimeout(r, 0)); + stop(); +} + describe("persister is bounded and surfaces quota failures", () => { const original = (globalThis as { indexedDB?: unknown }).indexedDB; diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts index eed2a4b7d..4555e4aa6 100644 --- a/src/lib/pwa/query-persister.ts +++ b/src/lib/pwa/query-persister.ts @@ -1,10 +1,10 @@ /** - * Minimal IndexedDB-backed TanStack Query persistence (v1.18.6). + * Minimal IndexedDB-backed TanStack Query persistence. * * No external persister dependency: a tiny IDB key/value store plus a manual - * dehydrate-on-cache-change / hydrate-on-boot bridge. The goal is offline - * READ value — an installed PWA opened offline hydrates the last cached - * dashboard/series instantly instead of painting empty skeletons forever. + * dehydrate-on-cache-change / hydrate-after-sign-in bridge. It is a warm + * start for the dashboard reads, not an offline mode: signed-in pages are + * never cached, so the app cannot boot without a connection. * * Safety: * - Only successful (`status: "success"`) query results are persisted. @@ -18,9 +18,10 @@ * - `clearPersistedQueryCache()` is called on every session END (logout AND * the 401 / session-expiry redirect), so a shared device never leaks one * account's cached data to the next once the session is gone. - * - The payload is stamped with the current user id; a restore whose stored - * marker doesn't match the live account discards + clears the cache rather - * than hydrating a foreign account's data. + * - The payload is stamped with the current user id. A restore runs only + * once `["auth","me"]` names the live account, and a stored owner that is + * not that account discards + clears the cache rather than hydrating a + * foreign account's data. * - Respects the centralised queryKey factory: it reads existing query keys, * it never invents new ones. */ @@ -166,9 +167,9 @@ function currentUserId(queryClient: QueryClient): string | null { } /** - * Restore persisted query data into the client (called once before the first - * paint of the authenticated shell). A build-version or age mismatch discards - * the snapshot rather than hydrating a stale/foreign-schema cache. + * Restore persisted query data into the client once the signed-in account is + * known. A build-version, age or owner mismatch discards the snapshot rather + * than hydrating a stale, foreign-schema or foreign-account cache. */ export async function restorePersistedQueryCache( queryClient: QueryClient, @@ -182,6 +183,9 @@ export async function restorePersistedQueryCache( // saying "viewing her record" is the mis-context failure this feature is // built to prevent, in the one place a full reload cannot clear. if (isReadingSharedRecord()) return; + // Without a live account there is nothing to compare the owner with. + const liveUserId = currentUserId(queryClient); + if (liveUserId === null) return; try { const payload = await idbGet(); if (!payload) return; @@ -193,32 +197,24 @@ export async function restorePersistedQueryCache( await clearPersistedQueryCache(); return; } - // Per-account binding: if the live account is known and differs from the - // snapshot's stored owner, this is a different user on the same browser - // profile — discard + clear rather than hydrate a foreign account's data. - // When the live id is not yet known (auth/me not resolved at restore time) - // we don't gate on it; the session-end wipe is the primary guarantee and - // the build-version + 24 h age gates still apply. - const liveUserId = currentUserId(queryClient); - if (liveUserId !== null && payload.userId !== liveUserId) { + // Per-account binding: a snapshot written by anyone but the live account + // (or before any account was known) is another user's on the same + // browser profile. Discard and clear it. + if (payload.userId !== liveUserId) { await clearPersistedQueryCache(); return; } - // Never clobber a query the live client already holds. `hydrate` calls - // `setData` per dehydrated query unconditionally, so a snapshot restored - // in an effect AFTER the first paint would overwrite a query the page - // already fetched/prefetched — replacing the fresh (post-mutation) list - // with the stale persisted (often empty) copy. That was the read-after- - // write break across dashboard/measurements/medications: the restore won - // the race against the in-flight fetch. Drop every dehydrated query whose - // key the cache already carries so restore only ever FILLS gaps, never - // overwrites; a query already present is by definition fresher than disk. + // Only fill gaps, never overwrite data the live client holds: the + // restore lands after the shell has mounted the page, so a query may + // already carry a fresh (post-mutation) answer, and that is fresher than + // disk. A query the page has started but not answered yet has no data + // and is filled; its own fetch replaces the copy when it lands. const cache = queryClient.getQueryCache(); const filtered: DehydratedState = { ...payload.state, queries: payload.state.queries.filter( - (q) => cache.get(q.queryHash) === undefined, + (q) => cache.get(q.queryHash)?.state.data === undefined, ), }; if (filtered.queries.length === 0 || !canHydrate()) return; @@ -230,9 +226,8 @@ export async function restorePersistedQueryCache( // after a create/update then serves the stale pre-mutation copy. Marking // every restored query invalidated makes it stale-but-shown: the persisted // data still paints instantly, and the first mount triggers a background - // refetch. Online that lands fresh data within ms; offline the refetch - // fails and the hydrated copy stays on screen — the offline value holds. - // Scoped to the keys we just hydrated so we never disturb a live query. + // refetch that lands fresh data within ms. Scoped to the keys just + // hydrated so a live query is never disturbed. for (const dq of filtered.queries) { cache.get(dq.queryHash)?.invalidate(); }