fix: let the new build's service worker take over once a tab runs that build #523

This commit is contained in:
Linus Rath
2026-11-01 12:38:59 +01:00
parent 2ff2e713ec
commit 8b23c81372
6 changed files with 63 additions and 12 deletions
+2 -1
View File
@@ -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);
+11 -1
View File
@@ -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();
});
});
+9
View File
@@ -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);
});
});
+23
View File
@@ -77,6 +77,29 @@ export async function reloadToNewBuild(): Promise<void> {
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();
+2 -2
View File
@@ -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<void> {
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). */
+16 -8
View File
@@ -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") {