From 51c44ee2f4fc032826068f54c5f12a4e2f6f1d66 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 30 Oct 2026 05:08:25 +0000 Subject: [PATCH] feat(site): tell the user when the explorer is ready to work offline The first worker to install caches the site, and nothing on the page said so. The footer now reports that it is ready to work offline once that install has finished. A later worker, which only replaces one, does not repeat it. --- e2e/pwa.e2e.ts | 13 +++++++++++++ site/index.html | 1 + site/src/pwa.ts | 4 ++++ site/src/updates.ts | 10 ++++++++++ site/src/updates.unit.test.ts | 21 +++++++++++++++++++-- 5 files changed, 47 insertions(+), 2 deletions(-) diff --git a/e2e/pwa.e2e.ts b/e2e/pwa.e2e.ts index 117991b..85767e0 100644 --- a/e2e/pwa.e2e.ts +++ b/e2e/pwa.e2e.ts @@ -165,6 +165,19 @@ test.describe('the version link', () => { }); test.describe('offline', () => { + test('says the site is ready to work offline once the first load has cached it, and not on later loads', async ({ page }) => { + await withSite(async (site) => { + await openAt(page, site); + + await expect(page.locator('#offline-status')).toHaveText('Ready to work offline.', { timeout: RESULT_TIMEOUT_MS }); + + await page.reload(); + await new Explorer(page).ready(); + + await expect(page.locator('#offline-status')).toHaveText(''); + }); + }); + test('opens, runs an operation and shows the engine with no network once it has been loaded', async ({ page }) => { await withSite(async (site) => { const explorer = await controlledPage(page, site); diff --git a/site/index.html b/site/index.html index 2424f4b..9838d19 100644 --- a/site/index.html +++ b/site/index.html @@ -134,6 +134,7 @@

One combination

diff --git a/site/src/pwa.ts b/site/src/pwa.ts index f695be7..527fda1 100644 --- a/site/src/pwa.ts +++ b/site/src/pwa.ts @@ -18,6 +18,7 @@ function startUpdates(): void { const prompt = byId("update-prompt", HTMLElement); const update = byId("update-now", HTMLButtonElement); const dismiss = byId("update-later", HTMLButtonElement); + const offlineStatus = byId("offline-status", HTMLElement); watchForUpdates( // Relative to the page, so it is found under the repository's project-page path and under a preview of the build. @@ -29,6 +30,9 @@ function startUpdates(): void { hide: () => { prompt.hidden = true; }, + offlineReady: () => { + offlineStatus.textContent = "Ready to work offline."; + }, onUpdate: (listener) => { update.addEventListener("click", listener); }, diff --git a/site/src/updates.ts b/site/src/updates.ts index 88f5632..a05b6fa 100644 --- a/site/src/updates.ts +++ b/site/src/updates.ts @@ -4,6 +4,8 @@ import { Workbox } from "workbox-window"; export interface UpdateSource { /** Registers the worker. Rejects when the browser cannot. */ readonly register: () => Promise; + /** Calls the listener when the first worker for this browser has installed and cached the site, so it will open with no network from now on. A later worker does not call it. */ + readonly onFirstInstall: (listener: () => void) => void; /** Calls the listener when a newer worker has installed and is waiting for the page to let it take over. */ readonly onWaiting: (listener: () => void) => void; /** Calls the listener when a newer worker has taken over the page. */ @@ -18,6 +20,8 @@ export interface UpdateSource { export interface UpdateView { readonly show: () => void; readonly hide: () => void; + /** Tells the user the site is cached and will open with no network. */ + readonly offlineReady: () => void; /** Calls the listener when the user asks to update now. */ readonly onUpdate: (listener: () => void) => void; /** Calls the listener when the user puts the update off. */ @@ -41,6 +45,7 @@ export interface UpdateEnvironment { export function watchForUpdates(source: UpdateSource, view: UpdateView, environment: UpdateEnvironment): void { let updating = false; + source.onFirstInstall(view.offlineReady); source.onWaiting(view.show); source.onControlling(() => { // A worker can also take over a page that never asked, such as the first one to install; only the update the user chose reloads it. @@ -66,6 +71,11 @@ export function workboxSource(url: string): UpdateSource { register: async () => { await workbox.register(); }, + onFirstInstall: (listener) => { + workbox.addEventListener("installed", (event) => { + if (event.isUpdate !== true) listener(); + }); + }, onWaiting: (listener) => { workbox.addEventListener("waiting", listener); }, diff --git a/site/src/updates.unit.test.ts b/site/src/updates.unit.test.ts index adb719c..b301d92 100644 --- a/site/src/updates.unit.test.ts +++ b/site/src/updates.unit.test.ts @@ -17,8 +17,8 @@ async function outcome(failure: Error | undefined): Promise { /** The worker, the prompt and the page around the update logic, driven by hand: each records what the logic asks of it and exposes the events a browser or a user would cause. */ function setup(failures: { readonly register?: Error; readonly check?: Error } = {}) { - const listeners = { waiting: [] as Listener[], controlling: [] as Listener[], update: [] as Listener[], dismiss: [] as Listener[], visible: [] as Listener[] }; - const seen = { registrations: 0, checks: 0, activations: 0, reloads: 0, shown: false, reported: [] as unknown[] }; + const listeners = { firstInstall: [] as Listener[], waiting: [] as Listener[], controlling: [] as Listener[], update: [] as Listener[], dismiss: [] as Listener[], visible: [] as Listener[] }; + const seen = { offlineReady: 0, registrations: 0, checks: 0, activations: 0, reloads: 0, shown: false, reported: [] as unknown[] }; watchForUpdates( { @@ -26,6 +26,9 @@ function setup(failures: { readonly register?: Error; readonly check?: Error } = seen.registrations += 1; await outcome(failures.register); }, + onFirstInstall: (listener) => { + listeners.firstInstall.push(listener); + }, onWaiting: (listener) => { listeners.waiting.push(listener); }, @@ -47,6 +50,9 @@ function setup(failures: { readonly register?: Error; readonly check?: Error } = hide: () => { seen.shown = false; }, + offlineReady: () => { + seen.offlineReady += 1; + }, onUpdate: (listener) => { listeners.update.push(listener); }, @@ -69,6 +75,9 @@ function setup(failures: { readonly register?: Error; readonly check?: Error } = return { seen, + installFirst: () => { + fire(listeners.firstInstall); + }, installWaiting: () => { fire(listeners.waiting); }, @@ -101,6 +110,14 @@ describe("watchForUpdates", () => { expect(seen.shown).toBe(false); }); + it("tells the user the site is ready to work offline when the first worker has installed, and offers no update", () => { + const { seen, installFirst } = setup(); + installFirst(); + + expect(seen.offlineReady).toBe(1); + expect(seen.shown).toBe(false); + }); + it("offers the update when a newer worker is waiting, without activating it", () => { const { seen, installWaiting } = setup(); installWaiting();