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:
Marc-André Bombeck committed 2026-07-08 23:51:08 +02:00
1 parent a53dd2b59c
commit 081bb2289d
9 files changed
+144 -18

No files matched your search

+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+39 -12
View File
@@ -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();
});
});
+26
View File
@@ -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",
},
};