From a83fc93bddc5482b5417a0972c3662a6aa076484 Mon Sep 17 00:00:00 2001 From: Mahathir Mohammad Shuvo Date: Sat, 29 Aug 2026 15:54:01 +0600 Subject: [PATCH] fix(react-query): recompute useMutationState when the filters change After the first render, `result.current` was only rewritten from inside the `mutationCache.subscribe()` callback, and `getSnapshot` returns that ref, so a render carrying new `filters` or a new `select` recomputed nothing. The hook kept serving the previous options' result until something unrelated touched the cache, and if nothing did, it stayed wrong indefinitely. `useIsMutating` is built on this hook and went stale with it. It is now recomputed on render as well. `useIsFetching` does the same work inside `getSnapshot`, which it can afford because it returns a number; this hook returns an array, and a fresh one per call never satisfies the snapshot consistency check. So the array stays in a ref, and the recompute is guarded on the options the same way `QueryObserver` guards `select`, which keeps an unrelated render from re-running it. Fixes #11272 --- .changeset/tidy-carrots-shake.md | 8 + .../src/__tests__/useMutationState.test.tsx | 263 ++++++++++++++++++ packages/react-query/src/useMutationState.ts | 20 +- 3 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 .changeset/tidy-carrots-shake.md diff --git a/.changeset/tidy-carrots-shake.md b/.changeset/tidy-carrots-shake.md new file mode 100644 index 00000000000..c290dc88a07 --- /dev/null +++ b/.changeset/tidy-carrots-shake.md @@ -0,0 +1,8 @@ +--- +'@tanstack/react-query': patch +--- + +Recompute `useMutationState` when `filters` or `select` change. The result was only +rebuilt when the mutation cache notified, so a render carrying new options kept +serving the previous ones until something unrelated touched the cache. `useIsMutating` +is built on the same hook and was stale in the same way. diff --git a/packages/react-query/src/__tests__/useMutationState.test.tsx b/packages/react-query/src/__tests__/useMutationState.test.tsx index 69bd9720904..bd417297466 100644 --- a/packages/react-query/src/__tests__/useMutationState.test.tsx +++ b/packages/react-query/src/__tests__/useMutationState.test.tsx @@ -241,4 +241,267 @@ describe('useMutationState', () => { expect(variables).toEqual([[], [1], []]) }) + + it('should update the result when the filters change without a cache update', async () => { + const queryClient = new QueryClient() + const key1 = queryKey() + const key2 = queryKey() + + function Variables({ mutationKey }: { mutationKey: Array }) { + const variables = useMutationState({ + filters: { mutationKey }, + select: (mutation) => mutation.state.variables, + }) + + return
variables: {variables.join(',')}
+ } + + function Page() { + const [mutationKey, setMutationKey] = React.useState(key1) + const { mutate: mutate1 } = useMutation({ + mutationKey: key1, + mutationFn: (input: number) => sleep(10).then(() => 'data' + input), + }) + const { mutate: mutate2 } = useMutation({ + mutationKey: key2, + mutationFn: (input: number) => sleep(10).then(() => 'data' + input), + }) + + return ( +
+ + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('variables: 1')).toBeInTheDocument() + + // only the filters change - nothing touches the mutation cache + fireEvent.click(rendered.getByRole('button', { name: /switch/i })) + expect(rendered.getByText('variables: 2')).toBeInTheDocument() + }) + + it('should update the result when the select changes', async () => { + const queryClient = new QueryClient() + const key = queryKey() + + function Selected({ pick }: { pick: (m: any) => unknown }) { + const values = useMutationState({ + filters: { mutationKey: key }, + select: pick, + }) + + return
value: {String(values[0])}
+ } + + function Page() { + const [pick, setPick] = React.useState( + () => (m: any) => m.state.variables as unknown, + ) + const { mutate } = useMutation({ + mutationKey: key, + mutationFn: (input: number) => sleep(10).then(() => 'data' + input), + }) + + return ( +
+ + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('value: 7')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /switch/i })) + expect(rendered.getByText('value: success')).toBeInTheDocument() + }) + + it('should empty the result when the filters stop matching', async () => { + const queryClient = new QueryClient() + const key = queryKey() + const other = queryKey() + + function Variables({ mutationKey }: { mutationKey: Array }) { + const variables = useMutationState({ + filters: { mutationKey }, + select: (mutation) => mutation.state.variables, + }) + + return
count: {variables.length}
+ } + + function Page() { + const [mutationKey, setMutationKey] = React.useState(key) + const { mutate } = useMutation({ + mutationKey: key, + mutationFn: (input: number) => sleep(10).then(() => 'data' + input), + }) + + return ( +
+ + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('count: 1')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /switch/i })) + expect(rendered.getByText('count: 0')).toBeInTheDocument() + }) + + it('should keep the same result reference across an unrelated render', async () => { + const queryClient = new QueryClient() + const key = queryKey() + const seen: Array = [] + + function Variables() { + const variables = useMutationState({ + filters: { mutationKey: key }, + select: (mutation) => ({ ...mutation.state }), + }) + seen.push(variables) + + return null + } + + function Page() { + const [, rerender] = React.useState(0) + const { mutate } = useMutation({ + mutationKey: key, + mutationFn: (input: number) => sleep(10).then(() => 'data' + input), + }) + + return ( +
+ + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(11) + const before = seen.length + expect((seen[before - 1] as Array).length).toBe(1) + + fireEvent.click(rendered.getByRole('button', { name: /rerender/i })) + + // the snapshot must stay referentially stable, or useSyncExternalStore loops + expect(seen.length).toBeGreaterThan(before) + expect(seen[seen.length - 1]).toBe(seen[before - 1]) + }) + + it('should not re-run a stable select on an unrelated render', async () => { + const queryClient = new QueryClient() + const key = queryKey() + let selectCalls = 0 + const select = (mutation: any) => { + selectCalls++ + + return mutation.state.variables + } + + function Variables() { + const [, rerender] = React.useState(0) + useMutationState({ filters: { mutationKey: key }, select }) + + return + } + + function Page() { + const { mutate } = useMutation({ + mutationKey: key, + mutationFn: (input: number) => sleep(10).then(() => 'data' + input), + }) + + return ( +
+ + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(11) + const before = selectCalls + + // the re-render starts inside the subtree, so nothing notifies the cache + fireEvent.click(rendered.getByRole('button', { name: /rerender/i })) + fireEvent.click(rendered.getByRole('button', { name: /rerender/i })) + + expect(selectCalls).toBe(before) + }) + + it('should update useIsMutating when the filters change', async () => { + const queryClient = new QueryClient() + const key1 = queryKey() + const key2 = queryKey() + + function Count({ mutationKey }: { mutationKey: Array }) { + const count = useIsMutating({ mutationKey }) + + return
count: {count}
+ } + + function Page() { + const [mutationKey, setMutationKey] = React.useState(key1) + const { mutate } = useMutation({ + mutationKey: key1, + mutationFn: (input: number) => sleep(50).then(() => 'data' + input), + }) + + return ( +
+ + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('count: 1')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /switch/i })) + expect(rendered.getByText('count: 0')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(41) + }) }) diff --git a/packages/react-query/src/useMutationState.ts b/packages/react-query/src/useMutationState.ts index ce21870527b..3b2c3a4733d 100644 --- a/packages/react-query/src/useMutationState.ts +++ b/packages/react-query/src/useMutationState.ts @@ -71,8 +71,24 @@ export function useMutationState< const mutationCache = useQueryClient(queryClient).getMutationCache() const optionsRef = React.useRef(options) const result = React.useRef>(null) - if (result.current === null) { - result.current = getResult(mutationCache, options) + const filtersRef = React.useRef(undefined) + const selectRef = React.useRef(options.select) + // Also recomputed here, not only when the cache notifies: `getSnapshot` returns this + // ref, so a render with new options would otherwise keep the previous result. Guarded + // on the options, the way `QueryObserver` guards `select`, so an unrelated render does + // not re-run it. + const nextFilters = replaceEqualDeep(filtersRef.current, options.filters) + if ( + result.current === null || + nextFilters !== filtersRef.current || + options.select !== selectRef.current + ) { + filtersRef.current = nextFilters + selectRef.current = options.select + result.current = + result.current === null + ? getResult(mutationCache, options) + : replaceEqualDeep(result.current, getResult(mutationCache, options)) } React.useEffect(() => {