mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
fix(pwa): restore the dashboard snapshot only for its own account
On a cold start the IndexedDB snapshot was restored before /api/auth/me had answered, and the owner check was skipped while the account was unknown. On a shared browser the previous account's dashboard could be put into the next account's cache until the refetch landed. The restore now waits until the signed-in account is known and runs again when it changes. A snapshot whose owner is not that account, or that was written before any account was known, is discarded and wiped. Because the restore now lands after the page has mounted, it fills queries that have no data yet instead of skipping every query the cache already knows; data the page holds is still never overwritten.
This commit is contained in:
1 parent
0df65ce396
commit
433b92a67f
5 files changed
+152
-44
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>();
|
||||
|
||||
function installRoundTripIdb(): void {
|
||||
roundTripStore.clear();
|
||||
const makeReq = (result?: unknown) => {
|
||||
const req: Record<string, unknown> = { 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<string, unknown> = { 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<void> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<PersistedPayload>();
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user