From 9ce7927627792b9205d32f192f532b7b02fa5d9e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:49:14 +0100 Subject: [PATCH] fix: keep the old build's offline files from coming back after a deploy #523 --- lib/__tests__/sw-build-caches.test.ts | 30 +++++++++++++++++++++++++++ public/sw.js | 16 +++++++++----- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/lib/__tests__/sw-build-caches.test.ts b/lib/__tests__/sw-build-caches.test.ts index 0350c5c6c..ddbfa0587 100644 --- a/lib/__tests__/sw-build-caches.test.ts +++ b/lib/__tests__/sw-build-caches.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { FakeCaches, loadWorker } from './sw-harness'; +const settle = () => new Promise((r) => setTimeout(r, 0)); + // The service worker names its caches after the build it came with (#523) // and deletes a build's cache once no open tab runs that build. describe('service worker caches per build', () => { @@ -45,4 +47,32 @@ describe('service worker caches per build', () => { await worker.dispatch('message', { data: { type: 'skip-waiting' } }); expect(worker.skipped()).toBe(before + 1); }); + + it("the old build's worker doesn't bring back its cache after the new build deleted it", async () => { + const caches = new FakeCaches(); + const state = await caches.open('bulwark-sw-state-v1'); + await state.put('/__bulwark/offline-shell', new Response(JSON.stringify({ on: true }))); + let online = true; + // The server runs build b now and says so on every answer. + const fetchFromB = async () => { + if (!online) throw new TypeError('Failed to fetch'); + const res = new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json', 'X-Bulwark-Build': 'b' } }); + Object.defineProperty(res, 'type', { value: 'basic' }); + return res; + }; + const old = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchFromB }); + await settle(); + // Build b's worker took over and deleted build a's files, while the + // reloaded tab's first requests still went through build a's worker. + expect(await caches.has('bulwark-shell-a')).toBe(false); + await old.request({ url: 'https://mail.example.com/api/config' }); + await settle(); + expect(await caches.has('bulwark-shell-a')).toBe(false); + expect(await caches.has('bulwark-shell-b')).toBe(true); + + online = false; + await old.request({ url: 'https://mail.example.com/mail', mode: 'navigate' }).catch(() => null); + await settle(); + expect(await caches.has('bulwark-shell-a')).toBe(false); + }); }); diff --git a/public/sw.js b/public/sw.js index 390de31c3..a33068c02 100644 --- a/public/sw.js +++ b/public/sw.js @@ -139,10 +139,14 @@ async function cachedDocument(url) { const path = appPagePath(url) || "/"; const section = "/" + (path.split("/").filter(Boolean)[0] || ""); const candidates = [documentKey(url), self.location.origin + BASE_PATH + (section === "/" ? "" : section), self.location.origin + BASE_PATH]; - const own = await caches.open(SHELL_CACHE); - for (const key of candidates) { - const hit = await own.match(key); - if (hit) return hit; + // Only looked into when it exists: opening it would create it again + // after the next build's worker deleted it. + if (await caches.has(SHELL_CACHE)) { + const own = await caches.open(SHELL_CACHE); + for (const key of candidates) { + const hit = await own.match(key); + if (hit) return hit; + } } for (const key of candidates) { const hit = await caches.match(key); @@ -195,7 +199,9 @@ async function respondWithConfig(request) { const response = await fetch(request); if (response.ok) { const copy = response.clone(); - caches.open(SHELL_CACHE).then((cache) => cache.put(key, copy)).catch(() => {}); + // Like pages and chunks, under the build that answered: right after a + // deploy this worker still fetches for the new build's tab. + caches.open(shellCacheFor(response)).then((cache) => cache.put(key, copy)).catch(() => {}); } return response; } catch (error) {