diff --git a/ui/public/sw.js b/ui/public/sw.js index f90d12156f..e5997304dc 100644 --- a/ui/public/sw.js +++ b/ui/public/sw.js @@ -32,11 +32,16 @@ self.addEventListener("fetch", (event) => { } return response; }) - .catch(() => { + .catch(async () => { + // caches.match() resolves undefined on a miss (and the promise itself + // is always truthy, so `||` can never supply a fallback). respondWith + // must always receive a real Response — resolving undefined breaks + // the navigation with "Failed to convert value to 'Response'" instead + // of showing anything. if (request.mode === "navigate") { - return caches.match("/") || new Response("Offline", { status: 503 }); + return (await caches.match("/")) ?? new Response("Offline", { status: 503 }); } - return caches.match(request); + return (await caches.match(request)) ?? Response.error(); }) ); }); diff --git a/ui/src/lib/sw-offline-fallback.test.ts b/ui/src/lib/sw-offline-fallback.test.ts new file mode 100644 index 0000000000..ab2b86ca09 --- /dev/null +++ b/ui/src/lib/sw-offline-fallback.test.ts @@ -0,0 +1,110 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, vi } from "vitest"; + +const uiRoot = resolve(fileURLToPath(new URL("../..", import.meta.url))); + +type FetchListener = (event: { + request: { method: string; url: string; mode: string }; + respondWith: (response: Promise | Response) => void; +}) => void; + +function loadServiceWorkerFetchListener(overrides: { + fetch: () => Promise; + cachesMatch: (key: unknown) => Promise; +}): FetchListener { + const listeners = new Map void>(); + const swSelf = { + addEventListener: (type: string, listener: (event: unknown) => void) => { + listeners.set(type, listener); + }, + skipWaiting: vi.fn(), + clients: { claim: vi.fn() }, + location: { origin: "https://app.example.com" }, + }; + const caches = { + match: overrides.cachesMatch, + open: vi.fn(async () => ({ put: vi.fn() })), + keys: vi.fn(async () => []), + delete: vi.fn(async () => true), + }; + const code = readFileSync(resolve(uiRoot, "public/sw.js"), "utf8"); + new Function("self", "caches", "fetch", "Response", "URL", code)( + swSelf, + caches, + overrides.fetch, + Response, + URL, + ); + const listener = listeners.get("fetch"); + if (!listener) throw new Error("sw.js registered no fetch listener"); + return listener as FetchListener; +} + +async function respondTo( + listener: FetchListener, + request: { method: string; url: string; mode: string }, +): Promise { + let captured: Promise | Response | undefined; + listener({ + request, + respondWith: (response) => { + captured = response; + }, + }); + return await captured; +} + +describe("sw.js offline fallback", () => { + it("serves the Offline response for a failed navigation with an empty cache", async () => { + const listener = loadServiceWorkerFetchListener({ + fetch: () => Promise.reject(new TypeError("network down")), + cachesMatch: async () => undefined, + }); + + const response = await respondTo(listener, { + method: "GET", + url: "https://app.example.com/settings/instance", + mode: "navigate", + }); + + // respondWith must always receive a real Response; resolving undefined + // fails the navigation with "Failed to convert value to 'Response'". + expect(response).toBeInstanceOf(Response); + expect(response!.status).toBe(503); + expect(await response!.text()).toBe("Offline"); + }); + + it("serves the cached shell for a failed navigation when one exists", async () => { + const shell = new Response("app shell", { status: 200 }); + const listener = loadServiceWorkerFetchListener({ + fetch: () => Promise.reject(new TypeError("network down")), + cachesMatch: async (key) => (key === "/" ? shell : undefined), + }); + + const response = await respondTo(listener, { + method: "GET", + url: "https://app.example.com/settings/instance", + mode: "navigate", + }); + + expect(response).toBe(shell); + }); + + it("returns a network-error Response for a failed asset with no cache entry", async () => { + const listener = loadServiceWorkerFetchListener({ + fetch: () => Promise.reject(new TypeError("network down")), + cachesMatch: async () => undefined, + }); + + const response = await respondTo(listener, { + method: "GET", + url: "https://app.example.com/assets/index-abc.js", + mode: "no-cors", + }); + + expect(response).toBeInstanceOf(Response); + expect(response!.type).toBe("error"); + }); +});