fix(pwa): keep the open page when a link is tapped offline

Without a connection the strip said "Showing your last synced data",
but one tap on a tab or link traded the readable page for the worker's
bare offline page: signed-in pages are no-store, so the document
navigation Next falls back to has nothing to load.

While the browser reports offline, a tap on a link to another page of
the app now stays on the open page and repeats the hint in a toast. The
strip says what is true: this page stays readable, other pages open once
the connection is back. The README stops promising offline support.

A cold start or a typed URL without a connection still gets the offline
page; the e2e spec keeps that and adds the tap.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:33:57 +01:00
1 parent 1d7169b266
commit 0df65ce396
17 files changed
+224 -81

No files matched your search

@@ -1,5 +1,5 @@
/**
* v1.4.43 — `<OfflineBanner>` SSR contract.
* `<OfflineBanner>` SSR contract and copy.
*
* The banner reads `navigator.onLine` + subscribes to `online`/`offline`
* window events inside a `useEffect`. SSR markup is therefore always
@@ -13,7 +13,6 @@ import { join } from "node:path";
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { OfflineBanner } from "../offline-banner";
@@ -22,20 +21,16 @@ const ROOT = join(__dirname, "../../../..");
const SHELL_PATH = join(ROOT, "src/components/layout/auth-shell.tsx");
const BANNER_PATH = join(ROOT, "src/components/layout/offline-banner.tsx");
function loadMessages(locale: string): { offlineBanner: { message: string } } {
function loadMessages(locale: string): {
offlineBanner: { message: string; stayOnPage: string };
} {
const path = join(ROOT, "messages", `${locale}.json`);
return JSON.parse(readFileSync(path, "utf8"));
}
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
// v1.18.6 — the banner now reads the cached dashboard snapshot to decide
// whether to append the "showing last synced data" hint, so it needs a
// QueryClient in context.
const queryClient = new QueryClient();
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
@@ -65,6 +60,16 @@ describe("<OfflineBanner>", () => {
);
});
it("says what still works: the open page, not the rest of the app", () => {
// The banner used to promise "your last synced data" although one tap
// on another page ended on the worker's offline page.
const en = loadMessages("en");
expect(en.offlineBanner.stayOnPage).toBe(
"This page stays readable; other pages open once you're back online.",
);
expect(en.offlineBanner).not.toHaveProperty("showingCached");
});
it("ships the message key in every supported locale", () => {
for (const locale of [
"en",
@@ -76,9 +81,11 @@ describe("<OfflineBanner>", () => {
"ko",
"nl",
"pt-BR",
"ru",
] as const) {
const m = loadMessages(locale);
expect(m.offlineBanner.message.length, locale).toBeGreaterThan(0);
expect(m.offlineBanner.stayOnPage.length, locale).toBeGreaterThan(0);
}
});
@@ -89,12 +96,10 @@ describe("<OfflineBanner>", () => {
});
});
// v1.4.43 — also pin the client-side branch: when
// `isOnline === false` the banner paints with the i18n message,
// the WifiOff icon, and the `role="status"` aria-live region. We
// can't easily fire window events from a node-environment vitest
// suite, but we can read the component source and assert the
// branch carries the expected affordances.
// The client-side branch: when `isOnline === false` the banner paints with
// the i18n message, the WifiOff icon, and the `role="status"` aria-live
// region. Window events cannot fire in this node-environment suite, so the
// branch is read from the source.
describe("<OfflineBanner> rendered branch (source-level)", () => {
const source = readFileSync(BANNER_PATH, "utf8");
+29 -43
View File
@@ -2,37 +2,28 @@
import { useEffect, useState } from "react";
import { WifiOff } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { isOfflineNavigationClick } from "@/lib/pwa/offline-navigation";
/**
* v1.4.43 — slim banner that surfaces "you're offline" while
* `navigator.onLine` reads false. The PWA service worker already
* serves the read paths from cache (`public/sw.js`), but pre-fix a
* user toggling airplane mode mid-form-fill saw blank tiles and no
* explanation. This banner closes the explanation gap.
* The strip that says the connection is down, and the guard that keeps the
* open page while it is.
*
* Mount strategy: rendered once at the top of `<AuthShell>` so every
* authenticated route inherits it. The component is mount-time SSR-
* safe — it never paints during the initial server render (the
* `online`/`offline` window events only fire client-side); a `useEffect`
* subscribes to the events once and toggles the visible state from
* there. Honours `prefers-reduced-motion` via the absence of any
* animation.
* Signed-in pages are never cached, so a navigation without a connection ends
* on the service worker's offline page and drops the page that was still
* readable. While the browser is offline, a tap on a link to another page of
* the app stays where it is and repeats the hint in a toast.
*
* Copy lives in `messages/*.json` under `offlineBanner.message`; all
* six locales ship today.
* Rendered once at the top of `<AuthShell>`. Hidden during SSR and the first
* client render (no hydration mismatch); the effect reads `navigator.onLine`
* after mount.
*/
export function OfflineBanner() {
const { t } = useTranslations();
const queryClient = useQueryClient();
// Initialise to `true` so the banner stays hidden during SSR + the
// first client render. The effect below flips it true/false after
// mount based on `navigator.onLine`. (We do NOT read navigator on
// the very first render to keep the SSR markup hydration-stable.)
const [isOnline, setIsOnline] = useState(true);
const stayOnPage = t("offlineBanner.stayOnPage");
useEffect(() => {
function handleOnline() {
@@ -41,17 +32,10 @@ export function OfflineBanner() {
function handleOffline() {
setIsOnline(false);
}
// Subscribe to the live transition events; both handlers route
// through the React setState pathway via the listener callback
// rather than a synchronous in-effect setState so the strict
// `react-hooks/set-state-in-effect` rule stays happy.
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
// Sync the initial state from `navigator.onLine` — deferred to a
// microtask so React sees this as an external-system bridge
// rather than a render-cascade setState. The legacy in-effect
// setState path tripped the strict lint rule, the queueMicrotask
// shape is the documented escape hatch.
// Deferred so the initial read is an external-system bridge, not a
// synchronous setState in the effect body.
queueMicrotask(() => {
if (typeof navigator !== "undefined" && navigator.onLine === false) {
setIsOnline(false);
@@ -63,18 +47,21 @@ export function OfflineBanner() {
};
}, []);
if (isOnline) return null;
useEffect(() => {
// Capture phase on `window`: runs before React's handlers on the
// document, so `next/link` sees the prevented default and stays put.
function keepOpenPage(event: MouseEvent) {
if (!isOfflineNavigationClick(event, window.location, navigator.onLine)) {
return;
}
event.preventDefault();
toast(stayOnPage, { id: "offline-navigation" });
}
window.addEventListener("click", keepOpenPage, true);
return () => window.removeEventListener("click", keepOpenPage, true);
}, [stayOnPage]);
// v1.18.6 — when an offline install is showing last-synced data (the SW's
// allowlisted read cache + the IndexedDB query persister hydrated the
// dashboard snapshot), say so honestly rather than implying a blank screen.
// v1.21.3 (b) — the snapshot cell is now locale-keyed, so match on the
// `["dashboard","snapshot"]` PREFIX (`getQueriesData`) rather than the
// exact zero-arg slot — any hydrated locale's snapshot proves cached data
// is present.
const hasCachedData = queryClient
.getQueriesData({ queryKey: queryKeys.dashboardSnapshot() })
.some(([, data]) => data != null);
if (isOnline) return null;
return (
<div
@@ -85,8 +72,7 @@ export function OfflineBanner() {
>
<WifiOff className="size-3.5 shrink-0" aria-hidden="true" />
<span className="text-center">
{t("offlineBanner.message")}
{hasCachedData ? ` ${t("offlineBanner.showingCached")}` : ""}
{t("offlineBanner.message")} {stayOnPage}
</span>
</div>
);