mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
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");
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user