diff --git a/public/sw.js b/public/sw.js index e56c47a381..b4a3f57220 100644 --- a/public/sw.js +++ b/public/sw.js @@ -72,9 +72,14 @@ self.addEventListener("fetch", (event) => { (async () => { if (isNavigateRequest) { try { - return await fetch(event.request); + const networkResponse = await fetch(event.request); + if (networkResponse && networkResponse.status === 200) { + const responseClone = networkResponse.clone(); + void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone)); + } + return networkResponse; } catch { - return (await caches.match("/offline")) || Response.error(); + return (await navigationFallback(event.request)) || Response.error(); } } @@ -110,6 +115,10 @@ self.addEventListener("fetch", (event) => { ); }); +async function navigationFallback(request) { + return (await caches.match(request)) || (await caches.match("/")) || (await caches.match("/offline")); +} + // ── Push Notifications ─────────────────────────────────────────────────────── self.addEventListener("push", (event) => { diff --git a/tests/unit/service-worker-navigation-fallback.test.ts b/tests/unit/service-worker-navigation-fallback.test.ts new file mode 100644 index 0000000000..9b5a4fea01 --- /dev/null +++ b/tests/unit/service-worker-navigation-fallback.test.ts @@ -0,0 +1,123 @@ +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) => void; +}; + +type ServiceWorkerEvent = FetchEvent | Record; + +type RequestLike = { + url: string; + method: string; + mode: string; + destination: string; +}; + +function createServiceWorkerHarness() { + const cacheEntries = new Map(); + const listeners = new Map void>(); + let fetchImpl: (request: RequestLike) => Promise = 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 | 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) => { + fetchImpl = nextFetch; + }, + }; +} + +test("#5165: service worker returns cached navigation before offline page", async () => { + const harness = createServiceWorkerHarness(); + const request = { + url: "https://app.example/dashboard", + method: "GET", + mode: "navigate", + destination: "document", + }; + + 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"); +}); + +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"); + }); + + assert.equal(await (await harness.dispatchFetch(request)).text(), "fresh dashboard"); +});