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", }, };