Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions document/06-traps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -106,6 +107,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
*/}
<link rel="manifest" href="/manifest.webmanifest" crossOrigin="use-credentials" />
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
{/*
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.
*/}
<script dangerouslySetInnerHTML={{ __html: RECOVERY_SCRIPT }} />
</head>
<body
className={`${jakarta.variable} ${plexMono.variable} antialiased`}
Expand Down
216 changes: 216 additions & 0 deletions src/lib/recover.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { describe, expect, it } from "vitest";
import {
RECOVERY_LIMIT,
RECOVERY_SCRIPT,
RECOVERY_WINDOW_MS,
isChunkError,
readHistory,
recoveryPlan,
} from "./recover";

describe("isChunkError", () => {
/* 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("</script");
});
});

/*
* The script is serialised source, and source that was never run is source
* that has never been checked. These run it in a sandbox with the browser it
* expects stubbed out, which is what catches a typo in the part that only
* exists as a string.
*/
describe("the inline script, run", () => {
async function boot(stored: string | null = null) {
const { runInNewContext } = await import("node:vm");
const listeners: Record<string, ((e: unknown) => 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<string, unknown>;
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);
});
});
Loading
Loading