From 5157d0a908929d6ac961d83b62a2ba03a217f0ec Mon Sep 17 00:00:00 2001 From: suskozaver Date: Thu, 17 Sep 2026 19:09:54 +0200 Subject: [PATCH] fix: an app whose shell no longer matches the server repairs itself Reported after a deploy: a white screen, "Application error: a client-side exception", and ChunkLoadError: Loading chunk 135 failed. The hashes said what happened. The browser was running webpack-55676dfde347fa72.js where the new build has webpack-9100694e6ef91c55.js, so the runtime came out of the service worker's cache. It asked for 135-d5de4c531d4e06a7.js and the new build calls that chunk 135-b4d0965ad68280be.js, so the request 404ed. The neighbouring 255-3d881dfa8c72bc56.js loaded fine, because that chunk had not changed between the builds and kept its name. Half old and half new, which is the exact thing the per-build cache exists to prevent. It does prevent it, for files it holds. What it cannot prevent is a chunk it never held. The cache fills by visiting, chunks are lazy, and a deploy takes the old ones off the server, so from that moment any chunk the old shell had not already cached is gone for good. The way out is the update prompt, and the update prompt lives inside the app that cannot boot. So this is a way out that does not live there. An inline script in the head, ahead of the app's own code, listening for that failure and only that one: drop the caches, unregister the worker, reload once. Explicit updates stay, because they are a good decision. This speaks up only when the shell is already broken and there is nothing left to protect. Twice in ten minutes and it stops. 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. Inline rather than a component, because by the time React could mount a component the chunk it needs may be the missing one. And serialised from the real functions with toString() rather than the same rules typed out again as a string, because two copies of a rule is how one of them gets fixed. Eighteen tests, five of which run the serialised source in a sandbox with the browser stubbed out, since source that has never been run has never been checked. Verified in the built output: the listener is in the HTML of every prerendered page. --- document/06-traps.md | 40 ++++++++ src/app/layout.tsx | 6 ++ src/lib/recover.test.ts | 216 ++++++++++++++++++++++++++++++++++++++++ src/lib/recover.ts | 148 +++++++++++++++++++++++++++ 4 files changed, 410 insertions(+) create mode 100644 src/lib/recover.test.ts create mode 100644 src/lib/recover.ts 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 }) */}