From 8b23c8137220818fe2e57edda9ad3c8a44c46b85 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:38:59 +0100 Subject: [PATCH] fix: let the new build's service worker take over once a tab runs that build #523 --- components/service-worker-registration.tsx | 3 ++- lib/__tests__/app-update.test.ts | 12 ++++++++++- lib/__tests__/sw-offline-shell.test.ts | 9 ++++++++ lib/app-update.ts | 23 +++++++++++++++++++++ lib/offline/shell.ts | 4 ++-- public/sw.js | 24 ++++++++++++++-------- 6 files changed, 63 insertions(+), 12 deletions(-) diff --git a/components/service-worker-registration.tsx b/components/service-worker-registration.tsx index 87310a67a..2c2f92139 100644 --- a/components/service-worker-registration.tsx +++ b/components/service-worker-registration.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { IS_LITE } from "@/lib/lite"; -import { announceBuildToServiceWorker, serviceWorkerUrl, SW_SCOPE } from "@/lib/app-update"; +import { adoptWaitingWorker, announceBuildToServiceWorker, serviceWorkerUrl, SW_SCOPE } from "@/lib/app-update"; export function ServiceWorkerRegistration() { useEffect(() => { @@ -37,6 +37,7 @@ export function ServiceWorkerRegistration() { .then((registration) => { console.log("Service Worker registered successfully:", registration); announceBuildToServiceWorker(); + adoptWaitingWorker(registration); }) .catch((error) => { console.error("Service Worker registration failed:", error); diff --git a/lib/__tests__/app-update.test.ts b/lib/__tests__/app-update.test.ts index 9ac425623..ede111d70 100644 --- a/lib/__tests__/app-update.test.ts +++ b/lib/__tests__/app-update.test.ts @@ -10,7 +10,7 @@ vi.mock('@/lib/lite', async (importOriginal) => { }; }); -import { currentBuildId, fetchDeployedBuildId, isChunkLoadFailure, newerBuildId, serviceWorkerUrl } from '../app-update'; +import { adoptWaitingWorker, currentBuildId, fetchDeployedBuildId, isChunkLoadFailure, newerBuildId, serviceWorkerUrl } from '../app-update'; function answer(body: unknown, ok = true) { return vi.fn(async () => ({ ok, json: async () => body }) as Response); @@ -73,4 +73,14 @@ describe('app update', () => { expect(isChunkLoadFailure({ reason: error } as unknown as PromiseRejectionEvent)).toBe(true); expect(isChunkLoadFailure({ reason: new Error('nope') } as unknown as PromiseRejectionEvent)).toBe(false); }); + + it('lets a waiting worker of this tab’s build take over, and no other', () => { + const worker = (v: string) => ({ scriptURL: `https://mail.example.com/sw.js?v=${v}`, postMessage: vi.fn() }); + const ours = worker('build-a'); + adoptWaitingWorker({ waiting: ours, addEventListener: () => {} } as unknown as ServiceWorkerRegistration); + expect(ours.postMessage).toHaveBeenCalledWith({ type: 'skip-waiting' }); + const other = worker('build-b'); + adoptWaitingWorker({ waiting: other, addEventListener: () => {} } as unknown as ServiceWorkerRegistration); + expect(other.postMessage).not.toHaveBeenCalled(); + }); }); diff --git a/lib/__tests__/sw-offline-shell.test.ts b/lib/__tests__/sw-offline-shell.test.ts index 9417ae016..f235f9f18 100644 --- a/lib/__tests__/sw-offline-shell.test.ts +++ b/lib/__tests__/sw-offline-shell.test.ts @@ -123,4 +123,13 @@ describe('service worker with offline mode on', () => { expect((await caches.keys()).filter((k) => k.startsWith('bulwark-shell-'))).toEqual([]); expect(await worker.request({ url: `${ORIGIN}/mail`, mode: 'navigate' })).toBeNull(); }); + + it('keeps a newer tab’s shell in that tab’s build cache, not its own', async () => { + const caches = new FakeCaches(); + const fetchMock = vi.fn(async () => sameOrigin('x', 'text/javascript')); + const worker = loadWorker({ buildId: 'old', caches, clients: [], fetch: fetchMock }); + await worker.dispatch('message', { data: { type: 'offline-shell', on: true, urls: ['/_next/static/chunks/n.js'], buildId: 'new' } }); + expect(await caches.has('bulwark-shell-new')).toBe(true); + expect(await caches.has('bulwark-shell-old')).toBe(false); + }); }); diff --git a/lib/app-update.ts b/lib/app-update.ts index 01dc21954..dab926309 100644 --- a/lib/app-update.ts +++ b/lib/app-update.ts @@ -77,6 +77,29 @@ export async function reloadToNewBuild(): Promise { window.location.reload(); } +/** + * A worker of this tab's own build that waits (offline mode on, so it + * doesn't take over while tabs of the old build are open): this tab runs + * that build already, so it takes over now. Tabs of the old build keep + * their files: the old build's cache stays until the last of them closes, + * and the new worker answers chunks from every build's cache. + */ +export function adoptWaitingWorker(registration: ServiceWorkerRegistration): void { + const id = currentBuildId(); + if (!id) return; + const ours = (worker: ServiceWorker | null | undefined) => !!worker && new URL(worker.scriptURL).searchParams.get('v') === id; + const adopt = () => { + if (ours(registration.waiting)) registration.waiting!.postMessage({ type: 'skip-waiting' }); + }; + adopt(); + registration.addEventListener('updatefound', () => { + const installing = registration.installing; + installing?.addEventListener('statechange', () => { + if (installing.state === 'installed') adopt(); + }); + }); +} + /** Tells the service worker which build this tab runs, so it keeps that build's files while the tab is open. */ export function announceBuildToServiceWorker(): void { const id = currentBuildId(); diff --git a/lib/offline/shell.ts b/lib/offline/shell.ts index db110d7c2..40f902bee 100644 --- a/lib/offline/shell.ts +++ b/lib/offline/shell.ts @@ -5,7 +5,7 @@ */ import { apiFetch, withBasePath } from '@/lib/browser-navigation'; -import { SW_SCOPE } from '@/lib/app-update'; +import { currentBuildId, SW_SCOPE } from '@/lib/app-update'; /** The app's pages kept for a start without the network. */ const SHELL_PAGES = ['/', '/mail', '/settings']; @@ -35,7 +35,7 @@ export async function tellServiceWorker(on: boolean): Promise { const worker = await activeWorker(); if (!worker) return; const urls = on ? [...SHELL_PAGES.map((p) => withBasePath(p)), ...(await shellFiles())] : []; - worker.postMessage({ type: 'offline-shell', on, urls }); + worker.postMessage({ type: 'offline-shell', on, urls, buildId: currentBuildId() }); } /** The service worker's copies of the shell, from the page (also when no worker runs). */ diff --git a/public/sw.js b/public/sw.js index 1cabd4e6e..c46133829 100644 --- a/public/sw.js +++ b/public/sw.js @@ -46,8 +46,12 @@ const OFFLINE_SHELL_KEY = "/__bulwark/offline-shell"; self.addEventListener("install", (event) => { // With offline mode on, a tab may load a chunk of its build at any time, // from this worker's caches. A new build's worker waits until those tabs - // are gone or one asks it to take over (its "Reload"); without offline - // mode nothing is cached and it takes over at once, as it always did. + // are gone or a tab asks it to take over: its "Reload", or a tab that + // runs the new build already (lib/app-update.ts adoptWaitingWorker). The + // old build's tabs keep their files then too: its cache stays while they + // are open, and chunks are answered from every build's cache. Without + // offline mode nothing is cached and it takes over at once, as it always + // did. event.waitUntil(readOfflineShell().then((on) => { if (!on) return self.skipWaiting(); return self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((open) => { @@ -200,10 +204,14 @@ function offlineRoute(request) { return null; } -/** Keeps the files a tab names (this build's shell) in this build's cache. */ -async function precacheShell(urls) { +/** + * Keeps the files a tab names (its build's shell) in that build's cache. The + * tab may run a newer build than this worker (right after a deploy). + */ +async function precacheShell(urls, buildId) { if (!Array.isArray(urls) || urls.length === 0) return; - const cache = await caches.open(SHELL_CACHE); + const name = typeof buildId === "string" && /^[\w.-]{1,80}$/.test(buildId) ? SHELL_CACHE_PREFIX + buildId : SHELL_CACHE; + const cache = await caches.open(name); const wanted = urls.filter((u) => typeof u === "string"); for (let i = 0; i < wanted.length; i += 8) { await Promise.all(wanted.slice(i, i + 8).map(async (u) => { @@ -221,10 +229,10 @@ async function precacheShell(urls) { } } -async function setOfflineShell(on, urls) { +async function setOfflineShell(on, urls, buildId) { await writeOfflineShell(on); if (on) { - await precacheShell(urls); + await precacheShell(urls, buildId); return; } // Off: nothing of the shell stays. @@ -320,7 +328,7 @@ self.addEventListener("message", (event) => { return; } if (data.type === "offline-shell") { - event.waitUntil(setOfflineShell(data.on === true, data.urls)); + event.waitUntil(setOfflineShell(data.on === true, data.urls, data.buildId)); return; } if (data.type === "skip-waiting") {