From 8b985bb64ce5fd192483eb5c28b64a5383900517 Mon Sep 17 00:00:00 2001 From: David Uzumeri Date: Tue, 1 Sep 2026 15:42:24 -0400 Subject: [PATCH] fix(solid-query): untrack the result read in useMutationState's cache subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MutationCache notifies synchronously, so the subscription callback runs while the computation that called `mutate` is still the active listener. Reading the hook's own result signal there registered it as a dependency of that computation, which `setResult` then immediately invalidated — so calling `mutate` from inside an effect re-ran the effect on every mutation, and an unguarded `mutate` looped. Adds a regression test: an effect that mutates once runs twice before this change and once after. --- .../solid-query-mutation-state-untrack.md | 7 ++++ .../src/__tests__/useMutationState.test.tsx | 32 +++++++++++++++++++ packages/solid-query/src/useMutationState.ts | 16 ++++++++-- 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 .changeset/solid-query-mutation-state-untrack.md diff --git a/.changeset/solid-query-mutation-state-untrack.md b/.changeset/solid-query-mutation-state-untrack.md new file mode 100644 index 0000000000..225b48d77c --- /dev/null +++ b/.changeset/solid-query-mutation-state-untrack.md @@ -0,0 +1,7 @@ +--- +'@tanstack/solid-query': patch +--- + +fix(solid-query): stop `useMutationState` making the computation that triggers a mutation depend on its result + +The mutation-cache subscription read the hook's own result signal. `MutationCache` notifies synchronously, so that read happened while the computation that called `mutate` was still the active listener — registering the signal as one of its dependencies, which `setResult` then immediately invalidated. Calling `mutate` from inside an effect therefore re-ran that effect on every mutation, and an unguarded `mutate` looped. The read is now untracked. diff --git a/packages/solid-query/src/__tests__/useMutationState.test.tsx b/packages/solid-query/src/__tests__/useMutationState.test.tsx index 8bc837fb8f..1e1860a8a5 100644 --- a/packages/solid-query/src/__tests__/useMutationState.test.tsx +++ b/packages/solid-query/src/__tests__/useMutationState.test.tsx @@ -111,4 +111,36 @@ describe('useMutationState', () => { expect(variables).toEqual([[], [1], []]) }) + + it('should not make the computation that triggers a mutation depend on its result', async () => { + const mutationKey = queryKey() + let effectRuns = 0 + + function Page() { + const states = useMutationState(() => ({ filters: { mutationKey } })) + const mutation = useMutation(() => ({ + mutationKey, + mutationFn: (input: number) => sleep(150).then(() => 'data' + input), + })) + + createEffect(() => { + effectRuns++ + // Guarded so that a genuine feedback loop still terminates the test. + if (effectRuns === 1) { + mutation.mutate(1) + } + }) + + return
count: {states().length}
+ } + + renderWithClient(queryClient, () => ) + await vi.advanceTimersByTimeAsync(150) + + // `mutate` notifies the mutation cache synchronously, so the subscription + // callback runs while this effect is the active computation. Reading the + // result signal there used to register it as a dependency of that effect, + // which `setResult` then immediately invalidated. + expect(effectRuns).toBe(1) + }) }) diff --git a/packages/solid-query/src/useMutationState.ts b/packages/solid-query/src/useMutationState.ts index 82f39cc1b4..80afd797cf 100644 --- a/packages/solid-query/src/useMutationState.ts +++ b/packages/solid-query/src/useMutationState.ts @@ -1,4 +1,10 @@ -import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js' +import { + createEffect, + createMemo, + createSignal, + onCleanup, + untrack, +} from 'solid-js' import { replaceEqualDeep } from '@tanstack/query-core' import { useQueryClientResolver } from './QueryClientProvider' import type { @@ -66,11 +72,15 @@ export function useMutationState< createEffect(() => { const unsubscribe = mutationCache().subscribe(() => { + // `subscribe` invokes this synchronously, so reading `result` while the + // enclosing effect is still tracking would make the effect depend on the + // signal it sets, re-running and re-subscribing on every mutation. + const previousResult = untrack(result) const nextResult = replaceEqualDeep( - result(), + previousResult, getResult(mutationCache(), options()), ) - if (result() !== nextResult) { + if (previousResult !== nextResult) { setResult(nextResult) } })