Files
HealthLog/src/lib/pwa/query-client-options.ts
T
Marc-André Bombeck 80175f3bfc feat(sharing): key the query cache by the record it holds
Every cached read in this app is about one health record, and nothing in the
key factory said which one — there was only ever the signed-in account's. Once
a browser can act on somebody else's, `["dashboard","snapshot"]` names two
different answers and the cache cannot tell them apart. That is this repo's
known cache-poison class, on health data, across two people.

One line at the root rather than a dimension threaded through twenty-three key
modules: a `queryKeyHashFn` that folds the record id into the cache identity of
every entry. Key arrays are untouched, which is the part that matters — prefix
invalidation and the import-time dependent-key bundles both match on the array
and keep working. Prefixing the arrays would have broken every bundle silently.

The hash is defence in depth. Switching is a full page reload, so the
in-memory cache is empty on the other side of it; the layers that survive a
reload are the IndexedDB snapshot and the service worker's data cache, and
those are handled by refusing to write either while a switch is on, and by
skipping the restore. Nothing of the owner's record ever reaches the disk of a
delegate's device, so a stale scope marker can cost an empty restore and never
another person's data.

The persister's per-account binding read the account payload's id, which under
a switch is the delegate in both states — it could not tell the two apart. It
now asks the record scope, which can.
2026-08-02 14:26:10 +02:00

82 lines
3.0 KiB
TypeScript

import type { DefaultOptions, QueryClient } from "@tanstack/react-query";
import { recordScopedQueryKeyHashFn } from "@/lib/query-keys/record-scope";
export const MEANINGFUL_HIDDEN_INTERVAL_MS = 60_000;
interface VisibilityTarget {
readonly visibilityState: DocumentVisibilityState;
addEventListener(type: "visibilitychange", listener: EventListener): void;
removeEventListener(type: "visibilitychange", listener: EventListener): void;
}
/**
* Refresh stale server state when an installed PWA resumes after a meaningful
* absence. The hidden timestamp is consumed by the first matching visible
* event, so duplicate browser lifecycle events cannot trigger a refresh burst.
*/
export function subscribeToMeaningfulVisibilityRefresh(
queryClient: QueryClient,
visibilityTarget: VisibilityTarget = document,
now: () => number = Date.now,
): () => void {
let hiddenAt: number | null = null;
const onVisibilityChange = () => {
if (visibilityTarget.visibilityState === "hidden") {
hiddenAt ??= now();
return;
}
if (visibilityTarget.visibilityState !== "visible" || hiddenAt === null) {
return;
}
const hiddenFor = now() - hiddenAt;
hiddenAt = null;
if (hiddenFor < MEANINGFUL_HIDDEN_INTERVAL_MS) return;
void queryClient.refetchQueries({ type: "active", stale: true });
};
visibilityTarget.addEventListener("visibilitychange", onVisibilityChange);
return () =>
visibilityTarget.removeEventListener(
"visibilitychange",
onVisibilityChange,
);
}
/**
* Shared TanStack Query default options for the app's single `QueryClient`.
* Extracted from `providers.tsx` so the offline-mutation contract (F-OFF-1) is
* unit-testable without importing the whole client-component tree.
*
* Mutations run in `always` network mode. The library default (`online`) PAUSES
* a mutation fired offline — `isPending` stays true forever, `onError` never
* fires, and the paused mutation is silently DROPPED on reload, so a health
* entry vanishes with no "not saved" signal. In `always` mode the mutation runs
* regardless, the offline `fetch` rejects immediately, and `onError` fires — an
* honest failure the form surfaces (and the global `OfflineMutationToaster`
* backstops), instead of a spinner-of-death plus silent write loss.
*
* v1.36.0 — `queryKeyHashFn` folds the record being read into the cache
* identity of every entry in the app. Without it `["dashboard","snapshot"]`
* names two different people's data and the cache cannot tell them apart. See
* `src/lib/query-keys/record-scope.ts` for why this sits on the hash rather
* than on the key arrays (the arrays feed prefix invalidation and the
* import-time dependent-key bundles, both of which would break).
*/
export const QUERY_CLIENT_DEFAULT_OPTIONS: DefaultOptions = {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
queryKeyHashFn: recordScopedQueryKeyHashFn,
},
mutations: {
networkMode: "always",
},
};