Open saved pages offline, cache recipe reads, flag cache fallbacks
CI / ci (push) Successful in 1m34s
CI / ci (pull_request) Successful in 1m34s

Address the Feature 06 integration review:

- Offline bootstrap: the API client keeps a device-local verified-session
  marker (localStorage "1" only, never the /me payload or CSRF token),
  set when /me answers with a member and cleared on a 401 or a
  not-in-household 403. When /me fails with a network error and the
  marker is set, AuthBootstrap opens the saved pages read-only (writes
  are refused for want of a CSRF token) and loads /me again when the
  browser comes back online. /me runs with networkMode 'always' so an
  offline load fails fast instead of pausing on the skeleton.
- Recipe reads (/api/v1/recipes) join the network-first API cache;
  /auth, settings and jobs stay network-only, including encoded and
  dot-segment forms.
- The network-first strategy marks a response answered from its cache
  with X-MealQuest-Cache-Fallback; the client counts it as offline, so
  the indicator shows while navigator.onLine is still true.

Tests: offline reload of /plan and /dinner/:id after an online visit,
no saved pages without a verified session or after a 401, reconnect
reloads /me; the real Workbox NetworkFirst strategy against an
in-memory cache opens a previously viewed recipe offline, marked; the
pill shows for a cache fallback while online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 09:10:53 -04:00
co-authored by Claude Opus 5.5
parent 1fa295b131
commit 7f8ae90978
17 changed files with 436 additions and 47 deletions
+19 -3
View File
@@ -1,7 +1,8 @@
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 { ApiProvider, createApiClient, createVerifiedSession, type ApiClient } from '../api/index.ts'
import { fakeLocation, jsonResponse, ME, stubFetch } from '../test/fetchStub.ts'
import { cacheFallbackHeader } from '../sw/routes.ts'
import { createNetworkStatus } from './networkStatus.ts'
import { OfflinePill } from './OfflinePill.tsx'
@@ -30,7 +31,7 @@ function goOnline() {
}
function renderPill(): ApiClient {
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<ApiProvider client={api}>
<OfflinePill />
@@ -80,6 +81,21 @@ describe('OfflinePill', () => {
expect(screen.queryByRole('status')).toBeNull()
})
it('shows while online when the service worker answers from its cache', async () => {
const api = renderPill()
const cached = jsonResponse(200, { id: '01J9ZR' })
cached.headers.set(cacheFallbackHeader, '1')
stubFetch(cached, jsonResponse(200, { id: '01J9ZR' }))
await act(() => api.apiFetch('/api/v1/recipes/01J9ZR'))
expect(navigator.onLine).toBe(true)
expect(screen.getByRole('status')).toHaveTextContent(copy)
// The server answers again.
await act(() => api.apiFetch('/api/v1/recipes/01J9ZR'))
expect(screen.queryByRole('status')).toBeNull()
})
it('ignores a request body that cannot be serialized', async () => {
const api = renderPill()
const fetchMock = stubFetch(jsonResponse(200, ME))
@@ -119,7 +135,7 @@ describe('OfflinePill', () => {
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 api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
const { unmount } = render(
<ApiProvider client={api}>
<OfflinePill />