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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
|
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 type { ReactNode } from 'react'
|
||||||
|
import { MemoryRouter, Route, Routes, useNavigate, type NavigateFunction } from 'react-router'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.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 WEEK = '2026-10-05'
|
||||||
const PLAN = `/api/v1/plans/${WEEK}`
|
const PLAN = `/api/v1/plans/${WEEK}`
|
||||||
@@ -336,4 +337,65 @@ describe('week mutations', () => {
|
|||||||
hook.rerender({ week: WEEK })
|
hook.rerender({ week: WEEK })
|
||||||
expect(weekMutations(queryClient, LATER)).toEqual([])
|
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<typeof usePlan>; 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(
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={['/plan']}>
|
||||||
|
<Navigator />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/plan" element={<Week />} />
|
||||||
|
<Route path="/settings" element={null} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ApiProvider>,
|
||||||
|
)
|
||||||
|
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<Plan>(planKey(WEEK))).toMatchObject({ status: 'synced', calendar_synced_at: new Date('2026-10-08T06:00:00Z') })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+74
-36
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react'
|
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 { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
||||||
import { isRecord } from '../api/errors.ts'
|
import { isRecord } from '../api/errors.ts'
|
||||||
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.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
|
// weekMutation is the kind and week of a swap or push of a week the mutation
|
||||||
// it: its swaps and its pushes.
|
// cache keeps for it, or null for any other mutation.
|
||||||
type WeekMutation = 'swap' | 'push'
|
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
|
// WeeksShown is what keepWeekMutations is told of the weeks shown: show
|
||||||
// week is shown, the settled ones of every other week are removed, unless
|
// marks a week shown until the function it returns is called.
|
||||||
// that week still has one pending: a pending one, and those an earlier
|
type WeeksShown = { show: (weekStart: string) => () => void }
|
||||||
// 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
|
const weeksShown = new WeakMap<QueryClient, WeeksShown>()
|
||||||
// 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
|
// keepWeekMutations bounds the swaps and pushes the mutation cache of
|
||||||
// weeks left pending, and one failure of each week whose last pending one
|
// queryClient keeps, for as long as the query client lives, whatever is
|
||||||
// settled since the week shown was.
|
// shown: it is installed on the client's own mutation cache once, the first
|
||||||
function useWeekMutationsKept(weekStart: string, kind: WeekMutation) {
|
// time a week is shown, and is never removed, so a swap or push that settles
|
||||||
const queryClient = useQueryClient()
|
// after its Draft board, or the whole Plan, is gone is still collected.
|
||||||
useEffect(() => {
|
//
|
||||||
|
// 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()
|
const mutations = queryClient.getMutationCache()
|
||||||
const isKind = (key: readonly unknown[] | undefined) => key !== undefined && key.length === 3 && key[0] === 'plans' && key[2] === kind
|
// How many times each week is shown at once.
|
||||||
|
const shown = new Map<string, number>()
|
||||||
function sweep(settled: boolean) {
|
function sweep(settled: boolean) {
|
||||||
const left = mutations.findAll({
|
// The mutations of each kind of each week not shown, in the order sent.
|
||||||
predicate: ({ options: { mutationKey: key } }) => isKind(key) && key![1] !== weekStart,
|
const left = new Map<string, Mutation[]>()
|
||||||
})
|
for (const mutation of mutations.getAll()) {
|
||||||
const pending = new Set(left.filter((m) => m.state.status === 'pending').map((m) => m.options.mutationKey![1]))
|
const of = weekMutation(mutation)
|
||||||
const latest = new Map(left.map((m) => [m.options.mutationKey![1], m]))
|
if (of === null || shown.has(of.week)) continue
|
||||||
for (const mutation of left) {
|
const id = `${of.kind} ${of.week}`
|
||||||
const week = mutation.options.mutationKey![1]
|
left.set(id, [...(left.get(id) ?? []), mutation])
|
||||||
if (pending.has(week)) continue
|
}
|
||||||
if (settled && latest.get(week) === mutation && mutation.state.status === 'error') continue
|
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.remove(mutation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sweep(false)
|
}
|
||||||
return mutations.subscribe((event) => {
|
mutations.subscribe((event) => {
|
||||||
if (event.type === 'updated' && isKind(event.mutation.options.mutationKey) && event.mutation.state.status !== 'pending') sweep(true)
|
if (event.type === 'updated' && weekMutation(event.mutation) !== null && event.mutation.state.status !== 'pending') sweep(true)
|
||||||
})
|
})
|
||||||
}, [queryClient, weekStart, kind])
|
const show = (weekStart: string) => {
|
||||||
|
shown.set(weekStart, (shown.get(weekStart) ?? 0) + 1)
|
||||||
|
sweep(false)
|
||||||
|
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
|
// 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
|
// 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
|
// 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
|
// 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 {
|
export function useSwapDay(weekStart: string): SwapDay {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
useWeekMutationsKept(weekStart, 'swap')
|
useWeekShown(weekStart)
|
||||||
const key = planKey(weekStart)
|
const key = planKey(weekStart)
|
||||||
const mutationKey = swapKey(weekStart)
|
const mutationKey = swapKey(weekStart)
|
||||||
const mutations = queryClient.getMutationCache()
|
const mutations = queryClient.getMutationCache()
|
||||||
@@ -267,7 +305,7 @@ export function useSwapDay(weekStart: string): SwapDay {
|
|||||||
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
|
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
|
||||||
mutationKey,
|
mutationKey,
|
||||||
// Kept until dismissed or settled in a week not shown
|
// 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.
|
// earlier one is pending.
|
||||||
gcTime: Infinity,
|
gcTime: Infinity,
|
||||||
mutationFn: async ({ date, dinner }) => {
|
mutationFn: async ({ date, dinner }) => {
|
||||||
@@ -346,11 +384,11 @@ export type PushToCalendar = {
|
|||||||
// swaps do, so leaving the week and coming back keeps one pending. reset
|
// 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
|
// leaves settled pushes behind; it is refused while one is pending. Settled
|
||||||
// pushes of a week no longer shown are left behind too
|
// pushes of a week no longer shown are left behind too
|
||||||
// (useWeekMutationsKept).
|
// (keepWeekMutations).
|
||||||
export function usePushToCalendar(weekStart: string): PushToCalendar {
|
export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
useWeekMutationsKept(weekStart, 'push')
|
useWeekShown(weekStart)
|
||||||
const key = planKey(weekStart)
|
const key = planKey(weekStart)
|
||||||
const mutationKey = pushKey(weekStart)
|
const mutationKey = pushKey(weekStart)
|
||||||
const mutations = queryClient.getMutationCache()
|
const mutations = queryClient.getMutationCache()
|
||||||
@@ -364,7 +402,7 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
|
|||||||
function mutate() {
|
function mutate() {
|
||||||
const mutation = mutations.build<Plan, Error, void, unknown>(queryClient, {
|
const mutation = mutations.build<Plan, Error, void, unknown>(queryClient, {
|
||||||
mutationKey,
|
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
|
// a push pending in a week left must still be found when it is shown
|
||||||
// again.
|
// again.
|
||||||
gcTime: Infinity,
|
gcTime: Infinity,
|
||||||
|
|||||||
Reference in New Issue
Block a user