Files
OmniRoute/tests/unit/service-worker-navigation-fallback.test.ts
Bob.Hou cab9cdc765 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!
2026-08-28 12:23:35 -03:00

130 lines
4.2 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import vm from "node:vm";
type FetchEvent = {
request: RequestLike;
respondWith: (response: Promise<Response>) => void;
};
type ServiceWorkerEvent = FetchEvent | Record<string, unknown>;
type RequestLike = {
url: string;
method: string;
mode: string;
destination: string;
};
function createServiceWorkerHarness() {
const cacheEntries = new Map<string, Response>();
const listeners = new Map<string, (event: ServiceWorkerEvent) => void>();
let fetchImpl: (request: RequestLike) => Promise<Response> = async () => {
throw new Error("network unavailable");
};
const cache = {
addAll: async (urls: string[]) => {
for (const url of urls) {
cacheEntries.set(url, new Response(`cached ${url}`, { status: 200 }));
}
},
delete: async (request: Request) => cacheEntries.delete(request.url),
keys: async () => [...cacheEntries.keys()].map((url) => new Request(url)),
put: async (request: Request, response: Response) => {
cacheEntries.set(request.url, response);
},
};
const caches = {
delete: async () => true,
keys: async () => ["omniroute-pwa-v2"],
match: async (request: Request | string) =>
cacheEntries.get(typeof request === "string" ? request : request.url),
open: async () => cache,
};
const context = vm.createContext({
URL,
Request,
Response,
caches,
fetch: (request: RequestLike) => fetchImpl(request),
self: {
clients: { claim: async () => undefined },
location: { href: "https://app.example/sw.js", origin: "https://app.example" },
registration: { showNotification: async () => undefined },
skipWaiting: async () => undefined,
addEventListener: (type: string, listener: (event: ServiceWorkerEvent) => void) => {
listeners.set(type, listener);
},
},
});
vm.runInContext(readFileSync("public/sw.js", "utf8"), context);
return {
cacheEntries,
dispatchFetch: async (request: RequestLike) => {
const listener = listeners.get("fetch");
assert.ok(listener, "fetch listener must be registered");
let responsePromise: Promise<Response> | undefined;
const event: FetchEvent = {
request,
respondWith: (response) => {
responsePromise = response;
},
};
listener(event);
assert.ok(responsePromise, "navigate request must call respondWith");
return responsePromise;
},
setFetch: (nextFetch: (request: Request) => Promise<Response>) => {
fetchImpl = nextFetch;
},
};
}
test("#11779: network failure on navigation surfaces an error, not a stale shell", async () => {
const harness = createServiceWorkerHarness();
const request = {
url: "https://app.example/dashboard",
method: "GET",
mode: "navigate",
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 }));
const response = await harness.dispatchFetch(request);
assert.ok(response.type === "error", "navigation fallback must surface the network failure");
});
test("#5165: successful navigations are cached for later transient failures", async () => {
const harness = createServiceWorkerHarness();
const request = {
url: "https://app.example/dashboard",
method: "GET",
mode: "navigate",
destination: "document",
};
harness.setFetch(async () => new Response("fresh dashboard", { status: 200 }));
assert.equal(await (await harness.dispatchFetch(request)).text(), "fresh dashboard");
harness.setFetch(async () => {
throw new Error("transient network failure");
});
// #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");
});