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) } })