Offline toggle queue for the Supply run (Feature 17) #138
@@ -1,4 +1,5 @@
|
|||||||
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
|
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router'
|
import { Link, useSearchParams } from 'react-router'
|
||||||
import { Unavailable } from '../plan/PlanPage.tsx'
|
import { Unavailable } from '../plan/PlanPage.tsx'
|
||||||
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
|
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
|
||||||
@@ -249,12 +250,15 @@ function ChosenSupplyRun() {
|
|||||||
const settings = useHouseholdSettings()
|
const settings = useHouseholdSettings()
|
||||||
const [params] = useSearchParams()
|
const [params] = useSearchParams()
|
||||||
const chosen = params.get(WEEK_PARAM)
|
const chosen = params.get(WEEK_PARAM)
|
||||||
|
// Read once, not on every render: a failed read reports a problem, which
|
||||||
|
// renders this again.
|
||||||
|
const [saved] = useState(() => keep(savedSchedule))
|
||||||
|
|
||||||
if (chosen !== null && !isMonday(chosen)) {
|
if (chosen !== null && !isMonday(chosen)) {
|
||||||
return <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
|
return <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
|
||||||
}
|
}
|
||||||
// fetch rejects with a TypeError only when the network fails.
|
// fetch rejects with a TypeError only when the network fails.
|
||||||
const schedule = settings.data ?? (settings.error instanceof TypeError ? keep(savedSchedule) : null)
|
const schedule = settings.data ?? (settings.error instanceof TypeError ? saved : null)
|
||||||
if (settings.isError && !schedule) {
|
if (settings.isError && !schedule) {
|
||||||
return <ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
|
return <ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-14
@@ -7,7 +7,7 @@ import {
|
|||||||
type MutationObserverOptions,
|
type MutationObserverOptions,
|
||||||
type QueryClient,
|
type QueryClient,
|
||||||
} from '@tanstack/react-query'
|
} from '@tanstack/react-query'
|
||||||
import { useEffect, useReducer, useSyncExternalStore } from 'react'
|
import { useEffect, useReducer, useRef, useSyncExternalStore } from 'react'
|
||||||
import { ApiError, useApi, type ApiClient } from '../api/index.ts'
|
import { ApiError, useApi, type ApiClient } from '../api/index.ts'
|
||||||
import { isRecord } from '../api/errors.ts'
|
import { isRecord } from '../api/errors.ts'
|
||||||
import { meQueryKey } from '../AuthBootstrap.tsx'
|
import { meQueryKey } from '../AuthBootstrap.tsx'
|
||||||
@@ -200,6 +200,10 @@ function toggleKey(weekStart: string) {
|
|||||||
return ['shopping', weekStart, 'toggle'] as const
|
return ['shopping', weekStart, 'toggle'] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOGGLE_SCOPE is the one mutation scope of every toggle of every week, made
|
||||||
|
// here or replayed from the queue saved on this device.
|
||||||
|
const TOGGLE_SCOPE = 'shopping toggle'
|
||||||
|
|
||||||
// Toggles is the toggles of one week's Supply run: shown, the Supply run
|
// Toggles is the toggles of one week's Supply run: shown, the Supply run
|
||||||
// with those still pending laid over it, the items whose toggles will sync
|
// with those still pending laid over it, the items whose toggles will sync
|
||||||
// once the server can be reached, the latest failed toggle no later toggle
|
// once the server can be reached, the latest failed toggle no later toggle
|
||||||
@@ -228,12 +232,13 @@ function reachable(network: NetworkStatus): Promise<void> {
|
|||||||
// made on the Supply run screen (useToggles) or replayed from the queue saved
|
// made on the Supply run screen (useToggles) or replayed from the queue saved
|
||||||
// on this device (useSupplyRunSync).
|
// on this device (useSupplyRunSync).
|
||||||
//
|
//
|
||||||
// The week's toggles are sent one at a time, in the order made (one
|
// Toggles are sent one at a time, in the order made, whatever their week
|
||||||
// mutation scope), so the counts each is answered with include every toggle
|
// (one mutation scope, TOGGLE_SCOPE), so the counts each is answered with
|
||||||
// before it and none after. While the server cannot be reached — the device
|
// include every toggle before it and none after, and a replayed toggle never
|
||||||
// is offline, or the toggle's PATCH failed with a network error (fetch
|
// overlaps another replayed or made here. While the server cannot be
|
||||||
// rejected with a TypeError) — a toggle waits, pending, and is sent once it
|
// reached — the device is offline, or the toggle's PATCH failed with a
|
||||||
// can be: those after it wait behind it. Any answer from the server settles
|
// network error (fetch rejected with a TypeError) — a toggle waits, pending,
|
||||||
|
// and is sent once it can be: those after it wait behind it. Any answer from the server settles
|
||||||
// it: a refusal (a 400, 404, 409 or any other error response) or a response
|
// it: a refusal (a 400, 404, 409 or any other error response) or a response
|
||||||
// that is not the item as toggled fails it, and it is not sent again. Before
|
// that is not the item as toggled fails it, and it is not sent again. Before
|
||||||
// a toggle is sent, GET /api/v1/me has answered, so it carries the session's
|
// a toggle is sent, GET /api/v1/me has answered, so it carries the session's
|
||||||
@@ -250,7 +255,7 @@ function toggleOptions(
|
|||||||
const mutationKey = toggleKey(weekStart)
|
const mutationKey = toggleKey(weekStart)
|
||||||
return {
|
return {
|
||||||
mutationKey,
|
mutationKey,
|
||||||
scope: { id: mutationKey.join(' ') },
|
scope: { id: TOGGLE_SCOPE },
|
||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
retry: false,
|
retry: false,
|
||||||
gcTime: Infinity,
|
gcTime: Infinity,
|
||||||
@@ -312,8 +317,9 @@ function toggleOptions(
|
|||||||
// the counts it answers with there. Until then the toggle is pending and is
|
// the counts it answers with there. Until then the toggle is pending and is
|
||||||
// laid over the Supply run shown, so the item and the needed count change at
|
// laid over the Supply run shown, so the item and the needed count change at
|
||||||
// once; a refused toggle just stops being pending, which puts both back.
|
// once; a refused toggle just stops being pending, which puts both back.
|
||||||
// While the server cannot be reached, the items with pending toggles will
|
// An item whose pending toggle was made, or waited, while the server could
|
||||||
// sync.
|
// not be reached will sync: it says so until that toggle settles, even once
|
||||||
|
// the server can be reached again.
|
||||||
//
|
//
|
||||||
// The Supply run and the toggles are read straight from the query and
|
// The Supply run and the toggles are read straight from the query and
|
||||||
// mutation caches as each render begins, never from the caches' batched
|
// mutation caches as each render begins, never from the caches' batched
|
||||||
@@ -339,6 +345,9 @@ export function useToggles(weekStart: string): Toggles {
|
|||||||
useMutationState({ filters: { mutationKey } })
|
useMutationState({ filters: { mutationKey } })
|
||||||
const offline = useSyncExternalStore(api.network.subscribe, api.network.offline)
|
const offline = useSyncExternalStore(api.network.subscribe, api.network.offline)
|
||||||
const [, renderAgain] = useReducer((n: number) => n + 1, 0)
|
const [, renderAgain] = useReducer((n: number) => n + 1, 0)
|
||||||
|
// The keys of the pending toggles seen while the server could not be
|
||||||
|
// reached: every change of the network status renders again.
|
||||||
|
const waited = useRef<ReadonlySet<string>>(new Set())
|
||||||
const run = queryClient.getQueryData<SupplyRun | null>(key)
|
const run = queryClient.getQueryData<SupplyRun | null>(key)
|
||||||
if (!run) throw new Error(`The week of ${weekStart} has no Supply run read to toggle items of.`)
|
if (!run) throw new Error(`The week of ${weekStart} has no Supply run read to toggle items of.`)
|
||||||
const toggles = queryClient
|
const toggles = queryClient
|
||||||
@@ -346,13 +355,14 @@ export function useToggles(weekStart: string): Toggles {
|
|||||||
.findAll({ mutationKey })
|
.findAll({ mutationKey })
|
||||||
.map((mutation) => ({ status: mutation.state.status, toggle: mutation.state.variables as KeyedToggle, error: mutation.state.error }))
|
.map((mutation) => ({ status: mutation.state.status, toggle: mutation.state.variables as KeyedToggle, error: mutation.state.error }))
|
||||||
const pending = toggles.filter((t) => t.status === 'pending').map((t) => t.toggle)
|
const pending = toggles.filter((t) => t.status === 'pending').map((t) => t.toggle)
|
||||||
|
waited.current = new Set(pending.filter((t) => offline || waited.current.has(t.key)).map((t) => t.key))
|
||||||
const failed = toggles
|
const failed = toggles
|
||||||
.filter((t, i) => t.status === 'error' && !toggles.slice(i + 1).some((later) => later.toggle.id === t.toggle.id))
|
.filter((t, i) => t.status === 'error' && !toggles.slice(i + 1).some((later) => later.toggle.id === t.toggle.id))
|
||||||
.at(-1)
|
.at(-1)
|
||||||
const { mutate } = useMutation(toggleOptions(weekStart, api, queryClient))
|
const { mutate } = useMutation(toggleOptions(weekStart, api, queryClient))
|
||||||
return {
|
return {
|
||||||
shown: shown(run, pending),
|
shown: shown(run, pending),
|
||||||
willSync: new Set(offline ? pending.map((toggle) => toggle.id) : []),
|
willSync: new Set(pending.filter((t) => waited.current.has(t.key)).map((t) => t.id)),
|
||||||
failed: failed === undefined ? null : { toggle: { id: failed.toggle.id, needed: failed.toggle.needed }, error: failed.error! },
|
failed: failed === undefined ? null : { toggle: { id: failed.toggle.id, needed: failed.toggle.needed }, error: failed.error! },
|
||||||
toggle: ({ id, needed }) => {
|
toggle: ({ id, needed }) => {
|
||||||
const queuedToggle = { key: crypto.randomUUID(), weekStart, id, needed }
|
const queuedToggle = { key: crypto.randomUUID(), weekStart, id, needed }
|
||||||
@@ -367,9 +377,18 @@ export function useToggles(weekStart: string): Toggles {
|
|||||||
// to open offline — the household's schedule and each Supply run the server
|
// to open offline — the household's schedule and each Supply run the server
|
||||||
// sends (saveRun) — and replays, in the order made, the toggles saved in the
|
// sends (saveRun) — and replays, in the order made, the toggles saved in the
|
||||||
// queue that are not already pending here: those of a page reloaded before
|
// queue that are not already pending here: those of a page reloaded before
|
||||||
// the server answered them. A replayed toggle's answer is written over the
|
// the server answered them.
|
||||||
// Supply run saved, so the week's Supply run is cached from it first; it
|
//
|
||||||
// is still read again (updatedAt 0) when the Supply run screen opens.
|
// Replay is at-least-once, not exactly-once: a toggle leaves the saved queue
|
||||||
|
// only once its answer settles here, so one the server applied just before a
|
||||||
|
// reload is sent again. That duplicate is safe because a toggle sets needed
|
||||||
|
// to a fixed value — the same PATCH applied twice leaves the item and the
|
||||||
|
// counts as applying it once does — so no key is sent for the server to
|
||||||
|
// deduplicate by.
|
||||||
|
//
|
||||||
|
// A replayed toggle's answer is written over the Supply run saved, so the
|
||||||
|
// week's Supply run is cached from it first; it is still read again
|
||||||
|
// (updatedAt 0) when the Supply run screen opens.
|
||||||
export function useSupplyRunSync() {
|
export function useSupplyRunSync() {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ const ITEMS = [
|
|||||||
{ id: 'i3', name: 'Taco seasoning', qty: '1 packet', needed: false },
|
{ id: 'i3', name: 'Taco seasoning', qty: '1 packet', needed: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Next week's curry: one item to buy.
|
||||||
|
const NEXT_ITEMS = [{ id: 'n1', name: 'Chicken thighs', qty: '2 lb', needed: true }]
|
||||||
|
|
||||||
|
// The items of each week, by its Monday.
|
||||||
|
const WEEKS: Record<string, typeof ITEMS> = { '2026-10-05': ITEMS, '2026-10-12': NEXT_ITEMS }
|
||||||
|
|
||||||
|
function itemsOf(week: string) {
|
||||||
|
const items = WEEKS[week]
|
||||||
|
if (items === undefined) throw new Error(`no week ${week}`)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
// The browser's online flag; goOffline and goOnline change it and send the
|
// The browser's online flag; goOffline and goOnline change it and send the
|
||||||
// page's window the event a browser sends.
|
// page's window the event a browser sends.
|
||||||
let onLine: boolean
|
let onLine: boolean
|
||||||
@@ -53,51 +65,78 @@ function goOnline() {
|
|||||||
act(() => void win.dispatchEvent(new Event('online')))
|
act(() => void win.dispatchEvent(new Event('online')))
|
||||||
}
|
}
|
||||||
|
|
||||||
// stubServer is the server's Supply run of the week: a PATCH marks its item
|
// stubServer is the server's Supply runs of this week and next: a PATCH
|
||||||
// and answers with its counts, unless refuse answers it instead. It counts
|
// marks its item and answers with its week's counts, unless refuse answers
|
||||||
// as archived once archive is called.
|
// it instead; when hold is given, the item is marked at once but the answer
|
||||||
function stubServer(refuse: (id: string) => Response | undefined = () => undefined) {
|
// waits for the promise hold returns. This week's counts as archived once
|
||||||
const needed = new Map(ITEMS.map((item) => [item.id, item.needed]))
|
// archive is called.
|
||||||
|
function stubServer(refuse: (id: string) => Response | undefined = () => undefined, hold?: () => Promise<void>) {
|
||||||
|
const needed = new Map(Object.values(WEEKS).flatMap((items) => items.map((item) => [item.id, item.needed] as const)))
|
||||||
let status = 'current'
|
let status = 'current'
|
||||||
const counts = () => ({ needed_count: [...needed.values()].filter(Boolean).length, total_count: needed.size })
|
// How many PATCHes are unanswered now, and the most there have been at once.
|
||||||
const run = () => ({
|
let inFlight = 0
|
||||||
weekStart: '2026-10-05',
|
let mostInFlight = 0
|
||||||
label: 'Week 41 · Oct 5 – 11',
|
const counts = (week: string) => {
|
||||||
|
const marks = itemsOf(week).map((item) => needed.get(item.id)!)
|
||||||
|
return { needed_count: marks.filter(Boolean).length, total_count: marks.length }
|
||||||
|
}
|
||||||
|
const run = (week: string) => ({
|
||||||
|
weekStart: week,
|
||||||
|
label: week === '2026-10-05' ? 'Week 41 · Oct 5 – 11' : 'Week 42 · Oct 12 – 18',
|
||||||
title: null,
|
title: null,
|
||||||
status,
|
status: week === '2026-10-05' ? status : 'current',
|
||||||
sent_to_chat_at: null,
|
sent_to_chat_at: null,
|
||||||
...counts(),
|
...counts(week),
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
dinner: { id: 'd0', name: 'Beef tacos' },
|
dinner: { id: week === '2026-10-05' ? 'd0' : 'd1', name: week === '2026-10-05' ? 'Beef tacos' : 'Chicken curry' },
|
||||||
date: '2026-10-05',
|
date: week,
|
||||||
items: ITEMS.map((item) => ({ ...item, needed: needed.get(item.id)! })),
|
items: itemsOf(week).map((item) => ({ ...item, needed: needed.get(item.id)! })),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
|
||||||
if (!onLine || !reachable) return Promise.reject(new TypeError('Failed to fetch'))
|
if (!onLine || !reachable) throw new TypeError('Failed to fetch')
|
||||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||||
if (key === 'GET /api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
if (key === 'GET /api/v1/me') return jsonResponse(200, ME)
|
||||||
if (key === 'GET /api/v1/settings/household') return Promise.resolve(jsonResponse(200, HOUSEHOLD_SETTINGS))
|
if (key === 'GET /api/v1/settings/household') return jsonResponse(200, HOUSEHOLD_SETTINGS)
|
||||||
if (key === `GET ${THIS_WEEK}`) return Promise.resolve(jsonResponse(200, run()))
|
if (key === `GET ${THIS_WEEK}`) return jsonResponse(200, run('2026-10-05'))
|
||||||
const item = ITEMS.find((i) => key === `PATCH ${ITEM(i.id)}`)
|
const week = Object.keys(WEEKS).find((w) => itemsOf(w).some((i) => key === `PATCH ${ITEM(i.id)}`))
|
||||||
if (item === undefined) throw new Error(`unexpected fetch: ${key}`)
|
if (week === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||||
const refusal = refuse(item.id)
|
const id = itemsOf(week).find((i) => key === `PATCH ${ITEM(i.id)}`)!.id
|
||||||
if (refusal !== undefined) return Promise.resolve(refusal)
|
const refusal = refuse(id)
|
||||||
|
if (refusal !== undefined) return refusal
|
||||||
const body = JSON.parse(String(init?.body)) as { needed: boolean }
|
const body = JSON.parse(String(init?.body)) as { needed: boolean }
|
||||||
needed.set(item.id, body.needed)
|
needed.set(id, body.needed)
|
||||||
return Promise.resolve(jsonResponse(200, { id: item.id, needed: body.needed, ...counts() }))
|
const answer = jsonResponse(200, { id, needed: body.needed, ...counts(week) })
|
||||||
|
inFlight++
|
||||||
|
mostInFlight = Math.max(mostInFlight, inFlight)
|
||||||
|
await hold?.()
|
||||||
|
inFlight--
|
||||||
|
return answer
|
||||||
})
|
})
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
return {
|
return {
|
||||||
fetchMock,
|
fetchMock,
|
||||||
|
run,
|
||||||
|
mostInFlight: () => mostInFlight,
|
||||||
archive: () => {
|
archive: () => {
|
||||||
status = 'archived'
|
status = 'archived'
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// held is a hold for stubServer whose answers wait until release is called,
|
||||||
|
// each one released in turn.
|
||||||
|
function held() {
|
||||||
|
const waiting: (() => void)[] = []
|
||||||
|
return {
|
||||||
|
hold: () => new Promise<void>((resolve) => waiting.push(resolve)),
|
||||||
|
waiting: () => waiting.length,
|
||||||
|
release: () => waiting.shift()!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// patches lists the PATCHes sent, as "path body".
|
// patches lists the PATCHes sent, as "path body".
|
||||||
function patches(fetchMock: Mock<typeof fetch>) {
|
function patches(fetchMock: Mock<typeof fetch>) {
|
||||||
return fetchMock.mock.calls.filter(([, init]) => init?.method === 'PATCH').map(([input, init]) => `${String(input)} ${String(init?.body)}`)
|
return fetchMock.mock.calls.filter(([, init]) => init?.method === 'PATCH').map(([input, init]) => `${String(input)} ${String(init?.body)}`)
|
||||||
@@ -145,6 +184,7 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
Reflect.deleteProperty(window, 'matchMedia')
|
Reflect.deleteProperty(window, 'matchMedia')
|
||||||
@@ -277,3 +317,94 @@ describe('offline toggle queue', () => {
|
|||||||
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`]))
|
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`]))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('offline toggle queue, replay', () => {
|
||||||
|
it('replays a queue spanning two weeks strictly in order, one PATCH at a time, a toggle made meanwhile after it', async () => {
|
||||||
|
const answers = held()
|
||||||
|
const server = stubServer(undefined, answers.hold)
|
||||||
|
const queued = [
|
||||||
|
{ key: 'k1', weekStart: '2026-10-05', id: 'i3', needed: true },
|
||||||
|
{ key: 'k2', weekStart: '2026-10-12', id: 'n1', needed: false },
|
||||||
|
{ key: 'k3', weekStart: '2026-10-05', id: 'i1', needed: false },
|
||||||
|
]
|
||||||
|
const runs = { '2026-10-05': server.run('2026-10-05'), '2026-10-12': server.run('2026-10-12') }
|
||||||
|
localStorage.setItem(SAVED_KEY, JSON.stringify({ schedule: null, runs, queue: queued }))
|
||||||
|
|
||||||
|
await openPage()
|
||||||
|
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||||
|
fireEvent.click(await screen.findByRole('checkbox', { name: 'Corn tortillas' }))
|
||||||
|
for (let sent = 1; sent <= 4; sent++) {
|
||||||
|
await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(sent))
|
||||||
|
// The next one is not sent before this one is answered.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||||
|
expect(patches(server.fetchMock)).toHaveLength(sent)
|
||||||
|
act(() => answers.release())
|
||||||
|
}
|
||||||
|
await waitFor(() => expect(queue()).toEqual([]))
|
||||||
|
expect(patches(server.fetchMock)).toEqual([
|
||||||
|
`${ITEM('i3')} {"needed":true}`,
|
||||||
|
`${ITEM('n1')} {"needed":false}`,
|
||||||
|
`${ITEM('i1')} {"needed":false}`,
|
||||||
|
`${ITEM('i2')} {"needed":false}`,
|
||||||
|
])
|
||||||
|
expect(server.mostInFlight()).toBe(1)
|
||||||
|
await waitFor(() => expect(screen.getByText('1 / 3')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replays at least once: a toggle the server applied before a reload is sent again and leaves the item and count the same', async () => {
|
||||||
|
const answers = held()
|
||||||
|
const server = stubServer(undefined, answers.hold)
|
||||||
|
const first = await openPage()
|
||||||
|
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||||
|
fireEvent.click(checkbox('Taco seasoning'))
|
||||||
|
// The server applied it; the page reloads before its answer arrives.
|
||||||
|
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||||
|
expect(queue()).toEqual(['i3 true'])
|
||||||
|
first.unmount()
|
||||||
|
act(() => answers.release())
|
||||||
|
|
||||||
|
await openPage()
|
||||||
|
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||||
|
act(() => answers.release())
|
||||||
|
await waitFor(() => expect(queue()).toEqual([]))
|
||||||
|
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`])
|
||||||
|
await waitFor(() => expect(screen.getByText('3 / 3')).toBeInTheDocument())
|
||||||
|
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||||
|
expect(server.run('2026-10-05')).toMatchObject({ needed_count: 3, total_count: 3 })
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps "will sync" on a queued toggle until its delayed replay is answered', async () => {
|
||||||
|
const answers = held()
|
||||||
|
stubServer(undefined, answers.hold)
|
||||||
|
await openPage()
|
||||||
|
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||||
|
goOffline()
|
||||||
|
fireEvent.click(checkbox('Ground beef'))
|
||||||
|
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||||
|
|
||||||
|
goOnline()
|
||||||
|
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||||
|
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||||
|
expect(queue()).toEqual(['i1 false'])
|
||||||
|
|
||||||
|
act(() => answers.release())
|
||||||
|
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||||
|
expect(queue()).toEqual([])
|
||||||
|
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says the saved list is unreadable when it is corrupt offline, without rendering again and again', async () => {
|
||||||
|
stubServer()
|
||||||
|
onLine = false
|
||||||
|
localStorage.setItem(SAVED_KEY, '{corrupt')
|
||||||
|
const getItem = vi.spyOn(Storage.prototype, 'getItem')
|
||||||
|
await openPage()
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Couldn't load the household settings/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/can't be kept on this device while offline/)).toBeInTheDocument()
|
||||||
|
const reads = getItem.mock.calls.filter(([key]) => key === SAVED_KEY).length
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
expect(getItem.mock.calls.filter(([key]) => key === SAVED_KEY)).toHaveLength(reads)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user