Two defects behind "the new version is ready" not going away.
1. THE WORKER NEVER INSTALLED — in any environment, ever.
install did `cache.addAll([...PRECACHE, SHELL])`. PRECACHE is derived from
the build's own outputs, which already contain index.html, and SHELL is
index.html again. addAll() rejects the ENTIRE install on a duplicate
request:
InvalidStateError: Cache.addAll(): duplicate requests (.../index.html)
So install failed on every load, the worker went redundant, and nothing
ever activated. Registration itself succeeds and the failure is inside the
install event, so nothing surfaced it — which is why offline behaviour was
listed as "never verified" rather than "broken". All 11 precached URLs
return 200; the list was the only fault. Deduping now lives in
src/swPrecache.ts, at the point of use, so no future addition to either
list can reintroduce it.
Verified in a real browser for the first time: the worker installs,
activates, claims the page, and caches 11 entries. A rebuild is then
detected, the prompt appears, clicking Reload applies it, stale caches are
dropped and the prompt does not return.
2. THE DEV WORKER IGNORED THE LIFECYCLE — my own regression, from 53707b1.
That commit made the dev server serve an inert sw.js to silence a MIME
error. Inert included the lifecycle: no skipWaiting, no clients.claim, no
message handler. A worker like that can sit in `waiting` forever and can
never be applied, so the prompt returns on every load and the Reload button
cannot possibly clear it. This is the likeliest cause of the reported
symptom, since with defect 1 a production build could never have shown that
prompt at all. The dev worker still caches nothing — that would fight HMR —
but now honours install, activate and SKIP_WAITING.
3. applyUpdate RACED ITS OWN RELOAD — real, measured, but not reproducible on
demand, so it is hardening rather than a proven fix.
It posted SKIP_WAITING and called window.location.reload() on the next
line. Measured in Chrome: at that instant the new worker was still
`installed` and the controller had NOT changed — it changed 2 ms later. The
window is wider in practice because activate deletes every stale cache
before claiming clients. When the reload wins, the reloaded page still finds
registration.waiting and shows the prompt again. It now waits for
controllerchange, with a timeout backstop so a worker that ignores
SKIP_WAITING cannot leave the button dead. The sequencing is pure and
tested (src/ui/swUpdate.ts): listener attached before the message, reload
exactly once, unsubscribed after.
Tooling: scripts/screenshot.ts gains --profile (a fresh Chrome profile has no
registered worker, so nothing stateful across loads could be reproduced at all)
and --eval-after-file (observe a flow that ends in a navigation from the other
side of it).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
78 lines
3.2 KiB
TypeScript
78 lines
3.2 KiB
TypeScript
import { serve } from "bun";
|
|
import index from "./index.html";
|
|
import { normalizeBase } from "./ui/basePath.ts";
|
|
import { buildManifest } from "./manifest.ts";
|
|
import { FONT_FILES } from "./fontCss.ts";
|
|
|
|
const basePath = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH);
|
|
|
|
const server = serve({
|
|
routes: {
|
|
// index.html references both of these with a plain <link href>. Without
|
|
// explicit routes the "/*" catch-all below answers them with the HTML
|
|
// shell, so the manifest arrives as text/html (a parse error, and no
|
|
// install prompt) and the icon never loads. Generated rather than served
|
|
// from a committed file so the dev manifest cannot drift from the built
|
|
// one — a stale copy is exactly how it came to hardcode scope "/".
|
|
[`${basePath}manifest.webmanifest`]: () =>
|
|
new Response(buildManifest(basePath), {
|
|
headers: { "content-type": "application/manifest+json" },
|
|
}),
|
|
[`${basePath}icon.svg`]: () =>
|
|
new Response(Bun.file(new URL("./icon.svg", import.meta.url)), {
|
|
headers: { "content-type": "image/svg+xml" },
|
|
}),
|
|
[`${basePath}icon-192.png`]: () =>
|
|
new Response(Bun.file(new URL("./icon-192.png", import.meta.url)), {
|
|
headers: { "content-type": "image/png" },
|
|
}),
|
|
[`${basePath}icon-512.png`]: () =>
|
|
new Response(Bun.file(new URL("./icon-512.png", import.meta.url)), {
|
|
headers: { "content-type": "image/png" },
|
|
}),
|
|
// registerServiceWorker() asks for sw.js on every boot. Without this the
|
|
// catch-all answers with the HTML shell and the browser logs
|
|
// "The script has an unsupported MIME type ('text/html')" — caught and
|
|
// harmless, but noise that hides real errors. A worker that claims no
|
|
// clients and handles no fetches keeps registration honest in dev without
|
|
// caching anything, which would fight HMR.
|
|
[`${basePath}sw.js`]: () =>
|
|
new Response(
|
|
// Caches nothing — that would fight HMR — but it DOES honour the
|
|
// lifecycle, so the update prompt's Reload button behaves in dev the
|
|
// way it behaves in production instead of only ever hitting the
|
|
// timeout backstop.
|
|
[
|
|
"/* dev worker: no caching, real lifecycle */",
|
|
'self.addEventListener("install", () => self.skipWaiting());',
|
|
'self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));',
|
|
'self.addEventListener("message", (e) => {',
|
|
' if (e.data && e.data.type === "SKIP_WAITING") self.skipWaiting();',
|
|
"});",
|
|
"",
|
|
].join("\n"),
|
|
{
|
|
headers: { "content-type": "text/javascript", "cache-control": "no-cache" },
|
|
},
|
|
),
|
|
// The @font-face rules are injected by frontend.tsx; these serve the
|
|
// files those rules point at.
|
|
...Object.fromEntries(
|
|
FONT_FILES.map((file) => [
|
|
`${basePath}fonts/${file}`,
|
|
() =>
|
|
new Response(Bun.file(new URL(`./fonts/${file}`, import.meta.url)), {
|
|
headers: { "content-type": "font/woff2", "cache-control": "no-cache" },
|
|
}),
|
|
]),
|
|
),
|
|
"/*": index,
|
|
},
|
|
development: process.env.NODE_ENV !== "production" && {
|
|
hmr: true,
|
|
console: true,
|
|
},
|
|
});
|
|
|
|
console.log(`Dev server running at ${server.url}`);
|