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

@@ -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;