fix(pwa): retire the service worker instead of just fixing it
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 35s
CI / tests-integration (push) Successful in 1m58s
CI / tests-unit (push) Successful in 2m22s
CI / tests-ui (push) Successful in 2m50s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m50s

The previous commit stopped the worker from caching build output, which
removed the cause of the blank Catalog Studio but left the worker itself
serving a purpose worth one offline fallback: three public endpoints
(/api/home, /api/online, /api/radio/config). Behind Cloudflare, for a site
whose visitors are online, that fallback rarely fires and goes stale exactly
where it is most likely to matter.

So the worker goes away rather than staying as a mostly-inert layer that every
future release still has to keep correct.

public/sw.js is not deleted, because deleting it would leave every existing
registration alive and still in control of the page, caching as it did before.
It becomes the opposite of what it was: on activate it deletes every cache,
unregisters itself and reloads open clients so they stop being controlled.
Browsers that already installed a worker therefore uninstall it on their next
visit; browsers that never had one are unaffected.

src/components/pwa-register.tsx is removed and unmounted from the root
layout, so nothing registers a worker any more and the kill switch only ever
runs for the visitors who need it.

The manifest is deliberately untouched. src/app/manifest.ts is an ordinary
Next.js route, independent of any worker, and Chrome installs from a manifest
alone, so the site stays installable on a phone.

Verified nothing depended on it: no other navigator.serviceWorker or caches.*
reference exists in the app, the theme runs from the plain /scripts/
theme-init.js script, and no Studio, catalog or import module touches a
worker. Typecheck and lint clean, 2326 unit tests and 72 UI tests green,
including every Studio and catalog spec.
This commit is contained in:
openhands committed 2026-10-11 17:31:15 +02:00
1 parent 1a4888df50
commit 990ebdb158
3 files changed
+30 -95

No files matched your search

+30 -65
View File
@@ -1,10 +1,26 @@
// 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";
// Retired service worker — this file no longer serves anything.
//
// The app used to register a worker that cached /_next/static/ and /assets/
// cache-first under a cache name with no build id. A release could not
// invalidate it, so browsers replayed the previous release's chunks against
// the new HTML and React never hydrated, which is what left the Catalog
// Studio on a blank white page. It also bought nothing: nginx already sends
// those two prefixes as `max-age=31536000, immutable` and their filenames are
// content-hashed, so the HTTP cache already handles them correctly.
//
// This file must stay at /sw.js and keep its install/activate handlers.
// Simply deleting it would leave every existing registration alive and in
// control of the page, with the old caching still running. Instead this
// worker does the opposite of what it used to: it deletes every cache and
// unregisters itself, then reloads open clients so they stop being
// controlled by it.
//
// Nothing registers it any more (see src/app/layout.tsx), so this only ever
// runs for browsers that already have a worker installed. Once a visitor
// loads the site again it uninstalls itself and never comes back.
//
// The manifest is unrelated and untouched — it is generated by
// src/app/manifest.ts and keeps the site installable without a worker.
self.addEventListener("install", () => self.skipWaiting());
@@ -12,64 +28,13 @@ self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((k) => k !== CACHE && k !== API_CACHE)
.map((k) => caches.delete(k)),
),
.then((keys) => Promise.all(keys.map((key) => caches.delete(key))))
.then(() => self.registration.unregister())
.then(() =>
self.clients.matchAll({ type: "window", includeUncontrolled: true }),
)
.then(() => self.clients.claim()),
.then((clients) => {
for (const client of clients) client.navigate(client.url);
}),
);
});
// 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;
// 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") ||
url.pathname.startsWith("/api/radio/config")
) {
event.respondWith(
caches.open(API_CACHE).then((cache) =>
fetch(req)
.then((res) => {
if (res.ok) cache.put(req, res.clone());
return res;
})
.catch(() => cache.match(req)),
),
);
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
View File
@@ -7,7 +7,6 @@ import { getLocale, getMessages } from "next-intl/server";
import { type ReactNode, Suspense } from "react";
import { GlobalProgressBar } from "@/components/global-progress-bar";
import { PwaRegister } from "@/components/pwa-register";
import { ScopedThemeVars } from "@/components/scoped-theme-vars";
import { ThemeScopeDetector } from "@/components/theme-scope-detector";
import { ThemeVars } from "@/components/theme-vars";
@@ -110,7 +109,6 @@ export default async function RootLayout({
<ScopedThemeVars nonce={nonce} />
<ThemeScopeDetector />
{children}
<PwaRegister />
</NextIntlClientProvider>
</body>
</html>
-28
View File
@@ -1,28 +0,0 @@
"use client";
import { useEffect } from "react";
// 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(() => {
if (typeof navigator === "undefined" || !("serviceWorker" in navigator))
return;
navigator.serviceWorker.getRegistrations().then((regs) => {
for (const reg of regs) {
reg.unregister();
}
});
navigator.serviceWorker
.register(`/sw.js?${SW_VERSION}`)
.catch((error) =>
console.error("[PWA] Service worker registration failed:", error),
);
}, []);
return null;
}