mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
82 lines
3.0 KiB
TypeScript
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",
|
|
},
|
|
};
|