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\(\)/,
+ )
+ })
})