From 0df65ce396c179dcf5058621e4b1d7a132a8b5aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:33:57 +0100 Subject: [PATCH] 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. --- README.md | 2 +- e2e/pwa-offline.spec.ts | 26 +++-- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/nl.json | 2 +- messages/pl.json | 2 +- messages/pt-BR.json | 2 +- messages/ru.json | 2 +- .../baselines/version-stamp-comments.json | 6 +- .../layout/__tests__/offline-banner.test.tsx | 37 +++---- src/components/layout/offline-banner.tsx | 72 ++++++-------- .../pwa/__tests__/offline-navigation.test.ts | 96 +++++++++++++++++++ src/lib/pwa/offline-navigation.ts | 46 +++++++++ 17 files changed, 224 insertions(+), 81 deletions(-) create mode 100644 src/lib/pwa/__tests__/offline-navigation.test.ts create mode 100644 src/lib/pwa/offline-navigation.ts diff --git a/README.md b/README.md index 4a1a137ee..392723cc0 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig - **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network. - **Clinician-ready output.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit. - **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere. -- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, Dutch, and Brazilian Portuguese end to end. +- **Built to be lived in.** Installable PWA that keeps the open page readable when the connection drops, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, Dutch, and Brazilian Portuguese end to end. The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev). diff --git a/e2e/pwa-offline.spec.ts b/e2e/pwa-offline.spec.ts index 28b34c16a..b71d67ac0 100644 --- a/e2e/pwa-offline.spec.ts +++ b/e2e/pwa-offline.spec.ts @@ -12,12 +12,14 @@ import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; * * 1. A page that is open when the connection drops stays the app and says * so (the offline strip). - * 2. Opening a page without a connection gets the worker's offline page: - * the word "Offline" and a working retry control, never the browser's - * error. (Signed-in pages are never cached, by design: the proxy pins - * `private, no-store` on them because some carry the record in the - * HTML. So this is what an offline launch of the installed app shows.) - * 3. When the connection returns, that page reloads into the app by + * 2. A tap on a link to another page keeps the open page and repeats the + * hint, instead of trading the readable page for the offline one. + * 3. Opening a page without a connection (a typed URL, a cold start) gets + * the worker's offline page: the word "Offline" and a working retry + * control, never the browser's error. Signed-in pages are never cached: + * the proxy pins `private, no-store` on them because some carry the + * record in the HTML. + * 4. When the connection returns, that page reloads into the app by * itself. * * The account is the populated mobile-sweep account; nothing here writes. @@ -25,7 +27,7 @@ import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; test.describe("offline, with the service worker", () => { test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); - test("an open page says it is offline, a new one opens the offline page, and both recover @critical", async ({ + test("an open page stays readable offline, a new one opens the offline page, and both recover @critical", async ({ page, context, }) => { @@ -42,6 +44,16 @@ test.describe("offline, with the service worker", () => { await context.setOffline(true); await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible(); + // A tap on the dashboard link stays on the open page. + await page.locator('a[href="/"]').first().click(); + await expect( + page + .locator("[data-sonner-toast]") + .filter({ hasText: "other pages open once you're back online" }), + ).toBeVisible(); + expect(new URL(page.url()).pathname).toBe("/medications"); + await expect(page.locator("#main-content")).toBeVisible(); + const response = await page.goto("/timeline"); expect(response?.status()).toBe(503); await expect(page.getByText("Offline", { exact: true })).toBeVisible(); diff --git a/messages/de.json b/messages/de.json index 4dcc47c51..d48421401 100644 --- a/messages/de.json +++ b/messages/de.json @@ -452,7 +452,7 @@ "offlineBanner": { "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." + "stayOnPage": "Diese Seite bleibt lesbar, andere Seiten öffnen sich, sobald du wieder online bist." }, "demoBanner": { "message": "Demo-Modus: Änderungen werden nicht gespeichert." diff --git a/messages/en.json b/messages/en.json index 6a5c05ac9..791c9073a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -452,7 +452,7 @@ "offlineBanner": { "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." + "stayOnPage": "This page stays readable; other pages open once you're back online." }, "demoBanner": { "message": "Demo mode: changes are not saved." diff --git a/messages/es.json b/messages/es.json index a2b612bad..1efa45ab6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -452,7 +452,7 @@ "offlineBanner": { "message": "Sin conexión: los cambios no se pueden guardar hasta que vuelvas a estar en línea.", "saveFailed": "No se pudo guardar porque estás sin conexión. Inténtalo de nuevo cuando vuelvas a estar en línea.", - "showingCached": "Mostrando tus últimos datos sincronizados." + "stayOnPage": "Esta página se puede seguir leyendo; las demás se abrirán cuando vuelvas a estar en línea." }, "demoBanner": { "message": "Modo demo: los cambios no se guardan." diff --git a/messages/fr.json b/messages/fr.json index af97df036..bfc94b278 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -452,7 +452,7 @@ "offlineBanner": { "message": "Pas de connexion : les modifications ne peuvent pas être enregistrées tant que vous n'êtes pas de nouveau en ligne.", "saveFailed": "Enregistrement impossible : vous êtes hors ligne. Réessayez une fois de nouveau en ligne.", - "showingCached": "Affichage de vos dernières données synchronisées." + "stayOnPage": "Cette page reste lisible ; les autres s'ouvriront dès que vous serez de nouveau en ligne." }, "demoBanner": { "message": "Mode démo : les modifications ne sont pas enregistrées." diff --git a/messages/it.json b/messages/it.json index fed51449c..97adcd5d1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -452,7 +452,7 @@ "offlineBanner": { "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." + "stayOnPage": "Questa pagina resta leggibile; le altre si aprono quando torni online." }, "demoBanner": { "message": "Modalità demo: le modifiche non vengono salvate." diff --git a/messages/ko.json b/messages/ko.json index 77eee65e5..8cce4be68 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -452,7 +452,7 @@ "offlineBanner": { "message": "연결이 없어요. 온라인으로 돌아올 때까지 저장할 수 없어요.", "saveFailed": "저장하지 못했어요. 오프라인 상태예요. 온라인으로 돌아온 뒤 다시 시도해 주세요.", - "showingCached": "마지막으로 동기화한 데이터를 보여주고 있어요." + "stayOnPage": "이 페이지는 계속 볼 수 있어요. 다른 페이지는 온라인으로 돌아오면 열려요." }, "demoBanner": { "message": "데모 모드: 변경 내용은 저장되지 않아요." diff --git a/messages/nl.json b/messages/nl.json index 1df8e98bf..88cb3077c 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -452,7 +452,7 @@ "offlineBanner": { "message": "Geen verbinding: wijzigingen kunnen pas worden opgeslagen als je weer online bent.", "saveFailed": "Opslaan is mislukt, je bent offline. Probeer het opnieuw zodra je weer online bent.", - "showingCached": "Je laatst gesynchroniseerde gegevens worden getoond." + "stayOnPage": "Deze pagina blijft leesbaar; andere pagina's openen zodra je weer online bent." }, "demoBanner": { "message": "Demomodus: wijzigingen worden niet opgeslagen." diff --git a/messages/pl.json b/messages/pl.json index fa9457d77..c06b9ff77 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -452,7 +452,7 @@ "offlineBanner": { "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." + "stayOnPage": "Ta strona pozostaje czytelna, inne otworzą się, gdy wrócisz online." }, "demoBanner": { "message": "Tryb demo: zmiany nie są zapisywane." diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 6abf9a204..3591c59cb 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -452,7 +452,7 @@ "offlineBanner": { "message": "Sem conexão: as alterações não podem ser salvas até você voltar a ficar online.", "saveFailed": "Não foi possível salvar, você está offline. Tente novamente quando voltar a ficar online.", - "showingCached": "Mostrando seus últimos dados sincronizados." + "stayOnPage": "Esta página continua legível; as outras abrem quando você voltar a ficar online." }, "demoBanner": { "message": "Modo demonstração: as alterações não são salvas." diff --git a/messages/ru.json b/messages/ru.json index 159cf4fab..16d386b54 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -452,7 +452,7 @@ "offlineBanner": { "message": "Нет подключения: изменения нельзя сохранить, пока вы снова не в сети.", "saveFailed": "Не удалось сохранить, вы не в сети. Попробуйте снова, когда подключение вернётся.", - "showingCached": "Показаны последние синхронизированные данные." + "stayOnPage": "Эту страницу можно читать дальше, другие откроются, когда вы снова будете в сети." }, "demoBanner": { "message": "Деморежим: изменения не сохраняются." diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 4e39c2009..e480b868d 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2890, - "total": 9211, + "count": 2888, + "total": 9205, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1092,7 +1092,6 @@ "components/layout/__tests__/capture-picker-discard.test.tsx": 1, "components/layout/__tests__/capture-picker.test.tsx": 1, "components/layout/__tests__/nav-model.test.ts": 7, - "components/layout/__tests__/offline-banner.test.tsx": 3, "components/layout/__tests__/shared-record-chrome.test.tsx": 1, "components/layout/__tests__/sidebar-nav.test.tsx": 3, "components/layout/account-switcher-menu.tsx": 1, @@ -1100,7 +1099,6 @@ "components/layout/bottom-nav.tsx": 8, "components/layout/capture-picker.tsx": 5, "components/layout/nav-model.ts": 17, - "components/layout/offline-banner.tsx": 3, "components/layout/record-session-fence-bridge.tsx": 1, "components/layout/shared-record-banner.tsx": 1, "components/layout/shared-record-grant-loss-bridge.tsx": 1, diff --git a/src/components/layout/__tests__/offline-banner.test.tsx b/src/components/layout/__tests__/offline-banner.test.tsx index 3bb7d0bd6..cf7e225ac 100644 --- a/src/components/layout/__tests__/offline-banner.test.tsx +++ b/src/components/layout/__tests__/offline-banner.test.tsx @@ -1,5 +1,5 @@ /** - * v1.4.43 — `` SSR contract. + * `` 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( - - {node} - , + {node}, ); } @@ -65,6 +60,16 @@ describe("", () => { ); }); + 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("", () => { "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("", () => { }); }); -// 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(" rendered branch (source-level)", () => { const source = readFileSync(BANNER_PATH, "utf8"); diff --git a/src/components/layout/offline-banner.tsx b/src/components/layout/offline-banner.tsx index e7190d5d2..9879dfc30 100644 --- a/src/components/layout/offline-banner.tsx +++ b/src/components/layout/offline-banner.tsx @@ -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 `` 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 ``. 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 (
); diff --git a/src/lib/pwa/__tests__/offline-navigation.test.ts b/src/lib/pwa/__tests__/offline-navigation.test.ts new file mode 100644 index 000000000..15c984120 --- /dev/null +++ b/src/lib/pwa/__tests__/offline-navigation.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { isOfflineNavigationClick } from "@/lib/pwa/offline-navigation"; + +const HERE = { + origin: "https://app.example", + pathname: "/medications", + search: "", +}; + +interface AnchorInit { + href: string; + target?: string; + download?: boolean; +} + +/** A click on an element inside an anchor, the way a nav icon is tapped. */ +function clickOn( + anchor: AnchorInit | null, + overrides: Partial[0]> = {}, +) { + const link = anchor && { + href: new URL(anchor.href, HERE.origin).href, + target: anchor.target ?? "", + hasAttribute: (name: string) => name === "download" && !!anchor.download, + }; + return { + defaultPrevented: false, + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + target: { + closest: (selector: string) => (selector === "a[href]" ? link : null), + } as unknown as EventTarget, + ...overrides, + }; +} + +describe("isOfflineNavigationClick", () => { + it("holds a tap on a link to another page while offline", () => { + expect(isOfflineNavigationClick(clickOn({ href: "/" }), HERE, false)).toBe( + true, + ); + expect( + isOfflineNavigationClick( + clickOn({ href: "/measurements?add=WEIGHT" }), + HERE, + false, + ), + ).toBe(true); + // Same path, different query: a different page for the router. + expect( + isOfflineNavigationClick( + clickOn({ href: "/medications?tab=history" }), + HERE, + false, + ), + ).toBe(true); + }); + + it("lets every link through while online", () => { + expect(isOfflineNavigationClick(clickOn({ href: "/" }), HERE, true)).toBe( + false, + ); + }); + + it("leaves links within the open page alone", () => { + expect( + isOfflineNavigationClick( + clickOn({ href: "/medications#main-content" }), + HERE, + false, + ), + ).toBe(false); + }); + + it("leaves clicks the browser handles itself alone", () => { + const offline = (click: ReturnType) => + isOfflineNavigationClick(click, HERE, false); + expect(offline(clickOn({ href: "https://elsewhere.example/" }))).toBe( + false, + ); + expect(offline(clickOn({ href: "/", target: "_blank" }))).toBe(false); + expect(offline(clickOn({ href: "/export.csv", download: true }))).toBe( + false, + ); + expect(offline(clickOn({ href: "/" }, { metaKey: true }))).toBe(false); + expect(offline(clickOn({ href: "/" }, { button: 1 }))).toBe(false); + expect(offline(clickOn({ href: "/" }, { defaultPrevented: true }))).toBe( + false, + ); + expect(offline(clickOn(null))).toBe(false); + }); +}); diff --git a/src/lib/pwa/offline-navigation.ts b/src/lib/pwa/offline-navigation.ts new file mode 100644 index 000000000..029105a84 --- /dev/null +++ b/src/lib/pwa/offline-navigation.ts @@ -0,0 +1,46 @@ +type ClickLike = Pick< + MouseEvent, + | "defaultPrevented" + | "button" + | "metaKey" + | "ctrlKey" + | "shiftKey" + | "altKey" + | "target" +>; + +/** + * Whether a click would leave the open page for another one of this app while + * the browser is offline. + * + * Signed-in pages are never cached (the proxy sends them `no-store`), so that + * navigation can only end on the service worker's offline page and throws + * away a page that was still readable. Clicks the browser handles on its own + * (new tab, download, another origin) and links within the same page are left + * alone. + */ +export function isOfflineNavigationClick( + event: ClickLike, + here: Pick, + online: boolean, +): boolean { + if (online || event.defaultPrevented || event.button !== 0) return false; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return false; + } + const anchor = (event.target as Partial | null)?.closest?.( + "a[href]", + ) as HTMLAnchorElement | null | undefined; + // An SVG `` carries an animated string, not a URL. + if (!anchor || typeof anchor.href !== "string") return false; + if (anchor.target && anchor.target !== "_self") return false; + if (anchor.hasAttribute("download")) return false; + let url: URL; + try { + url = new URL(anchor.href, here.origin); + } catch { + return false; + } + if (url.origin !== here.origin) return false; + return url.pathname !== here.pathname || url.search !== here.search; +}