Collect settled week mutations when they settle, not only on a week change
CI / ci (push) Successful in 4m22s
CI / ci (pull_request) Successful in 4m23s

The shared cleanup of swap and push mutations also runs whenever one of
its kind settles, so an entry that settles while another week is shown is
no longer kept indefinitely under gcTime: Infinity. Pending mutations, and
the settled ones of a week that still has one pending, are never removed;
a left week's latest mutation, when it failed, stays until another week is
shown so coming straight back still shows the failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 02:07:05 -04:00
co-authored by Claude Opus 5.5
parent 8a02e04110
commit 5c8484cfb8
2 changed files with 92 additions and 11 deletions
+67
View File
@@ -269,4 +269,71 @@ describe('week mutations', () => {
hook.rerender({ week: LATER })
expect(weekMutations(queryClient, NEXT)).toEqual([])
})
it('leaves behind what settles in a week no longer shown, unless that week still has one pending or it is the failure that speaks for it', async () => {
const friday = deferred()
const monday = deferred()
const nextPush = deferred()
const laterPush = deferred()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`PUT ${FRIDAY}`]: () => friday.reply,
[`PUT ${MONDAY}`]: () => monday.reply,
[`GET /api/v1/plans/${NEXT}`]: () => errorResponse(404, 'not_found'),
[`POST /api/v1/plans/${NEXT}/push-calendar`]: () => nextPush.reply,
[`GET /api/v1/plans/${LATER}`]: () => errorResponse(404, 'not_found'),
[`POST /api/v1/plans/${LATER}/push-calendar`]: () => laterPush.reply,
})
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient = createQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
const hook = renderHook(
({ week }) => ({ plan: usePlan(week), swap: useSwapDay(week), push: usePushToCalendar(week) }),
{ wrapper, initialProps: { week: WEEK } },
)
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
// Two swaps of the week and a push of the next are left pending, and a
// third week is shown.
act(() => hook.result.current.swap.mutate(ORZO))
act(() => hook.result.current.swap.mutate(FISH))
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap pending']))
hook.rerender({ week: NEXT })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
act(() => hook.result.current.push.mutate())
hook.rerender({ week: LATER })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
expect(weekMutations(queryClient, NEXT)).toEqual(['push pending'])
// The later swap settles first: the earlier, still pending, must see it,
// so both stay.
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']))
// The push succeeds while the third week is shown: it is left behind,
// and the week's pending swap is not.
await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT })))
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual([]))
expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success'])
// Once the earlier swap fails, the week has none pending: both are left
// behind, the failure not being the week's latest.
await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable')))
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([]))
// A push of the third week fails once another is shown: it stays, to be
// shown on coming back, until yet another week is.
act(() => hook.result.current.push.mutate())
hook.rerender({ week: NEXT })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
await act(async () => laterPush.send(errorResponse(500, 'internal')))
await waitFor(() => expect(weekMutations(queryClient, LATER)).toEqual(['push error']))
hook.rerender({ week: WEEK })
expect(weekMutations(queryClient, LATER)).toEqual([])
})
})
+25 -11
View File
@@ -185,20 +185,34 @@ type WeekMutation = 'swap' | 'push'
// 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. The
// cache holds at most the week shown's and those of weeks left pending.
// 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 left = mutations.findAll({
predicate: ({ options: { mutationKey: key } }) =>
key !== undefined && key.length === 3 && key[0] === 'plans' && key[1] !== weekStart && key[2] === kind,
})
const pending = new Set(left.filter((m) => m.state.status === 'pending').map((m) => m.options.mutationKey![1]))
for (const mutation of left) {
if (!pending.has(mutation.options.mutationKey![1])) mutations.remove(mutation)
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
mutations.remove(mutation)
}
}
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])
}
@@ -252,7 +266,7 @@ export function useSwapDay(weekStart: string): SwapDay {
const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId)
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
mutationKey,
// Kept until dismissed or its week is left settled
// Kept until dismissed or settled in a week not shown
// (useWeekMutationsKept): a later swap must stay in the cache while an
// earlier one is pending.
gcTime: Infinity,
@@ -350,7 +364,7 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
function mutate() {
const mutation = mutations.build<Plan, Error, void, unknown>(queryClient, {
mutationKey,
// Kept until reset or its week is left settled (useWeekMutationsKept):
// Kept until reset or settled in a week not shown (useWeekMutationsKept):
// a push pending in a week left must still be found when it is shown
// again.
gcTime: Infinity,