mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
fix: let the new build's service worker take over once a tab runs that build #523
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user