Bound Supply run toggle tries, retry without an online event, keep "will sync" across navigation (Feature 17)
CI / ci (push) Successful in 5m29s
CI / ci (pull_request) Successful in 5m26s

- Each try of a toggle (GET /api/v1/me and the PATCH) has a 10 s deadline:
  past it the PATCH is aborted, the network marked failed, and the toggle
  stays pending at the head of the queue to be tried again.
- A toggle waiting while the server cannot be reached is tried again when
  the network status says it can be, or after a wait doubling from 1 s up
  to 30 s, so recovery never depends on a browser online event.
- The "will sync" keys live in a page-lifetime store in offline.ts, not in
  a component ref, so leaving and reopening the Supply run screen keeps
  the marker until the toggle settles.
- ApiRequest takes an optional abort signal; the client sets no deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 11:07:21 -04:00
co-authored by Claude Opus 5.5
parent 4ba6471070
commit 6d45f3ed0d
4 changed files with 212 additions and 39 deletions
+77 -22
View File
@@ -7,14 +7,26 @@ import {
type MutationObserverOptions,
type QueryClient,
} from '@tanstack/react-query'
import { useEffect, useReducer, useRef, useSyncExternalStore } from 'react'
import { useEffect, useReducer, useSyncExternalStore } from 'react'
import { ApiError, useApi, type ApiClient } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { meQueryKey } from '../AuthBootstrap.tsx'
import { oneOf } from '../dinners/api.ts'
import type { NetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS_KEY, type HouseholdSettings } from '../settings/api.ts'
import { dequeue, enqueue, keep, queued, SAVED_KEY, saveRun, savedRun, saveSchedule } from './offline.ts'
import {
dequeue,
enqueue,
keep,
markWillSync,
queued,
SAVED_KEY,
saveRun,
savedRun,
saveSchedule,
synced,
willSync,
} from './offline.ts'
export const SHOPPING_PATH = '/api/v1/shopping'
@@ -215,19 +227,45 @@ export type Toggles = {
toggle: (toggle: Toggle) => void
}
// reachable resolves once network says the server can be reached: at once
// when it says so now.
function reachable(network: NetworkStatus): Promise<void> {
// TOGGLE_DEADLINE is how long, in milliseconds, one try of a toggle — its
// GET /api/v1/me and its PATCH — may take before it counts as the server not
// answering. Only toggles have a deadline: the API client sets none.
export const TOGGLE_DEADLINE = 10_000
// RETRY_FIRST and RETRY_CEILING bound, in milliseconds, how long a toggle
// waits while the server cannot be reached before it is tried again anyway:
// the first wait, doubled after each failed try up to the ceiling.
export const RETRY_FIRST = 1_000
export const RETRY_CEILING = 30_000
// reachable resolves once network says the server can be reached — at once
// when it says so now — or after delay milliseconds, whichever is first. The
// server coming back says nothing by itself, and the browser sends no online
// event when it never thought it was offline, so the toggle is tried again
// after delay whatever network says.
function reachable(network: NetworkStatus, delay: number): Promise<void> {
if (!network.offline()) return Promise.resolve()
return new Promise((resolve) => {
const stop = network.subscribe(() => {
if (network.offline()) return
const done = () => {
clearTimeout(timer)
stop()
resolve()
}
const timer = setTimeout(done, delay)
const stop = network.subscribe(() => {
if (!network.offline()) done()
})
})
}
// beforeDeadline is promise, rejected with signal's reason once it aborts.
function beforeDeadline<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason as Error), { once: true })
promise.then(resolve, reject)
})
}
// toggleOptions is the mutation every toggle of the week's Supply run is,
// made on the Supply run screen (useToggles) or replayed from the queue saved
// on this device (useSupplyRunSync).
@@ -237,10 +275,16 @@ function reachable(network: NetworkStatus): Promise<void> {
// include every toggle before it and none after, and a replayed toggle never
// overlaps another replayed or made here. While the server cannot be
// reached — the device is offline, or the toggle's PATCH failed with a
// 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
// that is not the item as toggled fails it, and it is not sent again. Before
// network error (fetch rejected with a TypeError), or a try ran past its
// deadline (TOGGLE_DEADLINE) — a toggle waits, pending, and is tried again
// once network says the server can be reached, or after a wait that doubles
// from RETRY_FIRST up to RETRY_CEILING, whichever is first: those after it
// wait behind it, in order. A try past its deadline is aborted and marks the
// network failed, as a network error does, so the item says it will sync; the
// server may have applied the aborted PATCH, and sending it again is safe for
// the reason a replay is (see useSupplyRunSync). Any answer from the server
// settles 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
// a toggle is sent, GET /api/v1/me has answered, so it carries the session's
// CSRF token even when the app opened offline.
//
@@ -259,20 +303,30 @@ function toggleOptions(
networkMode: 'always',
retry: false,
gcTime: Infinity,
mutationFn: async ({ id, needed }: KeyedToggle): Promise<Counts> => {
mutationFn: async ({ key, id, needed }: KeyedToggle): Promise<Counts> => {
const path = `${SHOPPING_PATH}/items/${encodeURIComponent(id)}`
const where = `PATCH ${path}`
let body: unknown
for (;;) {
await reachable(api.network)
for (let failures = 0; ; failures++) {
if (api.network.offline()) markWillSync(key)
await reachable(api.network, Math.min(RETRY_FIRST * 2 ** failures, RETRY_CEILING))
const deadline = new AbortController()
const timer = setTimeout(() => deadline.abort(new DOMException(`${where}: no answer in time`, 'TimeoutError')), TOGGLE_DEADLINE)
try {
await queryClient.ensureQueryData({ queryKey: meQueryKey, queryFn: api.loadMe, networkMode: 'always' })
body = await api.apiFetch<unknown>(path, { method: 'PATCH', body: { needed } })
const me = queryClient.ensureQueryData({ queryKey: meQueryKey, queryFn: api.loadMe, networkMode: 'always' })
await beforeDeadline(me, deadline.signal)
body = await api.apiFetch<unknown>(path, { method: 'PATCH', body: { needed }, signal: deadline.signal })
break
} catch (error) {
if (deadline.signal.aborted) {
api.network.requestFailed()
continue
}
// The API client marks the network offline as fetch rejects; a
// TypeError it did not mark is not a network failure.
if (!(error instanceof TypeError) || !api.network.offline()) throw error
} finally {
clearTimeout(timer)
}
}
if (!isRecord(body) || body.id !== id || body.needed !== needed) throw new Error(`${where}: response is not item ${id} as toggled`)
@@ -307,6 +361,7 @@ function toggleOptions(
if ((earlier.state.variables as KeyedToggle).id === id) cache.remove(earlier)
}
keep(() => dequeue(settled))
synced(settled)
},
}
}
@@ -319,7 +374,8 @@ function toggleOptions(
// once; a refused toggle just stops being pending, which puts both back.
// An item whose pending toggle was made, or waited, while the server could
// not be reached will sync: it says so until that toggle settles, even once
// the server can be reached again.
// the server can be reached again, and even when the screen is left and
// opened again meanwhile (markWillSync keeps it with the page, not here).
//
// The Supply run and the toggles are read straight from the query and
// mutation caches as each render begins, never from the caches' batched
@@ -345,9 +401,6 @@ export function useToggles(weekStart: string): Toggles {
useMutationState({ filters: { mutationKey } })
const offline = useSyncExternalStore(api.network.subscribe, api.network.offline)
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)
if (!run) throw new Error(`The week of ${weekStart} has no Supply run read to toggle items of.`)
const toggles = queryClient
@@ -355,14 +408,16 @@ export function useToggles(weekStart: string): Toggles {
.findAll({ mutationKey })
.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)
waited.current = new Set(pending.filter((t) => offline || waited.current.has(t.key)).map((t) => t.key))
// Every change of the network status renders again, so each pending
// toggle seen while the server cannot be reached is marked.
if (offline) pending.forEach((t) => markWillSync(t.key))
const failed = toggles
.filter((t, i) => t.status === 'error' && !toggles.slice(i + 1).some((later) => later.toggle.id === t.toggle.id))
.at(-1)
const { mutate } = useMutation(toggleOptions(weekStart, api, queryClient))
return {
shown: shown(run, pending),
willSync: new Set(pending.filter((t) => waited.current.has(t.key)).map((t) => t.id)),
willSync: new Set(pending.filter((t) => willSync(t.key)).map((t) => t.id)),
failed: failed === undefined ? null : { toggle: { id: failed.toggle.id, needed: failed.toggle.needed }, error: failed.error! },
toggle: ({ id, needed }) => {
const queuedToggle = { key: crypto.randomUUID(), weekStart, id, needed }