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();