diff --git a/e2e/pwa-offline.spec.ts b/e2e/pwa-offline.spec.ts
new file mode 100644
index 000000000..05701ce0d
--- /dev/null
+++ b/e2e/pwa-offline.spec.ts
@@ -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);
+ }
+ });
+});
diff --git a/playwright.config.ts b/playwright.config.ts
index 715b38b97..ed9e0ffb8 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -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
diff --git a/public/sw.js b/public/sw.js
index c83b525f8..784022a63 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -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(
- '
HealthLog — Offline
HealthLog
Offline
',
- {
- 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 =
+ 'HealthLog — Offline