Add the update prompt and the offline pill
When a new service worker is waiting to replace the one controlling the page, a "New version ready · Reload" toast offers to reload. Reload sends SKIP_WAITING, which src/sw.ts answers with skipWaiting(), and reloads once on the controllerchange; dismissing hides the toast until a different worker is waiting. An "Offline — showing saved data" pill shows while navigator.onLine is false or the last API request failed with a network error. The network status store the API client reports to clears on the online event or the next response, and removes its window listeners on unsubscribe. Maestro task 63f6c6c2-2936-4738-9869-432d7a11141d. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'
|
|||||||
import { MemoryRouter } from 'react-router'
|
import { MemoryRouter } from 'react-router'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createApiClient } from './api/index.ts'
|
import { createApiClient } from './api/index.ts'
|
||||||
|
import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||||
import { mockViewport } from './test/matchMedia.ts'
|
import { mockViewport } from './test/matchMedia.ts'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
|
||||||
import App, { routes } from './App.tsx'
|
import App, { routes } from './App.tsx'
|
||||||
@@ -14,7 +15,7 @@ afterEach(() => {
|
|||||||
function renderAt(path: string, location = fakeLocation(path)) {
|
function renderAt(path: string, location = fakeLocation(path)) {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
<App api={createApiClient(location)} />
|
<App api={createApiClient(location, createNetworkStatus(window))} serviceWorker={undefined} />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
)
|
)
|
||||||
return location
|
return location
|
||||||
|
|||||||
+11
-1
@@ -4,6 +4,8 @@ import { Route, Routes } from 'react-router'
|
|||||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||||
import { PlaceholderPage } from './pages/PlaceholderPage.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'
|
import { AppShell } from './shell/AppShell.tsx'
|
||||||
|
|
||||||
// Every route the app serves, with the heading its placeholder page shows.
|
// Every route the app serves, with the heading its placeholder page shows.
|
||||||
@@ -40,11 +42,17 @@ export function AppRoutes() {
|
|||||||
|
|
||||||
export type AppProps = {
|
export type AppProps = {
|
||||||
api: ApiClient
|
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,
|
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
|
||||||
// MemoryRouter in tests).
|
// MemoryRouter in tests).
|
||||||
export default function App({ api }: AppProps) {
|
export default function App({ api, serviceWorker }: AppProps) {
|
||||||
const [queryClient] = useState(createQueryClient)
|
const [queryClient] = useState(createQueryClient)
|
||||||
return (
|
return (
|
||||||
<ApiProvider client={api}>
|
<ApiProvider client={api}>
|
||||||
@@ -52,6 +60,8 @@ export default function App({ api }: AppProps) {
|
|||||||
<AuthBootstrap>
|
<AuthBootstrap>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</AuthBootstrap>
|
</AuthBootstrap>
|
||||||
|
<OfflinePill />
|
||||||
|
<UpdatePrompt container={serviceWorker} reload={reloadPage} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ApiProvider>
|
</ApiProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
+13
-12
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from '../test/fetchStub.ts'
|
||||||
import { ApiError, createApiClient, CSRF_HEADER } from './index.ts'
|
import { ApiError, createApiClient, CSRF_HEADER } from './index.ts'
|
||||||
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
@@ -9,7 +10,7 @@ afterEach(() => {
|
|||||||
describe('apiFetch', () => {
|
describe('apiFetch', () => {
|
||||||
it('sends same-origin JSON and the cached CSRF token on non-GET requests only', async () => {
|
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 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 api.loadMe()
|
||||||
|
|
||||||
await expect(api.apiFetch('/api/v1/settings/household')).resolves.toEqual({ ok: 1 })
|
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 () => {
|
it('refuses a non-GET request before /me has loaded', async () => {
|
||||||
const fetchMock = stubFetch()
|
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/)
|
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
||||||
expect(fetchMock).not.toHaveBeenCalled()
|
expect(fetchMock).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -47,7 +48,7 @@ describe('apiFetch', () => {
|
|||||||
'api/v1/me',
|
'api/v1/me',
|
||||||
])('refuses %s without sending it or the CSRF token', async (path) => {
|
])('refuses %s without sending it or the CSRF token', async (path) => {
|
||||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||||
const api = createApiClient(fakeLocation())
|
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
|
||||||
await api.loadMe()
|
await api.loadMe()
|
||||||
|
|
||||||
await expect(api.apiFetch(path, { method: 'POST', body: {} })).rejects.toThrow(
|
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 () => {
|
it('rejects with a typed ApiError from the error body', async () => {
|
||||||
stubFetch(errorResponse(503, 'unavailable', true))
|
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)
|
const error = await api.apiFetch('/api/v1/jobs/j1').catch((e: unknown) => e)
|
||||||
expect(error).toBeInstanceOf(ApiError)
|
expect(error).toBeInstanceOf(ApiError)
|
||||||
expect(error).toMatchObject({ status: 503, code: 'unavailable', message: 'unavailable message', retryable: true })
|
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 () => {
|
it('rejects a response without the API error body', async () => {
|
||||||
stubFetch(new Response('<html>bad gateway</html>', { status: 502 }))
|
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')
|
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 () => {
|
it('on 401 navigates to sign-in with the current path and search to return to', async () => {
|
||||||
stubFetch(errorResponse(401, 'unauthenticated'))
|
stubFetch(errorResponse(401, 'unauthenticated'))
|
||||||
const location = fakeLocation('/plan/shopping', '?week=2026-10-05')
|
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 })
|
await expect(api.apiFetch('/api/v1/settings/household')).rejects.toMatchObject({ status: 401 })
|
||||||
expect(location.assign).toHaveBeenCalledExactlyOnceWith(
|
expect(location.assign).toHaveBeenCalledExactlyOnceWith(
|
||||||
@@ -90,7 +91,7 @@ describe('apiFetch', () => {
|
|||||||
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
|
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
|
||||||
jsonResponse(201, { id: 'j1' }),
|
jsonResponse(201, { id: 'j1' }),
|
||||||
)
|
)
|
||||||
const api = createApiClient(fakeLocation())
|
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
|
||||||
await api.loadMe()
|
await api.loadMe()
|
||||||
|
|
||||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: { kind: 'noop' } })).resolves.toEqual({
|
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' }),
|
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
|
||||||
errorResponse(403, 'csrf'),
|
errorResponse(403, 'csrf'),
|
||||||
)
|
)
|
||||||
const api = createApiClient(fakeLocation())
|
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
|
||||||
await api.loadMe()
|
await api.loadMe()
|
||||||
|
|
||||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toMatchObject({
|
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 () => {
|
it('marks any other 403 as not in this household and notifies subscribers', async () => {
|
||||||
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
|
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
|
||||||
const api = createApiClient(fakeLocation())
|
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
|
||||||
const listener = vi.fn()
|
const listener = vi.fn()
|
||||||
api.subscribe(listener)
|
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 () => {
|
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 }))
|
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')
|
await expect(api.apiFetch('/api/v1/me')).rejects.toThrow('GET /api/v1/me: 403 response is not JSON')
|
||||||
expect(api.forbidden()).toBe(false)
|
expect(api.forbidden()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not refresh /me for a csrf 403 on a GET, which carries no token', async () => {
|
it('does not refresh /me for a csrf 403 on a GET, which carries no token', async () => {
|
||||||
const fetchMock = stubFetch(errorResponse(403, 'csrf'))
|
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' })
|
await expect(api.loadMe()).rejects.toMatchObject({ status: 403, code: 'csrf' })
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
@@ -152,7 +153,7 @@ describe('apiFetch', () => {
|
|||||||
describe('loadMe', () => {
|
describe('loadMe', () => {
|
||||||
it('rejects a /me response without a CSRF token', async () => {
|
it('rejects a /me response without a CSRF token', async () => {
|
||||||
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
|
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')
|
await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+22
-7
@@ -1,3 +1,4 @@
|
|||||||
|
import type { NetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
import { ApiError, isRecord, parseApiError } from './errors.ts'
|
import { ApiError, isRecord, parseApiError } from './errors.ts'
|
||||||
|
|
||||||
// CSRF_HEADER carries the session's CSRF token on every non-GET request.
|
// 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.
|
// account is not in this household; subscribe is notified when it changes.
|
||||||
forbidden(): boolean
|
forbidden(): boolean
|
||||||
subscribe(listener: () => void): () => void
|
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
|
// 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.
|
// 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 csrfToken: string | null = null
|
||||||
let refreshing: Promise<Me> | null = null
|
let refreshing: Promise<Me> | null = null
|
||||||
let forbidden = false
|
let forbidden = false
|
||||||
@@ -70,12 +74,22 @@ export function createApiClient(location: ApiLocation): ApiClient {
|
|||||||
}
|
}
|
||||||
headers[CSRF_HEADER] = csrfToken
|
headers[CSRF_HEADER] = csrfToken
|
||||||
}
|
}
|
||||||
return fetch(path, {
|
let response: Response
|
||||||
method,
|
try {
|
||||||
headers,
|
response = await fetch(path, {
|
||||||
credentials: 'same-origin',
|
method,
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
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
|
// refreshMe shares one in-flight /me request between every request that
|
||||||
@@ -151,5 +165,6 @@ export function createApiClient(location: ApiLocation): ApiClient {
|
|||||||
listeners.add(listener)
|
listeners.add(listener)
|
||||||
return () => listeners.delete(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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, request, stubFetch } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, request, stubFetch } from '../test/fetchStub.ts'
|
||||||
import { ApiProvider, createApiClient, useJob } from './index.ts'
|
import { ApiProvider, createApiClient, useJob } from './index.ts'
|
||||||
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
|
|
||||||
type Draft = { dinners: string[] }
|
type Draft = { dinners: string[] }
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ function job(status: string, result: Draft | null, error: string | null = null)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderJob(jobId: string | 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>
|
const wrapper = ({ children }: { children: ReactNode }) => <ApiProvider client={api}>{children}</ApiProvider>
|
||||||
return renderHook(({ id }) => useJob<Draft>(id), { wrapper, initialProps: { id: jobId } })
|
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 './styles/base.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import { createApiClient } from './api/index.ts'
|
import { createApiClient } from './api/index.ts'
|
||||||
|
import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||||
|
|
||||||
const root = document.getElementById('root')
|
const root = document.getElementById('root')
|
||||||
if (!root) {
|
if (!root) {
|
||||||
throw new Error('MealQuest: #root element missing from index.html')
|
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(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App api={api} />
|
<App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</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,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(
|
||||||
|
<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 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)))),
|
.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(fontRoute).toBeGreaterThanOrEqual(0)
|
||||||
expect(precache).toBeGreaterThan(fontRoute)
|
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