Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #52.
This commit is contained in:
@@ -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(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App api={createApiClient(location)} />
|
||||
<App api={createApiClient(location, createNetworkStatus(window))} serviceWorker={undefined} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
return location
|
||||
|
||||
+11
-1
@@ -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 (
|
||||
<ApiProvider client={api}>
|
||||
@@ -52,6 +60,8 @@ export default function App({ api }: AppProps) {
|
||||
<AuthBootstrap>
|
||||
<AppRoutes />
|
||||
</AuthBootstrap>
|
||||
<OfflinePill />
|
||||
<UpdatePrompt container={serviceWorker} reload={reloadPage} />
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
|
||||
+13
-12
@@ -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('<html>bad gateway</html>', { 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('<html>denied by proxy</html>', { 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')
|
||||
})
|
||||
})
|
||||
|
||||
+31
-8
@@ -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.
|
||||
@@ -36,7 +37,7 @@ export type ApiLocation = Pick<Location, 'origin' | 'pathname' | 'search' | 'ass
|
||||
|
||||
export type ApiRequest = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
// Sent as JSON.
|
||||
// Sent as JSON. A GET must not have one.
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
@@ -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<Me> | null = null
|
||||
let forbidden = false
|
||||
@@ -70,12 +74,25 @@ 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),
|
||||
})
|
||||
// Serialize before fetching so a body that cannot be sent is its own error
|
||||
// and never counts as a network failure.
|
||||
let json: string | undefined
|
||||
try {
|
||||
json = body === undefined ? undefined : JSON.stringify(body)
|
||||
} catch (error) {
|
||||
throw new Error(`${method} ${path}: request body is not serializable as JSON`, { cause: error })
|
||||
}
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(path, { method, headers, credentials: 'same-origin', body: json })
|
||||
} 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
|
||||
@@ -90,6 +107,11 @@ export function createApiClient(location: ApiLocation): ApiClient {
|
||||
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
|
||||
const method = request.method ?? 'GET'
|
||||
const url = apiURL(location.origin, method, path)
|
||||
// fetch refuses a GET with a body by rejecting with a TypeError, the same
|
||||
// error as a network failure, so refuse it here before anything is sent.
|
||||
if (method === 'GET' && request.body !== undefined) {
|
||||
throw new Error(`GET ${path}: a GET request cannot carry a body`)
|
||||
}
|
||||
let response = await send(method, url, request.body)
|
||||
if (response.status === 403) {
|
||||
const error = await parseForbidden(method, path, response)
|
||||
@@ -151,5 +173,6 @@ export function createApiClient(location: ApiLocation): ApiClient {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
network,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }) => <ApiProvider client={api}>{children}</ApiProvider>
|
||||
return renderHook(({ id }) => useJob<Draft>(id), { wrapper, initialProps: { id: jobId } })
|
||||
}
|
||||
|
||||
+3
-2
@@ -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(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App api={api} />
|
||||
<App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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, ME, 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(
|
||||
<ApiProvider client={api}>
|
||||
<OfflinePill />
|
||||
</ApiProvider>,
|
||||
)
|
||||
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<typeof fetch>().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<typeof fetch>().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 a request body that cannot be serialized', async () => {
|
||||
const api = renderPill()
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
await act(() => api.loadMe())
|
||||
const body: Record<string, unknown> = {}
|
||||
body.self = body
|
||||
|
||||
await act(() =>
|
||||
expect(api.apiFetch('/api/v1/plan', { method: 'PUT', body })).rejects.toThrow(
|
||||
'PUT /api/v1/plan: request body is not serializable as JSON',
|
||||
),
|
||||
)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a GET request with a body, which fetch would refuse to send', async () => {
|
||||
const api = renderPill()
|
||||
const fetchMock = stubFetch()
|
||||
|
||||
await act(() =>
|
||||
expect(api.apiFetch('/api/v1/plan', { body: { week: 1 } })).rejects.toThrow(
|
||||
'GET /api/v1/plan: a GET request cannot carry a body',
|
||||
),
|
||||
)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores an aborted request', async () => {
|
||||
const api = renderPill()
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().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(
|
||||
<ApiProvider client={api}>
|
||||
<OfflinePill />
|
||||
</ApiProvider>,
|
||||
)
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<p role="status" className={styles.pill}>
|
||||
<Icon icon={WifiOff} />
|
||||
Offline — showing saved data
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(<UpdatePrompt container={container as unknown as UpdateContainer} reload={reload} />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<ServiceWorkerContainer, 'ready' | 'controller' | 'addEventListener' | 'removeEventListener'>
|
||||
|
||||
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<ServiceWorker | null>(null)
|
||||
const [dismissed, setDismissed] = useState<ServiceWorker | null>(null)
|
||||
const [accepted, setAccepted] = useState<ServiceWorker | null>(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<ServiceWorker>()
|
||||
|
||||
// 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 (
|
||||
<Card role="status" className={styles.toast}>
|
||||
<p className={styles.message}>
|
||||
New version ready <span aria-hidden="true">·</span>
|
||||
</p>
|
||||
{/* The space keeps the copy "New version ready · Reload" in the text;
|
||||
a flex container does not render it. */}{' '}
|
||||
<Button onClick={update}>Reload</Button>
|
||||
<IconButton label="Dismiss" onClick={() => setDismissed(waiting)}>
|
||||
<Icon icon={X} />
|
||||
</IconButton>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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<Window, 'navigator' | 'addEventListener' | 'removeEventListener'>
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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\(\)/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user