Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-mutation-scope-queue.md
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 54 additions & 0 deletions packages/query-core/src/__tests__/mutationObserver.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = []
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),
Expand Down
6 changes: 5 additions & 1 deletion packages/query-core/src/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
MutationFunctionContext,
MutationMeta,
MutationOptions,
MutationScope,
MutationStatus,
} from './types'
import type { MutationCache } from './mutationCache'
Expand Down Expand Up @@ -147,6 +148,7 @@ export class Mutation<
MutationObserver<TData, TError, TVariables, TOnMutateResult>
>
#mutationCache: MutationCache
readonly #scope: MutationScope | undefined
#retryer?: Retryer<TData>

constructor(
Expand All @@ -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()

Expand All @@ -168,7 +171,8 @@ export class Mutation<
setOptions(
options: MutationOptions<TData, TError, TVariables, TOnMutateResult>,
): 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)
}
Expand Down
47 changes: 46 additions & 1 deletion packages/react-query/src/__tests__/useMutation.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string> = []
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<string> | undefined
let second: Promise<string> | 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 {
Expand Down