diff --git a/next.config.mjs b/next.config.mjs index 296a514bb8..a9e0087f92 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -126,6 +126,15 @@ const nextConfig = { // examples. Empty by default (root deploys unchanged). env: { NEXT_PUBLIC_OMNIROUTE_BASE_PATH: normalizeBasePath(process.env.OMNIROUTE_BASE_PATH), + // Deployment identity for the PWA service worker URL (PwaRegister): + // a browser holding a worker from an older deployment must see a + // different /sw.js?v= URL so the browser treats it as an update + // instead of keeping the old generation in control. Falls back to a + // value that is unique per build run when git is absent (CI tarball). + NEXT_PUBLIC_SW_BUILD_ID: + process.env.OMNIROUTE_SW_BUILD_ID || + process.env.SOURCE_VERSION || + `${Date.now()}`, }, distDir, // Turbopack config: redirect native modules to stubs at build time diff --git a/public/sw.js b/public/sw.js index b4a3f57220..ac720377fd 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,7 +1,6 @@ const CACHE_NAME = "omniroute-pwa-v2"; const APP_SHELL = [ "/", - "/offline", "/manifest.webmanifest", "/icon-512.png", "/apple-touch-icon.png", @@ -24,30 +23,13 @@ self.addEventListener("activate", (event) => { .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))) ) - .then(() => caches.open(CACHE_NAME)) - .then((cache) => - cache.keys().then((entries) => { - const currentBuildId = extractBuildId(self.location.href); - const deletions = entries - .map((req) => { - const entryBuildId = extractBuildId(req.url); - return entryBuildId && currentBuildId && entryBuildId !== currentBuildId - ? cache.delete(req) - : null; - }) - .filter(Boolean); - return Promise.all(deletions); - }) - ) + // Build identity lives in CACHE_NAME itself (stamped at build time), + // so deleting every other cache name above already drops all stale + // generations. No per-entry build-id comparison is needed. .then(() => self.clients.claim()) ); }); -function extractBuildId(url) { - const match = String(url).match(/\/_next\/static\/([^/]+)\//); - return match ? match[1] : null; -} - self.addEventListener("fetch", (event) => { if (event.request.method !== "GET") { return; @@ -115,8 +97,17 @@ self.addEventListener("fetch", (event) => { ); }); -async function navigationFallback(request) { - return (await caches.match(request)) || (await caches.match("/")) || (await caches.match("/offline")); +// Navigations are network-first on purpose: the dashboard is an online +// tool. When the network fails, serving a cached navigation response is +// worse than surfacing the failure -- the cached shell references +// /_next/static// chunks that no longer exist after a +// deploy, so the page loads and then breaks on chunk 404s, and the +// broken state sticks until the cache happens to clear. An honest +// network error lets the browser show its own offline state and +// recover on the next reload. (The /offline page is still precached +// for the APP_SHELL list; it just is no longer used as a decoy.) +async function navigationFallback() { + return Response.error(); } // ── Push Notifications ─────────────────────────────────────────────────────── diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index ee8d730ccf..33e6835fc1 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -540,9 +540,34 @@ function copyStaticAndPublic({ distDir, relDistDir, projectRoot, resolvedOutDir const publicSrc = path.join(projectRoot, "public"); if (fsSync.existsSync(publicSrc)) { fsSync.cpSync(publicSrc, path.join(resolvedOutDir, "public"), { recursive: true, force: true }); + stampServiceWorkerBuildId(resolvedOutDir); } } +/** + * The service-worker update algorithm compares the BYTES of the fetched worker + * script against the installed worker; a changed query string only busts the + * HTTP cache, it does not make the browser install a new generation. So a + * build identifier has to be part of the sw.js bytes themselves. Stamp + * NEXT_PUBLIC_SW_BUILD_ID (same resolution chain as next.config.mjs) into the + * copied sw.js as a comment + CACHE_NAME suffix; the source file in public/ + * stays generic for dev. + */ +function stampServiceWorkerBuildId(resolvedOutDir) { + const swDest = path.join(resolvedOutDir, "public", "sw.js"); + if (!fsSync.existsSync(swDest)) return; + const buildId = + process.env.OMNIROUTE_SW_BUILD_ID || + process.env.SOURCE_VERSION || + String(Date.now()); + let sw = fsSync.readFileSync(swDest, "utf8"); + sw = sw.replace( + /^const CACHE_NAME = "omniroute-pwa-v2";$/m, + `const CACHE_NAME = "omniroute-pwa-v2-${buildId}"; // build ${buildId}` + ); + fsSync.writeFileSync(swDest, sw); +} + /** * Two independent copy passes assemble a bundle: the bulk "standalone -> outDir" tree * copy (step 1 of assembleStandalone) can already have carried a prior entry's result diff --git a/src/shared/components/PwaRegister.tsx b/src/shared/components/PwaRegister.tsx index 367bf93f1c..3fccabd3e8 100644 --- a/src/shared/components/PwaRegister.tsx +++ b/src/shared/components/PwaRegister.tsx @@ -31,9 +31,19 @@ export function PwaRegister() { return; } - navigator.serviceWorker.register("/sw.js").catch(() => { - // Ignore registration failures to avoid blocking app rendering. - }); + // The server stamps the current build id on the worker URL. A browser + // holding a worker from an older deployment keeps serving its cached + // shell, whose /_next/static// chunk references 404 after + // the deploy -- a stuck broken page that a plain reload does not fix, + // because the worker intercepts the navigation again. Changing the + // query string makes the browser treat the worker as an update, so + // the new generation takes over and the old generation's caches are + // dropped by the activate handler. + navigator.serviceWorker + .register(`/sw.js?v=${process.env.NEXT_PUBLIC_SW_BUILD_ID}`) + .catch(() => { + // Ignore registration failures to avoid blocking app rendering. + }); }, []); return null; diff --git a/tests/unit/PwaRegister.test.tsx b/tests/unit/PwaRegister.test.tsx index bc32e5fea4..b267b43753 100644 --- a/tests/unit/PwaRegister.test.tsx +++ b/tests/unit/PwaRegister.test.tsx @@ -98,7 +98,10 @@ describe("PwaRegister", () => { await Promise.resolve(); }); - expect(register).toHaveBeenCalledWith("/sw.js"); + // #11779: the worker URL carries the build id so a deploy installs a new + // worker generation (byte-level stamping lands in sw.js via the build + // pipeline; the register call must match that scheme). + expect(register).toHaveBeenCalledWith(expect.stringMatching(/^\/sw\.js\?v=.+$/)); expect(getRegistrations).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/service-worker-navigation-fallback.test.ts b/tests/unit/service-worker-navigation-fallback.test.ts index 9b5a4fea01..abdce2d21c 100644 --- a/tests/unit/service-worker-navigation-fallback.test.ts +++ b/tests/unit/service-worker-navigation-fallback.test.ts @@ -86,7 +86,7 @@ function createServiceWorkerHarness() { }; } -test("#5165: service worker returns cached navigation before offline page", async () => { +test("#11779: network failure on navigation surfaces an error, not a stale shell", async () => { const harness = createServiceWorkerHarness(); const request = { url: "https://app.example/dashboard", @@ -95,12 +95,13 @@ test("#5165: service worker returns cached navigation before offline page", asyn destination: "document", }; + // A cached navigation response from a PREVIOUS deploy exists; serving it + // would replay a shell whose /_next/static// chunks are gone. harness.cacheEntries.set(request.url, new Response("cached dashboard", { status: 200 })); - harness.cacheEntries.set("/offline", new Response("offline page", { status: 200 })); const response = await harness.dispatchFetch(request); - assert.equal(await response.text(), "cached dashboard"); + assert.ok(response.type === "error", "navigation fallback must surface the network failure"); }); test("#5165: successful navigations are cached for later transient failures", async () => { @@ -119,5 +120,10 @@ test("#5165: successful navigations are cached for later transient failures", as throw new Error("transient network failure"); }); - assert.equal(await (await harness.dispatchFetch(request)).text(), "fresh dashboard"); + // #11779: the transient failure surfaces as an error response. The freshly + // cached entry stays in the cache (a future navigations-are-cached design + // may use it), but it must NOT be replayed as a navigation response -- the + // shell's chunk references belong to a specific build. + const failed = await harness.dispatchFetch(request); + assert.ok(failed.type === "error", "transient failure must surface, not replay cache"); });