diff --git a/document/06-traps.md b/document/06-traps.md
index 2bb8e31..8d8bf4a 100755
--- a/document/06-traps.md
+++ b/document/06-traps.md
@@ -521,6 +521,46 @@ The rule this leaves: a module under `src/lib/calc` may return an id, a number
or a date. If it is about to return a sentence, the sentence belongs to
whoever renders it.
+## A deploy takes the old build's chunks away from the old shell
+
+Reported as a white screen and `Application error: a client-side exception`,
+with `ChunkLoadError: Loading chunk 135 failed` in the console, on a deployment
+that had just gone out and worked for everybody who had never visited before.
+
+The hashes said what happened. The browser was running
+`webpack-55676dfde347fa72.js` where the new build had
+`webpack-9100694e6ef91c55.js`, so the runtime was the old one, out of the
+service worker's cache. It asked for `135-d5de4c531d4e06a7.js`, and the new
+build's copy of that chunk is `135-b4d0965ad68280be.js`, so the request 404ed.
+The neighbouring `255-3d881dfa8c72bc56.js` loaded perfectly, because that chunk
+had not changed between the two builds and therefore kept its name. A page half
+old and half new, which is the exact thing the per-build cache was written to
+prevent.
+
+It prevents it for the files it holds. What it cannot prevent is a chunk it
+never held: the cache is populated by visiting, chunks are lazy, and a deploy
+removes the old ones from the server. From the moment of a deploy, any chunk
+the old shell had not already cached is gone for good.
+
+The design is still right. Updates are explicit on purpose, and cache-first is
+what makes the app work on a phone with no signal. The flaw was that the way
+out, the update prompt, lives inside the app that cannot boot.
+
+`src/lib/recover.ts` is the way out that does not: an inline script in the head,
+before the app's own code, listening for exactly this failure and, when it
+comes, dropping the caches, unregistering the worker and reloading once. Twice
+in ten minutes and it stops, because if clearing the cache did not help then the
+fault is on the server and a page that reloads forever is one broken page turned
+into a machine hammering it.
+
+Two things about the shape of it are deliberate. It is an inline script rather
+than a component, because by the time React could mount a component the chunk it
+needs may be the missing one. And it is the real functions serialised with
+`toString()` rather than the same rules written out a second time as a string,
+because two copies of a rule is how one of them gets fixed. There are tests that
+run the serialised source in a sandbox, since source that has never been run has
+never been checked.
+
## Git from a Linux shell, on a working tree checked out by Windows
`git status` in the mounted repo reported thirty modified files, including
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index ac69fb9..cfa8172 100755
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next";
+import { RECOVERY_SCRIPT } from "@/lib/recover";
import { IBM_Plex_Mono, Plus_Jakarta_Sans } from "next/font/google";
import "./globals.css";
import { AppFrame } from "@/components/AppFrame";
@@ -106,6 +107,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
*/}
+ {/*
+ Before the app's own code, because the thing it is listening for is
+ the app's own code failing to arrive. See src/lib/recover.ts.
+ */}
+
{
+ /* The one that was actually reported, from a real deployment. */
+ it("knows the error that started this", () => {
+ expect(isChunkError("ChunkLoadError", "Loading chunk 135 failed.")).toBe(true);
+ });
+
+ it("knows it under each name it arrives with", () => {
+ expect(isChunkError("Error", "Loading chunk 42 failed.")).toBe(true);
+ expect(isChunkError("ChunkLoadError", undefined)).toBe(true);
+ expect(isChunkError("TypeError", "Failed to fetch dynamically imported module: /_next/x.js")).toBe(true);
+ expect(isChunkError("TypeError", "error loading dynamically imported module")).toBe(true);
+ expect(isChunkError("Error", "Loading CSS chunk 7 failed.")).toBe(true);
+ });
+
+ /*
+ * The important half. Reloading and dropping the cache on an ordinary bug
+ * would hide it and cost somebody their unsaved form, so this has to stay
+ * narrow.
+ */
+ it("leaves every other error alone", () => {
+ expect(isChunkError("TypeError", "x is not a function")).toBe(false);
+ expect(isChunkError("Error", "Network request failed")).toBe(false);
+ expect(isChunkError(undefined, undefined)).toBe(false);
+ expect(isChunkError(null, "")).toBe(false);
+ expect(isChunkError("QuotaExceededError", "The quota has been exceeded.")).toBe(false);
+ });
+});
+
+describe("readHistory", () => {
+ it("starts from nothing when nothing is stored", () => {
+ expect(readHistory(null)).toEqual({ n: 0, first: 0 });
+ });
+
+ it("starts from nothing when something else wrote there", () => {
+ expect(readHistory("not json")).toEqual({ n: 0, first: 0 });
+ expect(readHistory('{"n":"lots"}')).toEqual({ n: 0, first: 0 });
+ });
+
+ it("reads back what it wrote", () => {
+ expect(readHistory('{"n":1,"first":1700000000000}')).toEqual({
+ n: 1,
+ first: 1_700_000_000_000,
+ });
+ });
+});
+
+describe("recoveryPlan", () => {
+ const NOW = 1_700_000_000_000;
+
+ it("allows the first attempt and remembers when it was", () => {
+ const { allow, next } = recoveryPlan({ n: 0, first: 0 }, NOW);
+ expect(allow).toBe(true);
+ expect(next).toEqual({ n: 1, first: NOW });
+ });
+
+ it("allows a second, on the same clock", () => {
+ const { allow, next } = recoveryPlan({ n: 1, first: NOW }, NOW + 4000);
+ expect(allow).toBe(true);
+ expect(next).toEqual({ n: 2, first: NOW });
+ });
+
+ /*
+ * The guard that matters. If clearing the cache did not fix it the fault is
+ * on the server, and a page that reloads forever is one broken page turned
+ * into a machine hammering it.
+ */
+ it("stops after the limit, rather than looping", () => {
+ const { allow, next } = recoveryPlan({ n: RECOVERY_LIMIT, first: NOW }, NOW + 5000);
+ expect(allow).toBe(false);
+ expect(next).toEqual({ n: RECOVERY_LIMIT, first: NOW });
+ });
+
+ it("forgives once the window has passed", () => {
+ const later = NOW + RECOVERY_WINDOW_MS + 1;
+ const { allow, next } = recoveryPlan({ n: RECOVERY_LIMIT, first: NOW }, later);
+ expect(allow).toBe(true);
+ expect(next).toEqual({ n: 1, first: later });
+ });
+});
+
+/*
+ * The script is the functions above, serialised. These hold that it stayed
+ * that way: a second copy written out by hand is how one of them gets fixed
+ * and the other does not.
+ */
+describe("the inline script", () => {
+ it("carries the real functions rather than a retyped copy", () => {
+ expect(RECOVERY_SCRIPT).toContain(isChunkError.toString());
+ expect(RECOVERY_SCRIPT).toContain(recoveryPlan.toString());
+ expect(RECOVERY_SCRIPT).toContain(readHistory.toString());
+ });
+
+ it("closes the door behind itself", () => {
+ expect(RECOVERY_SCRIPT).toContain("location.reload()");
+ expect(RECOVERY_SCRIPT).toContain("unregister()");
+ expect(RECOVERY_SCRIPT).toContain("caches.delete");
+ });
+
+ /* It goes into a script tag, so a stray closing tag would end it early. */
+ it("cannot end the tag it is written into", () => {
+ expect(RECOVERY_SCRIPT).not.toContain(" {
+ async function boot(stored: string | null = null) {
+ const { runInNewContext } = await import("node:vm");
+ const listeners: Record void)[]> = {};
+ const state = { reloads: 0, cachesDeleted: [] as string[], unregistered: 0, wrote: "" };
+
+ const sandbox = {
+ addEventListener(type: string, fn: (e: unknown) => void) {
+ (listeners[type] ??= []).push(fn);
+ },
+ localStorage: {
+ getItem: () => stored,
+ setItem: (_k: string, v: string) => {
+ state.wrote = v;
+ },
+ },
+ caches: {
+ keys: async () => ["bench-1", "bench-2"],
+ delete: async (n: string) => {
+ state.cachesDeleted.push(n);
+ return true;
+ },
+ },
+ navigator: {
+ serviceWorker: {
+ getRegistrations: async () => [
+ {
+ unregister: async () => {
+ state.unregistered++;
+ return true;
+ },
+ },
+ ],
+ },
+ },
+ location: {
+ reload: () => {
+ state.reloads++;
+ },
+ },
+ Date,
+ Promise,
+ JSON,
+ console,
+ } as Record;
+ sandbox.self = sandbox;
+
+ runInNewContext(RECOVERY_SCRIPT, sandbox);
+ return { listeners, state };
+ }
+
+ /** A microtask turn or two, for the promises inside the handler. */
+ const settle = () => new Promise((r) => setTimeout(r, 0));
+
+ it("parses and attaches to both kinds of failure", async () => {
+ const { listeners } = await boot();
+ expect(listeners.error).toHaveLength(1);
+ expect(listeners.unhandledrejection).toHaveLength(1);
+ });
+
+ it("clears the cache, drops the worker and reloads, in that order", async () => {
+ const { listeners, state } = await boot();
+ listeners.error[0]({ error: { name: "ChunkLoadError" }, message: "Loading chunk 135 failed." });
+ await settle();
+
+ expect(state.cachesDeleted).toEqual(["bench-1", "bench-2"]);
+ expect(state.unregistered).toBe(1);
+ expect(state.reloads).toBe(1);
+ expect(JSON.parse(state.wrote).n).toBe(1);
+ });
+
+ it("recovers from a rejected dynamic import too", async () => {
+ const { listeners, state } = await boot();
+ listeners.unhandledrejection[0]({
+ reason: { name: "TypeError", message: "Failed to fetch dynamically imported module: /_next/x.js" },
+ });
+ await settle();
+ expect(state.reloads).toBe(1);
+ });
+
+ it("does nothing at all for an ordinary error", async () => {
+ const { listeners, state } = await boot();
+ listeners.error[0]({ error: { name: "TypeError" }, message: "x is not a function" });
+ await settle();
+ expect(state.reloads).toBe(0);
+ expect(state.cachesDeleted).toEqual([]);
+ });
+
+ it("refuses a third attempt inside the window", async () => {
+ const { listeners, state } = await boot(
+ JSON.stringify({ n: RECOVERY_LIMIT, first: Date.now() }));
+ listeners.error[0]({ error: { name: "ChunkLoadError" }, message: "Loading chunk 135 failed." });
+ await settle();
+ expect(state.reloads).toBe(0);
+ });
+});
diff --git a/src/lib/recover.ts b/src/lib/recover.ts
new file mode 100644
index 0000000..61207bd
--- /dev/null
+++ b/src/lib/recover.ts
@@ -0,0 +1,148 @@
+/**
+ * Getting back in when the shell in the cache no longer matches the server.
+ *
+ * The failure, in the order it happens. Updates here are deliberately explicit:
+ * the service worker caches per build id and the running app compares its own
+ * id against `/version.json` and offers the choice, rather than swapping code
+ * under somebody mid-session. The cache is therefore allowed to go on serving
+ * the old document, which registers `/sw.js?v=`, which is the same URL
+ * as before, so no new worker installs. All of that is intended.
+ *
+ * What was not intended is that a deploy takes the old build's files off the
+ * server. Chunks are content-hashed and lazily loaded, so any chunk the old
+ * shell had not already cached is, from the moment of the deploy, a 404. The
+ * old webpack runtime asks for it by its old name, the request fails, and the
+ * page is a client-side exception before it has drawn anything.
+ *
+ * That is the part that makes it serious: the way out, the update prompt, lives
+ * inside the app that cannot boot. Reported as exactly that, a white screen and
+ * `ChunkLoadError: Loading chunk 135 failed` on a deployment whose other chunk
+ * had kept its hash and loaded fine.
+ *
+ * So this repairs it. Not by giving up on explicit updates, which are a good
+ * decision, but by noticing that the shell is already broken and that there is
+ * nothing left to protect: drop the caches, unregister the worker, reload once.
+ *
+ * Twice in ten minutes, and then it stops. If clearing the cache did not help,
+ * the fault is on the server and reloading forever would turn one broken page
+ * into a machine hammering it.
+ */
+
+/** Two attempts, then leave the error on screen where somebody can read it. */
+export const RECOVERY_LIMIT = 2;
+export const RECOVERY_WINDOW_MS = 10 * 60 * 1000;
+export const RECOVERY_KEY = "bench-chunk-recovery";
+
+/**
+ * Whether this error is the shell asking for a file that is no longer there.
+ *
+ * Matched on the message as well as the name, because the same failure reaches
+ * us under three different names depending on how the chunk was requested and
+ * which browser is asking.
+ */
+export function isChunkError(name: unknown, message: unknown): boolean {
+ const text = `${name ?? ""} ${message ?? ""}`;
+ return (
+ /ChunkLoadError/i.test(text) ||
+ /Loading chunk \S+ failed/i.test(text) ||
+ /Loading CSS chunk/i.test(text) ||
+ /error loading dynamically imported module/i.test(text) ||
+ /Failed to fetch dynamically imported module/i.test(text) ||
+ /Importing a module script failed/i.test(text)
+ );
+}
+
+export interface RecoveryHistory {
+ /** How many times this browser has already tried, inside the window. */
+ n: number;
+ /** When the first of those attempts was. */
+ first: number;
+}
+
+export function readHistory(raw: string | null): RecoveryHistory {
+ try {
+ const v = JSON.parse(raw ?? "");
+ const n = Number(v?.n);
+ const first = Number(v?.first);
+ if (Number.isFinite(n) && Number.isFinite(first)) return { n, first };
+ } catch {
+ // Nothing stored, or something else wrote here. Either way, start over.
+ }
+ return { n: 0, first: 0 };
+}
+
+/**
+ * Whether to try again, and what to write down before doing so.
+ *
+ * The window restarts once it has passed, so a deploy next week is not refused
+ * a repair because of one last month.
+ */
+export function recoveryPlan(
+ history: RecoveryHistory,
+ nowMs: number): { allow: boolean; next: RecoveryHistory } {
+ const stale = history.first === 0 || nowMs - history.first > RECOVERY_WINDOW_MS;
+ const n = stale ? 0 : history.n;
+ if (n >= RECOVERY_LIMIT) return { allow: false, next: history };
+ return { allow: true, next: { n: n + 1, first: stale ? nowMs : history.first } };
+}
+
+/**
+ * The listener, as source, for an inline script in the document head.
+ *
+ * Inline and not a component, because by the time React could mount one the
+ * chunk it needs may be the chunk that is missing. This runs before the app's
+ * own code and is listening while it loads.
+ *
+ * Serialised from the real functions above rather than written out a second
+ * time as a string. Two copies of a rule is how one of them gets fixed.
+ */
+export const RECOVERY_SCRIPT = `
+(function(){
+ var isChunkError = ${isChunkError.toString()};
+ var readHistory = ${readHistory.toString()};
+ var recoveryPlan = ${recoveryPlan.toString()};
+ var RECOVERY_LIMIT = ${RECOVERY_LIMIT};
+ var RECOVERY_WINDOW_MS = ${RECOVERY_WINDOW_MS};
+ var KEY = ${JSON.stringify(RECOVERY_KEY)};
+
+ function recover() {
+ var plan;
+ try {
+ plan = recoveryPlan(readHistory(localStorage.getItem(KEY)), Date.now());
+ if (!plan.allow) return;
+ localStorage.setItem(KEY, JSON.stringify(plan.next));
+ } catch (e) {
+ // Storage refused, which is a browsing mode rather than a fault. One
+ // attempt with no memory of it is better than none at all.
+ plan = { allow: true };
+ }
+
+ var jobs = [];
+ try {
+ if (self.caches) {
+ jobs.push(caches.keys().then(function (names) {
+ return Promise.all(names.map(function (n) { return caches.delete(n); }));
+ }));
+ }
+ } catch (e) {}
+ try {
+ if (navigator.serviceWorker) {
+ jobs.push(navigator.serviceWorker.getRegistrations().then(function (rs) {
+ return Promise.all(rs.map(function (r) { return r.unregister(); }));
+ }));
+ }
+ } catch (e) {}
+
+ var again = function () { location.reload(); };
+ Promise.all(jobs).then(again, again);
+ }
+
+ addEventListener("error", function (e) {
+ if (isChunkError(e && e.error && e.error.name, (e && e.message) || (e && e.error && e.error.message))) recover();
+ });
+ addEventListener("unhandledrejection", function (e) {
+ var r = e && e.reason;
+ if (isChunkError(r && r.name, r && r.message)) recover();
+ });
+})();
+`;