fix(pwa): stop the service worker replaying chunks from a previous release
Gitea Actions Runner Test / test-job (push) Successful in 3s
CI / check (push) Successful in 32s
CI / tests-integration (push) Successful in 2m11s
CI / tests-unit (push) Successful in 2m16s
CI / tests-ui (push) Successful in 2m54s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m47s
Gitea Actions Runner Test / test-job (push) Successful in 3s
CI / check (push) Successful in 32s
CI / tests-integration (push) Successful in 2m11s
CI / tests-unit (push) Successful in 2m16s
CI / tests-ui (push) Successful in 2m54s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m47s
The Catalog Studio rendered as a blank white page after a deploy, while the server was serving it correctly: /admin/studio/furni answered 200 with 110KB of HTML and every API it calls answered 200 with data. No script chunk 404ed during the session and no JavaScript threw, so the failure was entirely in what the browser chose to execute. The service worker wrapped /assets/ and /_next/static/ in a Cache Storage entry served cache-first, under a hardcoded name (atom-v3) with no build id and no revalidation. A release therefore could not invalidate it: the browser kept replaying the previous release's chunks against the new HTML, and React never hydrated. The chunk branch also had no .catch(), unlike the API branch below it, so a rejected fetch silently dropped the <script> instead of surfacing a network error the browser could retry. That cache also bought nothing. nginx already sends /_next/static/ and /assets/ as `max-age=31536000, immutable`, and those filenames are content-hashed, so the HTTP cache is both sufficient and safe — a changed file gets a new name. The worker was the only layer able to go stale across a deploy, and it was the one doing it. Both prefixes now fall through to the network and are left to the HTTP cache. The API offline fallback and network-first navigations are unchanged, and the cache names move to v4 so an existing worker is replaced. SW_VERSION moves with them: it is part of the registration URL, so without the bump the browser never refetches sw.js and the old worker keeps running. Unrelated but confirmed while tracing this: /assets/images/themes/arctic-ice.png is requested by the browser and 404s, but that string exists nowhere in the code, the database or the build. It was a stale reference served out of this same cache, not a missing asset.
This commit is contained in:
1 parent
2978483ea9
commit
1a4888df50
2 files changed
+33
-22
No files matched your search
+29
-21
@@ -1,5 +1,10 @@
|
||||
const CACHE = "atom-v3";
|
||||
const API_CACHE = "atom-api-v3";
|
||||
// Bumped to v4. The v3 worker cached /_next/static/ and /assets/ cache-first
|
||||
// under a hardcoded cache name that was not tied to the build, so a release
|
||||
// left the browser replaying the previous release's chunks against the new
|
||||
// HTML. That combination renders a blank page. The cache name is the version,
|
||||
// so it must move whenever this behaviour changes.
|
||||
const CACHE = "atom-v4";
|
||||
const API_CACHE = "atom-api-v4";
|
||||
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
|
||||
@@ -18,31 +23,32 @@ self.addEventListener("activate", (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
// Static build output is NOT cached here on purpose.
|
||||
//
|
||||
// nginx already sends `/_next/static/` and `/assets/` with
|
||||
// `max-age=31536000, immutable`. Those filenames are content-hashed, so the
|
||||
// HTTP cache is both sufficient and safe: a changed file gets a new name.
|
||||
// Wrapping them in a Cache Storage entry bought nothing and introduced a
|
||||
// staleness bug the HTTP cache cannot have — the cache name had no build id,
|
||||
// so a release kept serving the previous release's chunks.
|
||||
//
|
||||
// Letting these requests fall through also means a failed fetch surfaces as a
|
||||
// normal network error the browser can retry, instead of a rejected
|
||||
// respondWith() that silently drops the <script> and leaves React unhydrated.
|
||||
const isStaticBuildOutput = (pathname) =>
|
||||
pathname.startsWith("/assets/") ||
|
||||
pathname.startsWith("/_next/static/") ||
|
||||
pathname.startsWith("/_next/data/");
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
if (isStaticBuildOutput(url.pathname)) return;
|
||||
|
||||
if (
|
||||
url.pathname.startsWith("/assets/") ||
|
||||
url.pathname.startsWith("/_next/static/")
|
||||
) {
|
||||
event.respondWith(
|
||||
caches.open(CACHE).then((cache) =>
|
||||
cache.match(req).then(
|
||||
(hit) =>
|
||||
hit ||
|
||||
fetch(req).then((res) => {
|
||||
if (res.ok) cache.put(req, res.clone());
|
||||
return res;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Offline tolerance for the few endpoints the public shell needs to render
|
||||
// at all. Network-first, so a deploy is never shadowed by this cache.
|
||||
if (
|
||||
url.pathname.startsWith("/api/home") ||
|
||||
url.pathname.startsWith("/api/online") ||
|
||||
@@ -61,6 +67,8 @@ self.addEventListener("fetch", (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Admin pages are never cached (nginx sends no-store once a session cookie
|
||||
// is present). Always network-first; offline is the browser's call.
|
||||
if (req.mode === "navigate") {
|
||||
event.respondWith(fetch(req));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
const SW_VERSION = "3";
|
||||
// Must move together with the caches in public/sw.js. It is part of the
|
||||
// registration URL, so an unchanged value means the browser never refetches
|
||||
// sw.js and keeps running the previous worker.
|
||||
const SW_VERSION = "4";
|
||||
|
||||
export function PwaRegister() {
|
||||
useEffect(() => {
|
||||
|
||||
Reference in new issue
Block a user