diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index ed77cf6..ea0f33a 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react' import { MemoryRouter } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' import { createApiClient } from './api/index.ts' +import { createNetworkStatus } from './pwa/networkStatus.ts' import { mockViewport } from './test/matchMedia.ts' import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts' import App, { routes } from './App.tsx' @@ -14,7 +15,7 @@ afterEach(() => { function renderAt(path: string, location = fakeLocation(path)) { render( - + , ) return location diff --git a/web/src/App.tsx b/web/src/App.tsx index 13cdf10..687c59f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,8 @@ import { Route, Routes } from 'react-router' import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts' import { AuthBootstrap } from './AuthBootstrap.tsx' import { PlaceholderPage } from './pages/PlaceholderPage.tsx' +import { OfflinePill } from './pwa/OfflinePill.tsx' +import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx' import { AppShell } from './shell/AppShell.tsx' // Every route the app serves, with the heading its placeholder page shows. @@ -40,11 +42,17 @@ export function AppRoutes() { export type AppProps = { api: ApiClient + // navigator.serviceWorker, or undefined where there is none (and in tests). + serviceWorker: UpdateContainer | undefined +} + +function reloadPage() { + window.location.reload() } // App expects to be rendered inside a router (BrowserRouter in main.tsx, // MemoryRouter in tests). -export default function App({ api }: AppProps) { +export default function App({ api, serviceWorker }: AppProps) { const [queryClient] = useState(createQueryClient) return ( @@ -52,6 +60,8 @@ export default function App({ api }: AppProps) { + + ) diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index f6fa5be..3e788b7 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from '../test/fetchStub.ts' import { ApiError, createApiClient, CSRF_HEADER } from './index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' afterEach(() => { vi.unstubAllGlobals() @@ -9,7 +10,7 @@ afterEach(() => { describe('apiFetch', () => { it('sends same-origin JSON and the cached CSRF token on non-GET requests only', async () => { const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, { ok: 1 }), jsonResponse(200, { ok: 2 })) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window)) await api.loadMe() await expect(api.apiFetch('/api/v1/settings/household')).resolves.toEqual({ ok: 1 }) @@ -29,7 +30,7 @@ describe('apiFetch', () => { it('refuses a non-GET request before /me has loaded', async () => { const fetchMock = stubFetch() - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window)) await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/) expect(fetchMock).not.toHaveBeenCalled() }) @@ -47,7 +48,7 @@ describe('apiFetch', () => { 'api/v1/me', ])('refuses %s without sending it or the CSRF token', async (path) => { const fetchMock = stubFetch(jsonResponse(200, ME)) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window)) await api.loadMe() await expect(api.apiFetch(path, { method: 'POST', body: {} })).rejects.toThrow( @@ -60,7 +61,7 @@ describe('apiFetch', () => { it('rejects with a typed ApiError from the error body', async () => { stubFetch(errorResponse(503, 'unavailable', true)) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window)) const error = await api.apiFetch('/api/v1/jobs/j1').catch((e: unknown) => e) expect(error).toBeInstanceOf(ApiError) expect(error).toMatchObject({ status: 503, code: 'unavailable', message: 'unavailable message', retryable: true }) @@ -68,14 +69,14 @@ describe('apiFetch', () => { it('rejects a response without the API error body', async () => { stubFetch(new Response('bad gateway', { status: 502 })) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window)) await expect(api.apiFetch('/api/v1/jobs/j1')).rejects.toThrow('GET /api/v1/jobs/j1: 502 response is not JSON') }) it('on 401 navigates to sign-in with the current path and search to return to', async () => { stubFetch(errorResponse(401, 'unauthenticated')) const location = fakeLocation('/plan/shopping', '?week=2026-10-05') - const api = createApiClient(location) + const api = createApiClient(location, createNetworkStatus(window)) await expect(api.apiFetch('/api/v1/settings/household')).rejects.toMatchObject({ status: 401 }) expect(location.assign).toHaveBeenCalledExactlyOnceWith( @@ -90,7 +91,7 @@ describe('apiFetch', () => { jsonResponse(200, { ...ME, csrfToken: 'token-2' }), jsonResponse(201, { id: 'j1' }), ) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window)) await api.loadMe() await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: { kind: 'noop' } })).resolves.toEqual({ @@ -111,7 +112,7 @@ describe('apiFetch', () => { jsonResponse(200, { ...ME, csrfToken: 'token-2' }), errorResponse(403, 'csrf'), ) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window)) await api.loadMe() await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toMatchObject({ @@ -124,7 +125,7 @@ describe('apiFetch', () => { it('marks any other 403 as not in this household and notifies subscribers', async () => { const fetchMock = stubFetch(errorResponse(403, 'forbidden')) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window)) const listener = vi.fn() api.subscribe(listener) @@ -136,14 +137,14 @@ describe('apiFetch', () => { it('does not take a 403 without the API error body as not in this household', async () => { stubFetch(new Response('denied by proxy', { status: 403 })) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window)) await expect(api.apiFetch('/api/v1/me')).rejects.toThrow('GET /api/v1/me: 403 response is not JSON') expect(api.forbidden()).toBe(false) }) it('does not refresh /me for a csrf 403 on a GET, which carries no token', async () => { const fetchMock = stubFetch(errorResponse(403, 'csrf')) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window)) await expect(api.loadMe()).rejects.toMatchObject({ status: 403, code: 'csrf' }) expect(fetchMock).toHaveBeenCalledTimes(1) }) @@ -152,7 +153,7 @@ describe('apiFetch', () => { describe('loadMe', () => { it('rejects a /me response without a CSRF token', async () => { stubFetch(jsonResponse(200, { ...ME, csrfToken: '' })) - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window)) await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token') }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a4f840e..ca48ccb 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,3 +1,4 @@ +import type { NetworkStatus } from '../pwa/networkStatus.ts' import { ApiError, isRecord, parseApiError } from './errors.ts' // CSRF_HEADER carries the session's CSRF token on every non-GET request. @@ -51,11 +52,14 @@ export type ApiClient = { // account is not in this household; subscribe is notified when it changes. forbidden(): boolean subscribe(listener: () => void): () => void + // network learns of every request that failed with a network error and of + // every request that got a response. + network: NetworkStatus } // createApiClient builds the app's API client. location is window.location in // the app: a 401 sends it to /auth/login with the current path to return to. -export function createApiClient(location: ApiLocation): ApiClient { +export function createApiClient(location: ApiLocation, network: NetworkStatus): ApiClient { let csrfToken: string | null = null let refreshing: Promise | null = null let forbidden = false @@ -70,12 +74,22 @@ export function createApiClient(location: ApiLocation): ApiClient { } headers[CSRF_HEADER] = csrfToken } - return fetch(path, { - method, - headers, - credentials: 'same-origin', - body: body === undefined ? undefined : JSON.stringify(body), - }) + let response: Response + try { + response = await fetch(path, { + method, + headers, + credentials: 'same-origin', + body: body === undefined ? undefined : JSON.stringify(body), + }) + } catch (error) { + // fetch rejects with a TypeError when the network fails; an abort is a + // DOMException and says nothing about the network. + if (error instanceof TypeError) network.requestFailed() + throw error + } + network.requestSucceeded() + return response } // refreshMe shares one in-flight /me request between every request that @@ -151,5 +165,6 @@ export function createApiClient(location: ApiLocation): ApiClient { listeners.add(listener) return () => listeners.delete(listener) }, + network, } } diff --git a/web/src/api/useJob.test.tsx b/web/src/api/useJob.test.tsx index f93ce20..e249ec5 100644 --- a/web/src/api/useJob.test.tsx +++ b/web/src/api/useJob.test.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { errorResponse, fakeLocation, jsonResponse, request, stubFetch } from '../test/fetchStub.ts' import { ApiProvider, createApiClient, useJob } from './index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' type Draft = { dinners: string[] } @@ -11,7 +12,7 @@ function job(status: string, result: Draft | null, error: string | null = null) } function renderJob(jobId: string | null) { - const api = createApiClient(fakeLocation()) + const api = createApiClient(fakeLocation(), createNetworkStatus(window)) const wrapper = ({ children }: { children: ReactNode }) => {children} return renderHook(({ id }) => useJob(id), { wrapper, initialProps: { id: jobId } }) } diff --git a/web/src/main.tsx b/web/src/main.tsx index fe00a99..fbd16cf 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -5,18 +5,19 @@ import './styles/tokens.css' import './styles/base.css' import App from './App.tsx' import { createApiClient } from './api/index.ts' +import { createNetworkStatus } from './pwa/networkStatus.ts' const root = document.getElementById('root') if (!root) { throw new Error('MealQuest: #root element missing from index.html') } -const api = createApiClient(window.location) +const api = createApiClient(window.location, createNetworkStatus(window)) createRoot(root).render( - + , ) diff --git a/web/src/pwa/OfflinePill.module.css b/web/src/pwa/OfflinePill.module.css new file mode 100644 index 0000000..51b6b0e --- /dev/null +++ b/web/src/pwa/OfflinePill.module.css @@ -0,0 +1,22 @@ +.pill { + position: fixed; + top: calc(var(--space-3) + env(safe-area-inset-top)); + left: 50%; + z-index: 20; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: var(--space-2); + margin: 0; + padding: 6px 14px; + border-radius: var(--radius-pill); + background: var(--color-neutral-800); + color: var(--on-accent); + box-shadow: var(--shadow-md); + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; + pointer-events: none; +} diff --git a/web/src/pwa/OfflinePill.test.tsx b/web/src/pwa/OfflinePill.test.tsx new file mode 100644 index 0000000..d98ea6f --- /dev/null +++ b/web/src/pwa/OfflinePill.test.tsx @@ -0,0 +1,105 @@ +import { act, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiProvider, createApiClient, type ApiClient } from '../api/index.ts' +import { fakeLocation, jsonResponse, stubFetch } from '../test/fetchStub.ts' +import { createNetworkStatus } from './networkStatus.ts' +import { OfflinePill } from './OfflinePill.tsx' + +const copy = 'Offline — showing saved data' + +let onLine: boolean + +beforeEach(() => { + onLine = true + vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function goOffline() { + onLine = false + window.dispatchEvent(new Event('offline')) +} + +function goOnline() { + onLine = true + window.dispatchEvent(new Event('online')) +} + +function renderPill(): ApiClient { + const api = createApiClient(fakeLocation(), createNetworkStatus(window)) + render( + + + , + ) + return api +} + +describe('OfflinePill', () => { + it('is hidden while online', () => { + renderPill() + expect(screen.queryByRole('status')).toBeNull() + }) + + it('shows while the browser is offline when it renders', () => { + onLine = false + renderPill() + expect(screen.getByRole('status')).toHaveTextContent(copy) + }) + + it('appears on the offline event and disappears on the online event', () => { + renderPill() + act(goOffline) + expect(screen.getByRole('status')).toHaveTextContent(copy) + act(goOnline) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('appears when a request fails with a network error', async () => { + const api = renderPill() + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow(TypeError)) + expect(screen.getByRole('status')).toHaveTextContent(copy) + + act(goOnline) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('disappears when a later request gets a response', async () => { + const api = renderPill() + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow(TypeError)) + expect(screen.getByRole('status')).toBeInTheDocument() + + stubFetch(jsonResponse(200, {})) + await act(() => api.apiFetch('/api/v1/plan')) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('ignores an aborted request', async () => { + const api = renderPill() + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('aborted', 'AbortError'))) + await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow('aborted')) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('removes its online and offline listeners on unmount', () => { + const add = vi.spyOn(window, 'addEventListener') + const remove = vi.spyOn(window, 'removeEventListener') + const api = createApiClient(fakeLocation(), createNetworkStatus(window)) + const { unmount } = render( + + + , + ) + const added = add.mock.calls.filter(([type]) => type === 'online' || type === 'offline') + expect(added.map(([type]) => type).sort()).toEqual(['offline', 'online']) + + unmount() + for (const [type, listener] of added) expect(remove).toHaveBeenCalledWith(type, listener) + }) +}) diff --git a/web/src/pwa/OfflinePill.tsx b/web/src/pwa/OfflinePill.tsx new file mode 100644 index 0000000..b1d2060 --- /dev/null +++ b/web/src/pwa/OfflinePill.tsx @@ -0,0 +1,19 @@ +import { useSyncExternalStore } from 'react' +import { WifiOff } from 'lucide-react' +import { useApi } from '../api/index.ts' +import { Icon } from '../ui/Icon.tsx' +import styles from './OfflinePill.module.css' + +// OfflinePill tells the household that what they see came from the cache +// while the browser is offline or the server cannot be reached. +export function OfflinePill() { + const { network } = useApi() + const offline = useSyncExternalStore(network.subscribe, network.offline) + if (!offline) return null + return ( +

+ + Offline — showing saved data +

+ ) +} diff --git a/web/src/pwa/UpdatePrompt.module.css b/web/src/pwa/UpdatePrompt.module.css new file mode 100644 index 0000000..ad2f014 --- /dev/null +++ b/web/src/pwa/UpdatePrompt.module.css @@ -0,0 +1,22 @@ +.toast { + position: fixed; + left: 50%; + bottom: calc(110px + env(safe-area-inset-bottom)); + z-index: 20; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: var(--space-3); + width: max-content; + max-width: calc(100vw - 2 * var(--space-4)); + padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4); + box-shadow: var(--shadow-lg); +} + +.message { + margin: 0; + font-family: var(--font-body); + font-size: 15px; + font-weight: 600; + line-height: 1.35; +} diff --git a/web/src/pwa/UpdatePrompt.test.tsx b/web/src/pwa/UpdatePrompt.test.tsx new file mode 100644 index 0000000..c869209 --- /dev/null +++ b/web/src/pwa/UpdatePrompt.test.tsx @@ -0,0 +1,165 @@ +import { act, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { SKIP_WAITING, UpdatePrompt, type UpdateContainer } from './UpdatePrompt.tsx' + +// FakeWorker is a ServiceWorker whose state the test moves along. +class FakeWorker extends EventTarget { + state: ServiceWorkerState = 'installing' + postMessage = vi.fn() + setState(state: ServiceWorkerState) { + this.state = state + this.dispatchEvent(new Event('statechange')) + } +} + +class FakeRegistration extends EventTarget { + installing: FakeWorker | null = null + waiting: FakeWorker | null = null + // install starts a new worker, as the browser does on finding an update. + install(): FakeWorker { + const worker = new FakeWorker() + this.installing = worker + this.dispatchEvent(new Event('updatefound')) + return worker + } + // finishInstall moves the installing worker to waiting. + finishInstall(worker: FakeWorker) { + this.installing = null + this.waiting = worker + worker.setState('installed') + } +} + +class FakeContainer extends EventTarget { + controller: object | null = {} + registration = new FakeRegistration() + ready = Promise.resolve(this.registration) + // takeOver makes the waiting worker the controller. + takeOver() { + const worker = this.registration.waiting + this.registration.waiting = null + this.controller = worker + worker?.setState('activated') + this.dispatchEvent(new Event('controllerchange')) + } +} + +async function renderPrompt(container: FakeContainer | undefined) { + const reload = vi.fn() + const view = render() + await act(async () => { + await container?.ready + }) + return { reload, ...view } +} + +const copy = 'New version ready · Reload' + +describe('UpdatePrompt', () => { + it('shows nothing without a service worker', async () => { + await renderPrompt(undefined) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('shows nothing while no worker is waiting', async () => { + const container = new FakeContainer() + await renderPrompt(container) + act(() => { + container.registration.install() + }) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('shows nothing for a waiting worker when the page has no controller', async () => { + const container = new FakeContainer() + container.controller = null + container.registration.waiting = new FakeWorker() + await renderPrompt(container) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('shows the toast for a worker already waiting when the page loads', async () => { + const container = new FakeContainer() + container.registration.waiting = new FakeWorker() + await renderPrompt(container) + expect(screen.getByRole('status')).toHaveTextContent(copy) + }) + + it('shows the toast when an update finishes installing', async () => { + const container = new FakeContainer() + await renderPrompt(container) + act(() => container.registration.finishInstall(container.registration.install())) + expect(screen.getByRole('status')).toHaveTextContent(copy) + }) + + it('sends SKIP_WAITING on Reload and reloads once the new worker controls the page', async () => { + const container = new FakeContainer() + const { reload } = await renderPrompt(container) + const worker = container.registration.install() + act(() => container.registration.finishInstall(worker)) + + act(() => screen.getByRole('button', { name: 'Reload' }).click()) + expect(worker.postMessage).toHaveBeenCalledExactlyOnceWith(SKIP_WAITING) + expect(screen.queryByRole('status')).toBeNull() + expect(reload).not.toHaveBeenCalled() + + act(() => container.takeOver()) + expect(reload).toHaveBeenCalledOnce() + // A later controller change (another tab's update) does not reload again. + act(() => container.dispatchEvent(new Event('controllerchange'))) + expect(reload).toHaveBeenCalledOnce() + expect(screen.queryByRole('status')).toBeNull() + }) + + it('does not reappear for the worker the household chose to reload into', async () => { + const container = new FakeContainer() + container.registration.waiting = new FakeWorker() + await renderPrompt(container) + act(() => screen.getByRole('button', { name: 'Reload' }).click()) + // The worker reports another state change before it takes over. + act(() => container.registration.waiting?.setState('installed')) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('hides when dismissed until the next update is waiting', async () => { + const container = new FakeContainer() + const { reload } = await renderPrompt(container) + const first = container.registration.install() + act(() => container.registration.finishInstall(first)) + + act(() => screen.getByRole('button', { name: 'Dismiss' }).click()) + expect(screen.queryByRole('status')).toBeNull() + expect(first.postMessage).not.toHaveBeenCalled() + act(() => first.setState('installed')) + expect(screen.queryByRole('status')).toBeNull() + + act(() => container.registration.finishInstall(container.registration.install())) + expect(screen.getByRole('status')).toHaveTextContent(copy) + expect(reload).not.toHaveBeenCalled() + }) + + it('hides when another tab activates the waiting worker', async () => { + const container = new FakeContainer() + container.registration.waiting = new FakeWorker() + const { reload } = await renderPrompt(container) + act(() => container.takeOver()) + expect(screen.queryByRole('status')).toBeNull() + expect(reload).not.toHaveBeenCalled() + }) + + it('removes its listeners on unmount', async () => { + const container = new FakeContainer() + const worker = new FakeWorker() + container.registration.waiting = worker + const { reload, unmount } = await renderPrompt(container) + const removeRegistration = vi.spyOn(container.registration, 'removeEventListener') + const removeWorker = vi.spyOn(worker, 'removeEventListener') + act(() => screen.getByRole('button', { name: 'Reload' }).click()) + + unmount() + expect(removeRegistration).toHaveBeenCalledWith('updatefound', expect.any(Function)) + expect(removeWorker).toHaveBeenCalledWith('statechange', expect.any(Function)) + container.takeOver() + expect(reload).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/pwa/UpdatePrompt.tsx b/web/src/pwa/UpdatePrompt.tsx new file mode 100644 index 0000000..c0d69b5 --- /dev/null +++ b/web/src/pwa/UpdatePrompt.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from 'react' +import { X } from 'lucide-react' +import { Button } from '../ui/Button.tsx' +import { Card } from '../ui/Card.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import styles from './UpdatePrompt.module.css' + +// UpdateContainer is the part of navigator.serviceWorker the prompt uses. +export type UpdateContainer = Pick + +export type UpdatePromptProps = { + // navigator.serviceWorker, or undefined where the browser has none. + container: UpdateContainer | undefined + // Reloads the page once the new worker controls it. + reload: () => void +} + +// SKIP_WAITING is the message src/sw.ts answers by activating the waiting +// worker. +export const SKIP_WAITING = { type: 'SKIP_WAITING' } as const + +// UpdatePrompt offers to reload when a new service worker is installed and +// waiting to replace the one controlling the page. Reload asks the waiting +// worker to activate and reloads once it controls the page; dismissing hides +// the prompt until a different worker is waiting. +export function UpdatePrompt({ container, reload }: UpdatePromptProps) { + const [waiting, setWaiting] = useState(null) + const [dismissed, setDismissed] = useState(null) + const [accepted, setAccepted] = useState(null) + const reloadListener = useRef<(() => void) | null>(null) + + useEffect(() => { + if (container === undefined) return + const sw = container + let registration: ServiceWorkerRegistration | null = null + let unmounted = false + const watched = new Set() + + // A waiting worker only replaces something when the page has a + // controller; without one there is no controllerchange to reload on. + function check() { + if (registration === null) return + setWaiting(sw.controller === null ? null : registration.waiting) + } + function watch(worker: ServiceWorker | null) { + if (worker === null || watched.has(worker)) return + watched.add(worker) + worker.addEventListener('statechange', check) + } + function updateFound() { + watch(registration?.installing ?? null) + } + + void sw.ready.then((ready) => { + if (unmounted) return + registration = ready + ready.addEventListener('updatefound', updateFound) + watch(ready.installing) + watch(ready.waiting) + check() + }) + return () => { + unmounted = true + registration?.removeEventListener('updatefound', updateFound) + watched.forEach((worker) => worker.removeEventListener('statechange', check)) + if (reloadListener.current !== null) { + sw.removeEventListener('controllerchange', reloadListener.current) + reloadListener.current = null + } + } + }, [container]) + + if (container === undefined || waiting === null || waiting === dismissed || waiting === accepted) return null + + function update() { + if (container === undefined || waiting === null || reloadListener.current !== null) return + const listener = () => { + reloadListener.current = null + reload() + } + reloadListener.current = listener + container.addEventListener('controllerchange', listener, { once: true }) + setAccepted(waiting) + waiting.postMessage(SKIP_WAITING) + } + + return ( + +

+ New version ready +

+ {/* The space keeps the copy "New version ready · Reload" in the text; + a flex container does not render it. */}{' '} + + setDismissed(waiting)}> + + +
+ ) +} diff --git a/web/src/pwa/networkStatus.ts b/web/src/pwa/networkStatus.ts new file mode 100644 index 0000000..16955fc --- /dev/null +++ b/web/src/pwa/networkStatus.ts @@ -0,0 +1,50 @@ +// NetworkWindow is the part of window the network status reads: the browser's +// online flag and its online/offline events. +export type NetworkWindow = Pick + +export type NetworkStatus = { + // offline reports whether the browser says it is offline or the last API + // request failed with a network error. + offline(): boolean + // subscribe is notified when offline may have changed. It listens for the + // window's online/offline events until the returned function is called. + subscribe(listener: () => void): () => void + // requestFailed records a fetch that rejected with a network error. + requestFailed(): void + // requestSucceeded records a fetch that got a response. + requestSucceeded(): void +} + +// createNetworkStatus tracks whether MealQuest can reach the server. A failed +// request counts as offline until a later request gets a response or the +// browser reports that it is back online. +export function createNetworkStatus(win: NetworkWindow): NetworkStatus { + let failed = false + const listeners = new Set<() => void>() + + function setFailed(value: boolean) { + if (failed === value) return + failed = value + listeners.forEach((listener) => listener()) + } + + return { + offline: () => !win.navigator.onLine || failed, + subscribe(listener) { + const online = () => { + failed = false + listener() + } + listeners.add(listener) + win.addEventListener('online', online) + win.addEventListener('offline', listener) + return () => { + listeners.delete(listener) + win.removeEventListener('online', online) + win.removeEventListener('offline', listener) + } + }, + requestFailed: () => setFailed(true), + requestSucceeded: () => setFailed(false), + } +} diff --git a/web/src/sw.ts b/web/src/sw.ts index b5f92dd..284f08e 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -95,3 +95,9 @@ self.addEventListener('activate', (event) => { .then((names) => Promise.all(staleCaches(names, current).map((name) => caches.delete(name)))), ) }) + +// The update prompt (src/pwa/UpdatePrompt.tsx) asks a waiting worker to take +// over when the household chooses Reload. +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting() +}) diff --git a/web/src/sw/worker.test.ts b/web/src/sw/worker.test.ts index 07382fb..f4d3572 100644 --- a/web/src/sw/worker.test.ts +++ b/web/src/sw/worker.test.ts @@ -21,4 +21,10 @@ describe('service worker source', () => { expect(fontRoute).toBeGreaterThanOrEqual(0) expect(precache).toBeGreaterThan(fontRoute) }) + + it('activates a waiting worker when the update prompt sends SKIP_WAITING', () => { + expect(source).toMatch( + /self\.addEventListener\('message', \(event\) => \{\s*if \(event\.data\?\.type === 'SKIP_WAITING'\) void self\.skipWaiting\(\)/, + ) + }) })