From 81024673265e644ea70b324524b96458a27b3e75 Mon Sep 17 00:00:00 2001 From: Brennan Butler Date: Fri, 18 Sep 2026 10:50:02 +0700 Subject: [PATCH] Preserve mutation scope across option updates Keep cache scope membership stable for each mutation so observer option updates cannot strand other mutations in its original queue. Future mutations still use the updated scope. Cover changed, removed, and initially absent scopes in core tests, plus React rerenders with and without Strict Mode. --- .changeset/fix-mutation-scope-queue.md | 5 ++ .../src/__tests__/mutationObserver.test.tsx | 54 +++++++++++++++++++ packages/query-core/src/mutation.ts | 6 ++- .../src/__tests__/useMutation.test.tsx | 47 +++++++++++++++- 4 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-mutation-scope-queue.md diff --git a/.changeset/fix-mutation-scope-queue.md b/.changeset/fix-mutation-scope-queue.md new file mode 100644 index 00000000000..8348ff07a01 --- /dev/null +++ b/.changeset/fix-mutation-scope-queue.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +Keep each mutation in its original scope when an observer's options change, so queued mutations resume when it settles. Updated scopes still apply to future mutations. diff --git a/packages/query-core/src/__tests__/mutationObserver.test.tsx b/packages/query-core/src/__tests__/mutationObserver.test.tsx index 759734a0a6c..7db125dbb79 100644 --- a/packages/query-core/src/__tests__/mutationObserver.test.tsx +++ b/packages/query-core/src/__tests__/mutationObserver.test.tsx @@ -16,6 +16,60 @@ describe('mutationObserver', () => { vi.useRealTimers() }) + it.each([undefined, { id: 'changed' }])( + 'should preserve the running mutation scope when observer scope becomes %j', + async (scope) => { + const calls: Array = [] + const mutationFn = (value: string) => { + calls.push(value) + return value === 'first' + ? sleep(10).then(() => value) + : Promise.resolve(value) + } + const observer = new MutationObserver(queryClient, { + scope: { id: 'original' }, + mutationFn, + }) + const queued = new MutationObserver(queryClient, { + scope: { id: 'original' }, + mutationFn, + }) + const first = observer.mutate('first') + const second = queued.mutate('second') + await vi.advanceTimersByTimeAsync(0) + expect(calls).toEqual(['first']) + expect(queued.getCurrentResult().isPaused).toBe(true) + + const onSuccess = vi.fn() + observer.setOptions({ scope, mutationFn, onSuccess }) + await vi.advanceTimersByTimeAsync(10) + + expect(calls).toEqual(['first', 'second']) + expect(queued.getCurrentResult().isPaused).toBe(false) + await expect(first).resolves.toBe('first') + await expect(second).resolves.toBe('second') + expect(onSuccess).toHaveBeenCalledTimes(1) + + await observer.mutate('future') + expect( + queryClient.getMutationCache().getAll().at(-1)?.options.scope, + ).toEqual(scope) + }, + ) + + it('should keep a running unscoped mutation unscoped when options change', async () => { + const mutationFn = () => sleep(10).then(() => 'done') + const observer = new MutationObserver(queryClient, { mutationFn }) + const result = observer.mutate() + observer.setOptions({ mutationFn, scope: { id: 'new-scope' } }) + + expect( + queryClient.getMutationCache().getAll()[0]?.options.scope, + ).toBeUndefined() + await vi.advanceTimersByTimeAsync(10) + await expect(result).resolves.toBe('done') + }) + it('onUnsubscribe should not remove the current mutation observer if there is still a subscription', async () => { const mutation = new MutationObserver(queryClient, { mutationFn: (text: string) => sleep(20).then(() => text), diff --git a/packages/query-core/src/mutation.ts b/packages/query-core/src/mutation.ts index fd74ba97efa..2c9b9dc4271 100644 --- a/packages/query-core/src/mutation.ts +++ b/packages/query-core/src/mutation.ts @@ -6,6 +6,7 @@ import type { MutationFunctionContext, MutationMeta, MutationOptions, + MutationScope, MutationStatus, } from './types' import type { MutationCache } from './mutationCache' @@ -147,6 +148,7 @@ export class Mutation< MutationObserver > #mutationCache: MutationCache + readonly #scope: MutationScope | undefined #retryer?: Retryer constructor( @@ -157,6 +159,7 @@ export class Mutation< this.#client = config.client this.mutationId = config.mutationId this.#mutationCache = config.mutationCache + this.#scope = config.options.scope this.#observers = [] this.state = config.state || getDefaultState() @@ -168,7 +171,8 @@ export class Mutation< setOptions( options: MutationOptions, ): void { - this.options = options + // Cache membership is determined at creation; changing scope would strand its queue. + this.options = { ...options, scope: this.#scope } this.updateGcTime(this.options.gcTime) } diff --git a/packages/react-query/src/__tests__/useMutation.test.tsx b/packages/react-query/src/__tests__/useMutation.test.tsx index e885b8e244c..b2812baa558 100644 --- a/packages/react-query/src/__tests__/useMutation.test.tsx +++ b/packages/react-query/src/__tests__/useMutation.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render } from '@testing-library/react' +import { act, fireEvent, render, renderHook } from '@testing-library/react' import * as React from 'react' import { ErrorBoundary } from 'react-error-boundary' import { queryKey, sleep } from '@tanstack/query-test-utils' @@ -31,6 +31,51 @@ describe('useMutation', () => { vi.useRealTimers() }) + it.each([false, true])( + 'should resume queued mutations after a scope prop changes (StrictMode: %s)', + async (strict) => { + const calls: Array = [] + const mutationFn = (value: string) => { + calls.push(value) + return value === 'first' + ? sleep(10).then(() => value) + : Promise.resolve(value) + } + const view = renderHook( + ({ scope }) => ({ + running: useMutation( + { scope: { id: scope }, mutationFn }, + queryClient, + ), + queued: useMutation( + { scope: { id: 'original' }, mutationFn }, + queryClient, + ), + }), + { + initialProps: { scope: 'original' }, + wrapper: strict ? React.StrictMode : undefined, + }, + ) + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = view.result.current.running.mutateAsync('first') + second = view.result.current.queued.mutateAsync('second') + await vi.advanceTimersByTimeAsync(0) + }) + expect(calls).toEqual(['first']) + view.rerender({ scope: 'changed' }) + await act(() => vi.advanceTimersByTimeAsync(11)) + + expect(calls).toEqual(['first', 'second']) + expect(view.result.current.queued.isPaused).toBe(false) + await first + await second + view.unmount() + }, + ) + it('should be able to reset `data`', async () => { function Page() { const {