mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-18 04:42:30 +03:00
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:
@@ -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
|
||||
|
||||
37
public/sw.js
37
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/<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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user