fix(pwa): stop serving the stale shell after deploys (#11779)

Fixes the stale-shell PWA lockout after a deploy: navigationFallback now returns
Response.error() instead of replaying a cached shell whose /_next/static chunk
references are dead, and the worker is registered as /sw.js?v=<build-id> so each
deploy is actually observed instead of never updating until a navigation to the
new build first succeeds.

Recreated onto release/v3.8.51 (original base was main, which had diverged too far
for a clean retarget) — both commits cherry-picked and force-pushed to the
contributor's branch (author preserved), then the PR's base edited in place.
4/4 focused tests passing (2 via vitest for the jsdom-environment PwaRegister
suite, 2 via node:test for the service-worker fallback suite). Thanks for the fix!
This commit is contained in:
Bob.Hou
2026-08-28 11:23:35 -04:00
committed by GitHub
parent 495d10de18
commit cab9cdc765
6 changed files with 75 additions and 31 deletions

View File

@@ -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=<id> 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

View File

@@ -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/<old-build-id>/ 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 ───────────────────────────────────────────────────────

View File

@@ -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

View File

@@ -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/<old-build>/ 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;

View File

@@ -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();
});
});

View File

@@ -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/<old-build>/ 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");
});