diff --git a/web/src/plan/api.test.tsx b/web/src/plan/api.test.tsx index 94eb6fb..6571f62 100644 --- a/web/src/plan/api.test.tsx +++ b/web/src/plan/api.test.tsx @@ -1,11 +1,12 @@ import { QueryClientProvider, type QueryClient } from '@tanstack/react-query' -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, render, renderHook, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' +import { MemoryRouter, Route, Routes, useNavigate, type NavigateFunction } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' -import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan } from './api.ts' +import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan, type PushToCalendar, type SwapDay } from './api.ts' const WEEK = '2026-10-05' const PLAN = `/api/v1/plans/${WEEK}` @@ -336,4 +337,65 @@ describe('week mutations', () => { hook.rerender({ week: WEEK }) expect(weekMutations(queryClient, LATER)).toEqual([]) }) + + it('leaves behind what settles once Plan is left for another route, and keeps what is pending', async () => { + const friday = deferred() + const monday = deferred() + const push = deferred() + stubServer({ + [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), + [`PUT ${FRIDAY}`]: () => friday.reply, + [`PUT ${MONDAY}`]: () => monday.reply, + [`POST ${PLAN}/push-calendar`]: () => push.reply, + }) + const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const queryClient = createQueryClient() + let week!: { plan: ReturnType; swap: SwapDay; push: PushToCalendar } + let navigate!: NavigateFunction + function Week() { + week = { plan: usePlan(WEEK), swap: useSwapDay(WEEK), push: usePushToCalendar(WEEK) } + return null + } + function Navigator() { + navigate = useNavigate() + return null + } + render( + + + + + + } /> + + + + + , + ) + await waitFor(() => expect(week.plan.isSuccess).toBe(true)) + + // Two swaps and a push of the week are left pending, and Settings is + // shown. + act(() => week.swap.mutate(ORZO)) + act(() => week.swap.mutate(FISH)) + act(() => week.push.mutate()) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap pending', 'push pending'])) + act(() => void navigate('/settings')) + + // The swaps settle: once neither is pending they are left behind, and + // the push still pending is not. + await act(async () => monday.send(jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })))) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success', 'push pending'])) + await act(async () => friday.send(jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' }, 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })))) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['push pending'])) + + // The push succeeds: the plan as synced is recorded, and the push is + // left behind. + const synced = { ...planBody(), status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' } + await act(async () => push.send(jsonResponse(200, synced))) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([])) + expect(queryClient.getQueryData(planKey(WEEK))).toMatchObject({ status: 'synced', calendar_synced_at: new Date('2026-10-08T06:00:00Z') }) + }) }) diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index 027bed1..0a8ee4a 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useMutationState, useQuery, useQueryClient, type Mutation, type QueryClient } from '@tanstack/react-query' import { ApiError, useApi, type ApiRead } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts' @@ -178,42 +178,80 @@ function sameDay(a: PlanDay, b: PlanDay): boolean { ) } -// WeekMutation is a kind of mutation of a week the mutation cache keeps for -// it: its swaps and its pushes. -type WeekMutation = 'swap' | 'push' +// weekMutation is the kind and week of a swap or push of a week the mutation +// cache keeps for it, or null for any other mutation. +function weekMutation(mutation: Mutation): { kind: 'swap' | 'push'; week: string } | null { + const key = mutation.options.mutationKey + if (key === undefined || key.length !== 3 || key[0] !== 'plans' || (key[2] !== 'swap' && key[2] !== 'push')) return null + return { kind: key[2], week: String(key[1]) } +} -// useWeekMutationsKept bounds the mutations of kind the cache keeps. When a -// week is shown, the settled ones of every other week are removed, unless -// that week still has one pending: a pending one, and those an earlier -// pending one must see, stay across leaving its week and coming back. When -// one settles, the same is done, except that a week's latest, when it -// failed, stays until another week is shown, so that coming straight back to -// it shows the failure. The cache holds at most the week shown's, those of -// weeks left pending, and one failure of each week whose last pending one -// settled since the week shown was. -function useWeekMutationsKept(weekStart: string, kind: WeekMutation) { - const queryClient = useQueryClient() - useEffect(() => { - const mutations = queryClient.getMutationCache() - const isKind = (key: readonly unknown[] | undefined) => key !== undefined && key.length === 3 && key[0] === 'plans' && key[2] === kind - function sweep(settled: boolean) { - const left = mutations.findAll({ - predicate: ({ options: { mutationKey: key } }) => isKind(key) && key![1] !== weekStart, - }) - const pending = new Set(left.filter((m) => m.state.status === 'pending').map((m) => m.options.mutationKey![1])) - const latest = new Map(left.map((m) => [m.options.mutationKey![1], m])) - for (const mutation of left) { - const week = mutation.options.mutationKey![1] - if (pending.has(week)) continue - if (settled && latest.get(week) === mutation && mutation.state.status === 'error') continue +// WeeksShown is what keepWeekMutations is told of the weeks shown: show +// marks a week shown until the function it returns is called. +type WeeksShown = { show: (weekStart: string) => () => void } + +const weeksShown = new WeakMap() + +// keepWeekMutations bounds the swaps and pushes the mutation cache of +// queryClient keeps, for as long as the query client lives, whatever is +// shown: it is installed on the client's own mutation cache once, the first +// time a week is shown, and is never removed, so a swap or push that settles +// after its Draft board, or the whole Plan, is gone is still collected. +// +// When a week is shown, the settled ones of every other week are removed, +// unless that week still has one pending: a pending one, and those an +// earlier pending one must see, stay across leaving its week and coming +// back. When one settles, the same is done, except that a week's latest, +// when it failed, stays until a week is next shown, so that coming straight +// back to it shows the failure. The cache holds at most the weeks shown's, +// those of weeks left pending, and one failure of each week whose last +// pending one settled since a week was last shown. +function keepWeekMutations(queryClient: QueryClient): WeeksShown { + const kept = weeksShown.get(queryClient) + if (kept !== undefined) return kept + const mutations = queryClient.getMutationCache() + // How many times each week is shown at once. + const shown = new Map() + function sweep(settled: boolean) { + // The mutations of each kind of each week not shown, in the order sent. + const left = new Map() + for (const mutation of mutations.getAll()) { + const of = weekMutation(mutation) + if (of === null || shown.has(of.week)) continue + const id = `${of.kind} ${of.week}` + left.set(id, [...(left.get(id) ?? []), mutation]) + } + for (const week of left.values()) { + if (week.some((mutation) => mutation.state.status === 'pending')) continue + const latest = week.at(-1) + for (const mutation of week) { + if (settled && mutation === latest && mutation.state.status === 'error') continue mutations.remove(mutation) } } + } + mutations.subscribe((event) => { + if (event.type === 'updated' && weekMutation(event.mutation) !== null && event.mutation.state.status !== 'pending') sweep(true) + }) + const show = (weekStart: string) => { + shown.set(weekStart, (shown.get(weekStart) ?? 0) + 1) sweep(false) - return mutations.subscribe((event) => { - if (event.type === 'updated' && isKind(event.mutation.options.mutationKey) && event.mutation.state.status !== 'pending') sweep(true) - }) - }, [queryClient, weekStart, kind]) + return () => { + const times = shown.get(weekStart)! - 1 + if (times === 0) shown.delete(weekStart) + else shown.set(weekStart, times) + } + } + const keeping = { show } + weeksShown.set(queryClient, keeping) + return keeping +} + +// useWeekShown marks the week starting on weekStart shown while the +// component is mounted (keepWeekMutations). +function useWeekShown(weekStart: string) { + const queryClient = useQueryClient() + useEffect(() => keepWeekMutations(queryClient).show(weekStart), [queryClient, weekStart]) } // swapKey keys the swaps of the week starting on weekStart in the mutation @@ -246,11 +284,11 @@ export type SwapDay = { // failure is not the week's. dismiss leaves settled swaps behind; it is // refused while one is pending, so a later swap is always in the cache for // an earlier, pending one to see. Settled swaps of a week no longer shown -// are left behind too (useWeekMutationsKept). +// are left behind too (keepWeekMutations). export function useSwapDay(weekStart: string): SwapDay { const { apiFetch } = useApi() const queryClient = useQueryClient() - useWeekMutationsKept(weekStart, 'swap') + useWeekShown(weekStart) const key = planKey(weekStart) const mutationKey = swapKey(weekStart) const mutations = queryClient.getMutationCache() @@ -267,7 +305,7 @@ export function useSwapDay(weekStart: string): SwapDay { const mutation = mutations.build(queryClient, { mutationKey, // Kept until dismissed or settled in a week not shown - // (useWeekMutationsKept): a later swap must stay in the cache while an + // (keepWeekMutations): a later swap must stay in the cache while an // earlier one is pending. gcTime: Infinity, mutationFn: async ({ date, dinner }) => { @@ -346,11 +384,11 @@ export type PushToCalendar = { // swaps do, so leaving the week and coming back keeps one pending. reset // leaves settled pushes behind; it is refused while one is pending. Settled // pushes of a week no longer shown are left behind too -// (useWeekMutationsKept). +// (keepWeekMutations). export function usePushToCalendar(weekStart: string): PushToCalendar { const { apiFetch } = useApi() const queryClient = useQueryClient() - useWeekMutationsKept(weekStart, 'push') + useWeekShown(weekStart) const key = planKey(weekStart) const mutationKey = pushKey(weekStart) const mutations = queryClient.getMutationCache() @@ -364,7 +402,7 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { function mutate() { const mutation = mutations.build(queryClient, { mutationKey, - // Kept until reset or settled in a week not shown (useWeekMutationsKept): + // Kept until reset or settled in a week not shown (keepWeekMutations): // a push pending in a week left must still be found when it is shown // again. gcTime: Infinity,