From 3a1884de38ffa612efdb11ccccae1df71f38350a Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 02:28:01 -0400 Subject: [PATCH] Keep week mutations bounded for the query client's lifetime, not Plan's The cleanup of settled swaps and pushes was subscribed only while a Draft board was mounted, so one that settled after the household left Plan stayed in the mutation cache with gcTime Infinity. keepWeekMutations is now installed once on the query client's own mutation cache and never removed; the hooks only mark which week is shown. Pending ones, and those an earlier pending one needs, are still kept. Co-Authored-By: Claude Opus 5.5 --- web/src/plan/api.test.tsx | 66 +++++++++++++++++++++- web/src/plan/api.ts | 114 +++++++++++++++++++++++++------------- 2 files changed, 140 insertions(+), 40 deletions(-) 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,