From 081bb2289dcf5e84ca5b4577eb468c8d3dd485f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:51:08 +0200 Subject: [PATCH] fix(pwa): fail offline mutations fast instead of pausing + silent write loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- src/components/providers.tsx | 51 ++++++++++---- .../__tests__/query-client-options.test.ts | 67 +++++++++++++++++++ src/lib/pwa/query-client-options.ts | 26 +++++++ 9 files changed, 144 insertions(+), 18 deletions(-) create mode 100644 src/lib/pwa/__tests__/query-client-options.test.ts create mode 100644 src/lib/pwa/query-client-options.ts diff --git a/messages/de.json b/messages/de.json index c88ace188..868177a34 100644 --- a/messages/de.json +++ b/messages/de.json @@ -210,7 +210,8 @@ "backToDashboard": "Zurück zum Dashboard" }, "offlineBanner": { - "message": "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist.", + "message": "Keine Verbindung — Änderungen können erst gespeichert werden, wenn du wieder online bist.", + "saveFailed": "Speichern fehlgeschlagen — du bist offline. Bitte versuche es erneut, sobald du wieder online bist.", "showingCached": "Zeigt deine zuletzt synchronisierten Daten." }, "demoBanner": { diff --git a/messages/en.json b/messages/en.json index 9d65503d1..e83170512 100644 --- a/messages/en.json +++ b/messages/en.json @@ -210,7 +210,8 @@ "backToDashboard": "Back to dashboard" }, "offlineBanner": { - "message": "No connection — your changes will save once you're back online.", + "message": "No connection — changes can't be saved until you're back online.", + "saveFailed": "Couldn't save — you're offline. Please try again once you're back online.", "showingCached": "Showing your last synced data." }, "demoBanner": { diff --git a/messages/es.json b/messages/es.json index afdabfc42..209c41dc9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -210,7 +210,8 @@ "backToDashboard": "Volver al panel" }, "offlineBanner": { - "message": "Sin conexión — tus cambios se guardarán cuando vuelvas a estar en línea.", + "message": "Sin conexión — los cambios no se pueden guardar hasta que vuelvas a estar en línea.", + "saveFailed": "No se pudo guardar — estás sin conexión. Inténtalo de nuevo cuando vuelvas a estar en línea.", "showingCached": "Mostrando tus últimos datos sincronizados." }, "demoBanner": { diff --git a/messages/fr.json b/messages/fr.json index 90b18136a..852071ba4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -210,7 +210,8 @@ "backToDashboard": "Retour au tableau de bord" }, "offlineBanner": { - "message": "Pas de connexion — tes modifications seront enregistrées dès que tu seras de nouveau en ligne.", + "message": "Pas de connexion — les modifications ne peuvent pas être enregistrées tant que tu n'es pas de nouveau en ligne.", + "saveFailed": "Enregistrement impossible — tu es hors ligne. Réessaie une fois de nouveau en ligne.", "showingCached": "Affichage de tes dernières données synchronisées." }, "demoBanner": { diff --git a/messages/it.json b/messages/it.json index b4296d3bf..92b11a12e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -210,7 +210,8 @@ "backToDashboard": "Torna alla dashboard" }, "offlineBanner": { - "message": "Nessuna connessione — le tue modifiche verranno salvate appena torni online.", + "message": "Nessuna connessione — le modifiche non possono essere salvate finché non torni online.", + "saveFailed": "Impossibile salvare — sei offline. Riprova quando torni online.", "showingCached": "Vengono mostrati i tuoi ultimi dati sincronizzati." }, "demoBanner": { diff --git a/messages/pl.json b/messages/pl.json index 71df1aca9..c9639079b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -210,7 +210,8 @@ "backToDashboard": "Powrót do panelu" }, "offlineBanner": { - "message": "Brak połączenia — twoje zmiany zostaną zapisane, gdy ponownie pojawisz się online.", + "message": "Brak połączenia — zmian nie można zapisać, dopóki nie wrócisz online.", + "saveFailed": "Nie udało się zapisać — jesteś offline. Spróbuj ponownie, gdy wrócisz online.", "showingCached": "Wyświetlane są ostatnio zsynchronizowane dane." }, "demoBanner": { diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 10ec7fae9..04f1eff50 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -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({ + {children} diff --git a/src/lib/pwa/__tests__/query-client-options.test.ts b/src/lib/pwa/__tests__/query-client-options.test.ts new file mode 100644 index 000000000..d5172db6e --- /dev/null +++ b/src/lib/pwa/__tests__/query-client-options.test.ts @@ -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(); + }); +}); diff --git a/src/lib/pwa/query-client-options.ts b/src/lib/pwa/query-client-options.ts new file mode 100644 index 000000000..579ff9928 --- /dev/null +++ b/src/lib/pwa/query-client-options.ts @@ -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", + }, +};