fix(pwa): give the offline page a way back and reload once on any stale chunk

Without a connection a signed-in page opens the worker's offline page:
signed-in pages carry `private, no-store` from the proxy and are never
cached, so this is also what an offline launch of the installed app
shows. The page now offers a real 64 px retry control, reloads by itself
when the connection returns, follows the device's light or dark scheme
and keeps clear of the notch and home indicator. The worker's comment on
the page cache says plainly that only public pages land in it.

The single reload after a stale-shell chunk failure moves into
src/lib/pwa/chunk-reload.ts and now runs in the root error boundary too,
and it recognises a failed dynamic import as WebKit and Firefox word it.

pwa-offline.spec.ts runs in the service-worker project and proves the
offline strip, the offline page and the automatic recovery against the
real worker.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 17:23:47 +02:00
1 parent 503802e363
commit ac212cf6b7
8 files changed
+271 -78

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* What the installed app shows without a connection, with the service worker
* actually running.
*
* Runs in the `chromium-service-worker` project, the one that lets the worker
* register (every other project blocks it; see `playwright.config.ts`), and
* like the other spec there it uses no route mocks: the claim is about what
* the worker itself serves.
*
* 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
* itself.
*
* The account is the populated mobile-sweep account; nothing here writes.
*/
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", async ({
page,
context,
}) => {
await page.goto("/medications");
await page.waitForFunction(
() => navigator.serviceWorker?.controller !== null,
undefined,
{ timeout: 20_000 },
);
await expect(page.locator("#main-content")).toBeVisible();
await page.waitForLoadState("networkidle");
try {
await context.setOffline(true);
await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible();
const response = await page.goto("/timeline");
expect(response?.status()).toBe(503);
await expect(page.getByText("Offline", { exact: true })).toBeVisible();
const retry = page.getByRole("link", { name: "Reload" });
await expect(retry).toBeVisible();
const box = (await retry.boundingBox())!;
expect(box.width).toBeGreaterThanOrEqual(44);
expect(box.height).toBeGreaterThanOrEqual(44);
// Back online: the offline page reloads itself into the app.
const reloaded = page.waitForEvent("load");
await context.setOffline(false);
await reloaded;
await expect(page.locator("#main-content")).toBeVisible();
expect(new URL(page.url()).pathname).toBe("/timeline");
} finally {
await context.setOffline(false);
}
});
});
+12 -4
View File
@@ -110,7 +110,10 @@ export default defineConfig({
name: "chromium-desktop",
// The disk-layer spec needs a live service worker, which the shared
// `use` block blocks for every other spec. It runs in its own project.
testIgnore: ["v137-record-session-fence-offline.spec.ts"],
testIgnore: [
"v137-record-session-fence-offline.spec.ts",
"pwa-offline.spec.ts",
],
use: {
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 720 },
@@ -130,10 +133,14 @@ export default defineConfig({
// cannot pass is worse than no check, so the spec gets a project rather
// than a caveat.
//
// It uses no route mocks for exactly this reason, and it is the only
// spec in this project.
// It uses no route mocks for exactly this reason. v1.42 — the offline
// page journey (`pwa-offline.spec.ts`) joined it on the same terms:
// what the worker itself serves without a connection, no mocks.
name: "chromium-service-worker",
testMatch: ["v137-record-session-fence-offline.spec.ts"],
testMatch: [
"v137-record-session-fence-offline.spec.ts",
"pwa-offline.spec.ts",
],
use: {
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 720 },
@@ -220,6 +227,7 @@ export default defineConfig({
"doctor-report-delegate.spec.ts",
// Runs only in the service-worker project.
"v137-record-session-fence-offline.spec.ts",
"pwa-offline.spec.ts",
// The Apple Health import journey uploads an archive into the one
// shared account and its refusal control asserts that the account's
// Apple-Health row count did not move. A second project running the
+27 -7
View File
@@ -341,6 +341,14 @@ async function networkFirstApi(request) {
* scores straight into the HTML and emits `no-store`; the explicit
* path skip is belt-and-braces so a revoked share can never linger in
* CacheStorage and render back offline.
*
* In practice rule 1 covers every signed-in page: `src/proxy.ts` pins
* `private, no-store` on all of them, because several server-prefetch one
* account's record into the HTML. Only the public pages (sign-in, legal)
* land in this cache, so a navigation to an app page without a connection
* gets the offline page below, never somebody's cached record. A page that
* is already open keeps working offline from the cached chunks and the
* allowlisted data reads.
*/
function isCacheableNavigation(request, response) {
const cacheControl = response.headers.get("Cache-Control") || "";
@@ -568,16 +576,28 @@ async function networkFirst(event, cacheName) {
// "Offline" token + a generic retry hint expressed as an icon
// (the round-trip arrow), so the page does the right thing in any
// locale without shipping a translation bundle into the worker.
return new Response(
'<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>HealthLog — Offline</title><style>body{font-family:system-ui,sans-serif;background:#282a36;color:#f8f8f2;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}div{text-align:center;padding:2rem}h1{color:#bd93f9;margin:0 0 .5rem;font-size:2rem;letter-spacing:.05em}p{color:#6272a4;margin:.25rem 0;font-size:.9rem}svg{width:48px;height:48px;color:#6272a4;margin-bottom:1rem}</style></head><body><div><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"/><path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"/></svg><h1>HealthLog</h1><p>Offline</p></div></body></html>',
{
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8" },
},
);
//
// v1.42 — the arrow is a real control now (a link to the same URL,
// 64 px), the page reloads by itself as soon as the connection is
// back, it follows the device's light or dark scheme, and it keeps
// clear of the notch and home indicator of an installed app.
return new Response(OFFLINE_PAGE, {
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
}
const OFFLINE_PAGE =
'<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark light"><title>HealthLog — Offline</title><style>' +
":root{--bg:#282a36;--fg:#f8f8f2;--accent:#bd93f9;--muted:#a4a9c9;color-scheme:dark}" +
"@media (prefers-color-scheme:light){:root{--bg:#f3f2f5;--fg:#1f2030;--accent:#6b46c1;--muted:#5b5e78;color-scheme:light}}" +
"body{font-family:system-ui,sans-serif;background:var(--bg);color:var(--fg);display:flex;align-items:center;justify-content:center;min-height:100vh;min-height:100dvh;margin:0;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);box-sizing:border-box}" +
"main{text-align:center;padding:2rem}h1{color:var(--accent);margin:0 0 .5rem;font-size:2rem;letter-spacing:.05em}p{color:var(--muted);margin:.25rem 0 1.5rem;font-size:1rem}" +
"a{display:inline-flex;align-items:center;justify-content:center;width:64px;height:64px;border-radius:9999px;color:var(--accent);border:2px solid currentColor}a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}svg{width:28px;height:28px}" +
'</style></head><body><main><h1>HealthLog</h1><p>Offline</p><a href="" aria-label="Reload" title="Reload"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"/><path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"/></svg></a></main>' +
'<script>addEventListener("online",function(){location.reload()})</script></body></html>';
/**
* Cache a successful network response without making CacheStorage
* availability part of the fetch contract. Quota, eviction, or trim failures
+21
View File
@@ -217,6 +217,10 @@ function bootServiceWorker(): SwHarness {
URL,
Promise,
console,
// Read through the context so a test can install fake timers before
// dispatching (the slow-navigation fallback arms one per navigation).
setTimeout: (...args: Parameters<typeof setTimeout>) => setTimeout(...args),
clearTimeout: (id: ReturnType<typeof setTimeout>) => clearTimeout(id),
};
vm.createContext(context);
vm.runInContext(SW_SOURCE, context);
@@ -617,6 +621,23 @@ describe("sw.js — networkFirst offline fallback", () => {
});
});
describe("sw.js — offline page", () => {
it("offers a retry control, reloads by itself when the connection returns, and follows the device scheme", async () => {
const harness = bootServiceWorker();
const res = await dispatchNavigationFetch(harness, "/");
expect(res.status).toBe(503);
const html = await res.text();
// A real control, not a decorative icon: a link back to the same URL.
expect(html).toMatch(/<a href="" aria-label="[^"]+"/);
expect(html).toContain('addEventListener("online"');
expect(html).toContain("prefers-color-scheme:light");
expect(html).toContain("viewport-fit=cover");
expect(html).toContain("env(safe-area-inset-bottom)");
// Still language-neutral: no sentence in any one locale.
expect(html).not.toMatch(/Keine Verbindung|You are offline/);
});
});
describe("sw.js — networkFirst privacy gate", () => {
it("does not cache a navigation response that carries Cache-Control: no-store", async () => {
const harness = bootServiceWorker();
@@ -1,5 +1,6 @@
/**
* Unit tests for the chunk-error auto-reload guard in `src/app/error.tsx`.
* Unit tests for the chunk-error auto-reload guard in
* `src/lib/pwa/chunk-reload.ts` (both error boundaries call it).
*
* The guard is keyed on the running shell's build version: one reload
* attempt per BROKEN SHELL, not one per session. After a successful
@@ -10,7 +11,11 @@
import { describe, expect, it } from "vitest";
import { chunkReloadGuardValue, shouldAttemptChunkReload } from "../error";
import {
chunkReloadGuardValue,
isChunkLoadError,
shouldAttemptChunkReload,
} from "@/lib/pwa/chunk-reload";
describe("shouldAttemptChunkReload", () => {
it("reloads when no attempt is recorded", () => {
@@ -39,3 +44,39 @@ describe("shouldAttemptChunkReload", () => {
expect(shouldAttemptChunkReload("unversioned", "")).toBe(false);
});
});
describe("isChunkLoadError", () => {
it.each([
[{ name: "ChunkLoadError", message: "x" }],
[{ name: "Error", message: "Loading chunk 123 failed." }],
[{ name: "Error", message: "Loading CSS chunk 7 failed." }],
[
{
name: "Error",
message: "Failed to load chunk /_next/static/chunks/a.js",
},
],
[
{
name: "TypeError",
message: "Failed to fetch dynamically imported module: /x.js",
},
],
// WebKit and Firefox word a failed dynamic import differently.
[{ name: "TypeError", message: "Importing a module script failed." }],
[
{
name: "TypeError",
message: "error loading dynamically imported module: /x.js",
},
],
])("recognises %j", (err) => {
expect(isChunkLoadError(err)).toBe(true);
});
it("leaves other errors to the error page", () => {
expect(
isChunkLoadError({ name: "TypeError", message: "x is undefined" }),
).toBe(false);
});
});
+5 -65
View File
@@ -2,47 +2,7 @@
import { useEffect } from "react";
import { ErrorDetails } from "@/components/error-details";
const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "";
function isChunkLoadError(err: Error & { digest?: string }): boolean {
if (err.name === "ChunkLoadError") return true;
const msg = err.message ?? "";
return (
msg.includes("Loading chunk") ||
msg.includes("Loading CSS chunk") ||
msg.includes("Failed to load chunk") ||
msg.includes("Failed to fetch dynamically imported module")
);
}
/**
* Guard value recorded per auto-reload attempt — the running shell's
* build version, so the suppression is scoped to ONE shell. After a
* successful heal the reloaded page carries the NEW version and the
* guard re-arms by construction; only a shell that reloaded and is
* STILL broken (the new build is also missing the chunk) stays
* suppressed. Builds without an injected version degrade to the legacy
* once-per-session behaviour via the "unversioned" sentinel.
*/
export function chunkReloadGuardValue(shellVersion: string): string {
return shellVersion || "unversioned";
}
/**
* Pure reload decision so the guard semantics carry direct unit
* coverage. `stored` is the sessionStorage value from the last attempt
* (`null` when none happened); legacy guard values (the pre-v1.16.8
* timestamp) never match a version string, so a session stuck from
* before the change heals on its next chunk error.
*/
export function shouldAttemptChunkReload(
stored: string | null,
shellVersion: string,
): boolean {
return stored !== chunkReloadGuardValue(shellVersion);
}
import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload";
export default function AppError({
error,
@@ -60,30 +20,10 @@ export default function AppError({
g.__healthlog_onError?.(error);
}
// v1.4.38.3 — auto-recover from a stale-shell chunk-load error.
// After a deploy the cached shell still references old chunk
// filenames; they 404 and Next.js surfaces them as `ChunkLoadError`.
// A single hard reload fetches the new shell + chunk graph.
// The sessionStorage guard is keyed on the shell version (see
// `chunkReloadGuardValue`) so it suppresses a loop within ONE
// broken shell but re-arms after every deploy — the pre-v1.16.8
// once-per-session guard exhausted on multi-deploy days and left
// the user on the error page with no automatic way out.
if (typeof window !== "undefined" && isChunkLoadError(error)) {
try {
const stored = window.sessionStorage.getItem(CHUNK_RELOAD_KEY);
if (shouldAttemptChunkReload(stored, SHELL_VERSION)) {
window.sessionStorage.setItem(
CHUNK_RELOAD_KEY,
chunkReloadGuardValue(SHELL_VERSION),
);
window.location.reload();
}
} catch {
// sessionStorage can throw under strict privacy modes — fall
// through to the error UI in that case.
}
}
// v1.4.38.3 — auto-recover from a stale-shell chunk-load error with a
// single reload (`src/lib/pwa/chunk-reload.ts`, shared with the root
// boundary).
reloadOnceForChunkError(error);
}, [error]);
return <ErrorDetails error={error} reset={reset} />;
+10
View File
@@ -1,5 +1,9 @@
"use client";
import { useEffect } from "react";
import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload";
/**
* Root-level error boundary. Renders when even the root layout itself fails,
* so it cannot rely on any providers (i18n, query, auth). Keep it static,
@@ -12,6 +16,12 @@ export default function GlobalError({
error: Error & { digest?: string };
reset: () => void;
}) {
// A chunk of the root layout's client tree missing after a deploy lands
// here rather than in `app/error.tsx`; it gets the same single reload.
useEffect(() => {
reloadOnceForChunkError(error);
}, [error]);
async function handleCopy() {
// Strip query string — OAuth callback routes carry sensitive tokens.
const loc = typeof window !== "undefined" ? window.location : null;
+89
View File
@@ -0,0 +1,89 @@
/**
* One reload after a stale-shell chunk failure.
*
* After a deploy, a page that was open across it still references the
* previous build's chunk files; they 404 and the lazy load fails. A client
* navigation already recovers on its own (Next turns a failed chunk during a
* route change into a full page load), but a chunk that fails inside a page
* (a `next/dynamic` panel, a lazily imported sheet) surfaces in an error
* boundary instead. Both boundaries, the route one (`app/error.tsx`) and the
* root one (`app/global-error.tsx`), call `reloadOnceForChunkError`, which
* loads the page once so it arrives with the new build's chunk graph.
*
* The guard is keyed on the running shell's version: it suppresses a loop
* within ONE broken shell, and re-arms after every deploy (the pre-v1.16.8
* once-per-session key exhausted itself on multi-deploy days and stranded the
* user on the error page).
*/
const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted";
/**
* A failed chunk, in the words of each loader and engine: webpack's
* `ChunkLoadError` / "Loading chunk", its CSS twin, Turbopack's "Failed to
* load chunk", and a native dynamic import as Chromium, WebKit and Firefox
* report it.
*/
export function isChunkLoadError(err: { name?: string; message?: string }) {
if (err.name === "ChunkLoadError") return true;
const msg = err.message ?? "";
return (
msg.includes("Loading chunk") ||
msg.includes("Loading CSS chunk") ||
msg.includes("Failed to load chunk") ||
msg.includes("Failed to fetch dynamically imported module") ||
msg.includes("Importing a module script failed") ||
msg.includes("error loading dynamically imported module")
);
}
/**
* Guard value recorded per auto-reload attempt — the running shell's build
* version, so the suppression is scoped to ONE shell. After a successful
* heal the reloaded page carries the NEW version and the guard re-arms by
* construction; only a shell that reloaded and is STILL broken (the new
* build is also missing the chunk) stays suppressed. Builds without an
* injected version degrade to once-per-session via the "unversioned"
* sentinel.
*/
export function chunkReloadGuardValue(shellVersion: string): string {
return shellVersion || "unversioned";
}
/**
* Pure reload decision. `stored` is the sessionStorage value from the last
* attempt (`null` when none happened); legacy guard values (the pre-v1.16.8
* timestamp) never match a version string, so a session stuck from before
* the change heals on its next chunk error.
*/
export function shouldAttemptChunkReload(
stored: string | null,
shellVersion: string,
): boolean {
return stored !== chunkReloadGuardValue(shellVersion);
}
/**
* Reload once if `error` is a chunk failure and this shell has not tried
* yet. Returns whether a reload was started.
*/
export function reloadOnceForChunkError(
error: { name?: string; message?: string },
shellVersion: string = process.env.NEXT_PUBLIC_APP_VERSION || "",
): boolean {
if (typeof window === "undefined" || !isChunkLoadError(error)) return false;
try {
const stored = window.sessionStorage.getItem(CHUNK_RELOAD_KEY);
if (!shouldAttemptChunkReload(stored, shellVersion)) return false;
window.sessionStorage.setItem(
CHUNK_RELOAD_KEY,
chunkReloadGuardValue(shellVersion),
);
} catch {
// sessionStorage can throw under strict privacy modes; without a guard
// a reload could loop, so fall through to the error UI instead.
return false;
}
window.location.reload();
return true;
}