diff --git a/.changeset/fix-mutation-scope-queue.md b/.changeset/fix-mutation-scope-queue.md new file mode 100644 index 0000000000..8348ff07a0 --- /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 759734a0a6..7db125dbb7 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 fd74ba97ef..2c9b9dc427 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 e885b8e244..b2812baa55 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 {