mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(pwa): fail offline mutations fast instead of pausing + silent write loss
Mutations ran in TanStack's default 'online' network mode, which PAUSES a write fired offline: isPending stays true forever, onError never fires, and the paused mutation is dropped on reload — a health entry vanishes with no 'not saved' signal. Switch mutations to 'always' network mode so an offline write rejects immediately and the form surfaces an honest failure, and add a global OfflineMutationToaster backstop for call sites without their own onError. Correct the offline banner copy, which promised changes would auto-save.
This commit is contained in:
1 parent
a53dd2b59c
commit
081bb2289d
9 files changed
+144
-18
No files matched your search
@@ -14,8 +14,9 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { VersionPoller } from "@/components/version-poller";
|
||||
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
restorePersistedQueryCache,
|
||||
startPersistingQueryCache,
|
||||
} from "@/lib/pwa/query-persister";
|
||||
import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options";
|
||||
|
||||
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
|
||||
|
||||
@@ -177,6 +179,40 @@ function QueryPersistenceBridge() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Offline mutation backstop ────────────────────────
|
||||
|
||||
/**
|
||||
* F-OFF-1 backstop. With mutations in `always` network mode an offline write
|
||||
* rejects immediately (rather than pausing forever), so the individual form's
|
||||
* `onError` fires and shows its own message. This subscriber is the safety net
|
||||
* for any mutation WITHOUT a call-site error handler: it surfaces one honest,
|
||||
* de-duplicated toast whenever a mutation errors while the browser is offline,
|
||||
* so a write can never vanish silently. Gated on `navigator.onLine === false`
|
||||
* so a normal server-side error (which the form already surfaces) is never
|
||||
* double-toasted.
|
||||
*/
|
||||
function OfflineMutationToaster() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
useEffect(() => {
|
||||
const cache = queryClient.getMutationCache();
|
||||
const unsubscribe = cache.subscribe((event) => {
|
||||
if (
|
||||
event.type === "updated" &&
|
||||
event.action.type === "error" &&
|
||||
typeof navigator !== "undefined" &&
|
||||
navigator.onLine === false
|
||||
) {
|
||||
toast.error(t("offlineBanner.saveFailed"), {
|
||||
id: "offline-mutation-failed",
|
||||
});
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [queryClient, t]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Root Providers ───────────────────────────────────
|
||||
|
||||
export function Providers({
|
||||
@@ -187,17 +223,7 @@ export function Providers({
|
||||
initialLocale?: Locale;
|
||||
}) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
() => new QueryClient({ defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS }),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -206,6 +232,7 @@ export function Providers({
|
||||
<I18nProvider initialLocale={initialLocale}>
|
||||
<QueryPersistenceBridge />
|
||||
<DashboardSnapshotPreloader />
|
||||
<OfflineMutationToaster />
|
||||
{children}
|
||||
<Toaster position="bottom-right" richColors />
|
||||
<VersionPoller />
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* F-OFF-1 — offline mutations must FAIL fast (surfacing state) rather than
|
||||
* pause forever and lose the write on reload.
|
||||
*
|
||||
* The library default network mode (`online`) pauses a mutation fired offline:
|
||||
* it never rejects, `onError` never fires, and the paused mutation is dropped on
|
||||
* reload — a silent health-data loss. This pins that the app config sets
|
||||
* `networkMode: "always"` on mutations AND that, under that config, an offline
|
||||
* mutation actually reaches the `error` state instead of `pending`/paused.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
QueryClient,
|
||||
MutationObserver,
|
||||
onlineManager,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { QUERY_CLIENT_DEFAULT_OPTIONS } from "../query-client-options";
|
||||
|
||||
afterEach(() => {
|
||||
// Restore the online flag for any following test in the same worker.
|
||||
onlineManager.setOnline(true);
|
||||
});
|
||||
|
||||
describe("QUERY_CLIENT_DEFAULT_OPTIONS", () => {
|
||||
it("runs mutations in 'always' network mode", () => {
|
||||
expect(QUERY_CLIENT_DEFAULT_OPTIONS.mutations?.networkMode).toBe("always");
|
||||
});
|
||||
|
||||
it("an offline mutation errors instead of pausing (no silent write loss)", async () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS,
|
||||
});
|
||||
|
||||
// Simulate the browser going offline.
|
||||
onlineManager.setOnline(false);
|
||||
|
||||
const observer = new MutationObserver(client, {
|
||||
mutationFn: async () => {
|
||||
// A real fetch would reject with a TypeError while offline.
|
||||
throw new Error("Failed to fetch");
|
||||
},
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
let settled: "error" | "paused-timeout" = "paused-timeout";
|
||||
await Promise.race([
|
||||
observer
|
||||
.mutate()
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
settled = "error";
|
||||
}),
|
||||
// If the mutation paused (the old default), it would never settle — cap
|
||||
// the wait so the test fails as a timeout rather than hanging.
|
||||
new Promise((resolve) => setTimeout(resolve, 200)),
|
||||
]);
|
||||
|
||||
// In `always` mode the mutation ran and rejected — an honest error the UI
|
||||
// can surface, not a paused mutation lost on reload.
|
||||
expect(settled).toBe("error");
|
||||
expect(observer.getCurrentResult().status).toBe("error");
|
||||
expect(observer.getCurrentResult().isPaused).toBe(false);
|
||||
|
||||
client.clear();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { DefaultOptions } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const QUERY_CLIENT_DEFAULT_OPTIONS: DefaultOptions = {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
mutations: {
|
||||
networkMode: "always",
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user