From 24f1d45cbd3a252e2c8bb3471591502a2418b6ad Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Mon, 22 Apr 2024 08:39:11 +0200 Subject: [PATCH] feat: scoped mutations (#7312) * refactor: add scope to mutationCache internally * refactor: remove unused defaultOptions on mutation this private field is a leftover from v4 * feat: make sure to not run mutations if there is already one running in the scope of this mutation * feat: make sure mutations in the same scope can run in serial * fix: find a _lot_ better way to determine if a mutation can run * test: widen test scenario to include scopes * fix: there is a difference between starting and continuing when starting, we need to check the networkMode differently than when continuing, because of how offlineFirst works (can start, but can't continue) * refactor: switch to a scope object with `id` * feat: dehydrate and hydrate mutation scope * fix: initiate the mutationCache with a random number since we use the mutationId to create the default scope, and the mutationId is merely incremented, we risk colliding scopes when hydrating mutations into an existing cache. That's because the mutationId itself is never dehydrated. When a mutation gets hydrated, it gets re-built, thus getting a new id. At this point, its id and the scope can differ. That per se isn't a problem. But if a mutation was dehydrated with scope:1, it would put into the same scope with another mutation from the new cache that might also have the scope:1. To avoid that, we can initialize the mutationId with Date.now(). It will make sure (or at least very likely) that there is no collision In the future, we should just be able to use `Crypto.randomUUID()` to generate a unique scope, but our promised compatibility doesn't allow for using this function * test: hydration * test: those tests actually fail because resumePausedMutations is still wrongly implemented * fix: simplify and fix resumePausedMutations we can fire off all mutations at the same time - only the first one in each scope will actually fire, the others have to stay paused until their time has come. mutation.continue handles this internally. but, we get back all the retryer promises, so resumePausedMutations will wait until the whole chain is done * test: more tests * refactor: scopeFor doesn't use anything of the mutationCache class * docs: scoped mutations --- docs/framework/react/guides/mutations.md | 19 +- docs/framework/react/reference/useMutation.md | 7 +- .../src/__tests__/hydration.test.tsx | 34 ++++ .../src/__tests__/mutations.test.tsx | 191 ++++++++++++++++++ .../src/__tests__/queryClient.test.tsx | 100 ++++++++- packages/query-core/src/hydration.ts | 14 +- packages/query-core/src/mutation.ts | 65 +++--- packages/query-core/src/mutationCache.ts | 82 +++++--- packages/query-core/src/query.ts | 3 +- packages/query-core/src/retryer.ts | 44 ++-- packages/query-core/src/types.ts | 5 + .../src/__tests__/useMutation.test.tsx | 6 +- 12 files changed, 473 insertions(+), 97 deletions(-) diff --git a/docs/framework/react/guides/mutations.md b/docs/framework/react/guides/mutations.md index f32ac895ec..c4d57bad41 100644 --- a/docs/framework/react/guides/mutations.md +++ b/docs/framework/react/guides/mutations.md @@ -267,7 +267,7 @@ try { ## Retry -By default TanStack Query will not retry a mutation on error, but it is possible with the `retry` option: +By default, TanStack Query will not retry a mutation on error, but it is possible with the `retry` option: [//]: # 'Example9' @@ -390,6 +390,23 @@ We also have an extensive [offline example](../examples/offline) that covers bot [//]: # 'Materials' +## Mutation Scopes + +Per default, all mutations run in parallel - even if you invoke `.mutate()` of the same mutation multiple times. Mutations can be given a `scope` with an `id` to avoid that. All mutations with the same `scope.id` will run in serial, which means when they are triggered, they will start in `isPaused: true` state if there is already a mutation for that scope in progress. They will be put into a queue and will automatically resume once their time in the queue has come. + +[//]: # 'ExampleScopes' + +```tsx +const mutation = useMutation({ + mutationFn: addTodo, + scope: { + id: 'todo', + }, +}) +``` + +[//]: # 'ExampleScopes' + ## Further reading For more information about mutations, have a look at [#12: Mastering Mutations in React Query](../tkdodos-blog#12-mastering-mutations-in-react-query) from diff --git a/docs/framework/react/reference/useMutation.md b/docs/framework/react/reference/useMutation.md index aac78ab9e2..4eee7b655b 100644 --- a/docs/framework/react/reference/useMutation.md +++ b/docs/framework/react/reference/useMutation.md @@ -23,6 +23,7 @@ const { } = useMutation({ mutationFn, gcTime, + meta, mutationKey, networkMode, onError, @@ -31,8 +32,8 @@ const { onSuccess, retry, retryDelay, + scope, throwOnError, - meta, }) mutate(variables, { @@ -85,6 +86,10 @@ mutate(variables, { - This function receives a `retryAttempt` integer and the actual Error and returns the delay to apply before the next attempt in milliseconds. - A function like `attempt => Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000)` applies exponential backoff. - A function like `attempt => attempt * 1000` applies linear backoff. +- `scope: { id: string }` + - Optional + - Defaults to a unique id (so that all mutations run in parallel) + - Mutations with the same scope id will run in serial - `throwOnError: undefined | boolean | (error: TError) => boolean` - Defaults to the global query config's `throwOnError` value, which is `undefined` - Set this to `true` if you want mutation errors to be thrown in the render phase and propagate to the nearest error boundary diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index d4a00b1dd7..45d67d148e 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -704,4 +704,38 @@ describe('dehydration and rehydration', () => { hydrationCache.find({ queryKey: ['string'] })?.state.fetchStatus, ).toBe('idle') }) + + test('should dehydrate and hydrate mutation scopes', async () => { + const queryClient = createQueryClient() + const onlineMock = mockOnlineManagerIsOnline(false) + + void executeMutation( + queryClient, + { + mutationKey: ['mutation'], + mutationFn: async () => { + return 'mutation' + }, + scope: { + id: 'scope', + }, + }, + 'vars', + ) + + const dehydrated = dehydrate(queryClient) + expect(dehydrated.mutations[0]?.scope?.id).toBe('scope') + const stringified = JSON.stringify(dehydrated) + + // --- + const parsed = JSON.parse(stringified) + const hydrationCache = new MutationCache() + const hydrationClient = createQueryClient({ mutationCache: hydrationCache }) + + hydrate(hydrationClient, parsed) + + expect(dehydrated.mutations[0]?.scope?.id).toBe('scope') + + onlineMock.mockRestore() + }) }) diff --git a/packages/query-core/src/__tests__/mutations.test.tsx b/packages/query-core/src/__tests__/mutations.test.tsx index 9a93828b35..acf52658c9 100644 --- a/packages/query-core/src/__tests__/mutations.test.tsx +++ b/packages/query-core/src/__tests__/mutations.test.tsx @@ -409,4 +409,195 @@ describe('mutations', () => { expect(onSuccess).toHaveBeenCalledWith(2) }) + + describe('scoped mutations', () => { + test('mutations in the same scope should run in serial', async () => { + const key1 = queryKey() + const key2 = queryKey() + + const results: Array = [] + + const execute1 = executeMutation( + queryClient, + { + mutationKey: key1, + scope: { + id: 'scope', + }, + mutationFn: async () => { + results.push('start-A') + await sleep(10) + results.push('finish-A') + return 'a' + }, + }, + 'vars1', + ) + + expect( + queryClient.getMutationCache().find({ mutationKey: key1 })?.state, + ).toMatchObject({ + status: 'pending', + isPaused: false, + }) + + const execute2 = executeMutation( + queryClient, + { + mutationKey: key2, + scope: { + id: 'scope', + }, + mutationFn: async () => { + results.push('start-B') + await sleep(10) + results.push('finish-B') + return 'b' + }, + }, + 'vars2', + ) + + expect( + queryClient.getMutationCache().find({ mutationKey: key2 })?.state, + ).toMatchObject({ + status: 'pending', + isPaused: true, + }) + + await Promise.all([execute1, execute2]) + + expect(results).toStrictEqual([ + 'start-A', + 'finish-A', + 'start-B', + 'finish-B', + ]) + }) + }) + + test('mutations without scope should run in parallel', async () => { + const key1 = queryKey() + const key2 = queryKey() + + const results: Array = [] + + const execute1 = executeMutation( + queryClient, + { + mutationKey: key1, + mutationFn: async () => { + results.push('start-A') + await sleep(10) + results.push('finish-A') + return 'a' + }, + }, + 'vars1', + ) + + const execute2 = executeMutation( + queryClient, + { + mutationKey: key2, + mutationFn: async () => { + results.push('start-B') + await sleep(10) + results.push('finish-B') + return 'b' + }, + }, + 'vars2', + ) + + await Promise.all([execute1, execute2]) + + expect(results).toStrictEqual([ + 'start-A', + 'start-B', + 'finish-A', + 'finish-B', + ]) + }) + + test('each scope should run should run in parallel, serial within scope', async () => { + const results: Array = [] + + const execute1 = executeMutation( + queryClient, + { + scope: { + id: '1', + }, + mutationFn: async () => { + results.push('start-A1') + await sleep(10) + results.push('finish-A1') + return 'a' + }, + }, + 'vars1', + ) + + const execute2 = executeMutation( + queryClient, + { + scope: { + id: '1', + }, + mutationFn: async () => { + results.push('start-B1') + await sleep(10) + results.push('finish-B1') + return 'b' + }, + }, + 'vars2', + ) + + const execute3 = executeMutation( + queryClient, + { + scope: { + id: '2', + }, + mutationFn: async () => { + results.push('start-A2') + await sleep(10) + results.push('finish-A2') + return 'a' + }, + }, + 'vars1', + ) + + const execute4 = executeMutation( + queryClient, + { + scope: { + id: '2', + }, + mutationFn: async () => { + results.push('start-B2') + await sleep(10) + results.push('finish-B2') + return 'b' + }, + }, + 'vars2', + ) + + await Promise.all([execute1, execute2, execute3, execute4]) + + expect(results).toStrictEqual([ + 'start-A1', + 'start-A2', + 'finish-A1', + 'start-B1', + 'finish-A2', + 'start-B2', + 'finish-B1', + 'finish-B2', + ]) + }) }) diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index ef64f5a3dc..047d067327 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -1511,11 +1511,51 @@ describe('queryClient', () => { await waitFor(() => { expect(observer1.getCurrentResult().status).toBe('success') + expect(observer2.getCurrentResult().status).toBe('success') + }) + }) + + test('should resume paused mutations in parallel', async () => { + onlineManager.setOnline(false) + + const orders: Array = [] + + const observer1 = new MutationObserver(queryClient, { + mutationFn: async () => { + orders.push('1start') + await sleep(50) + orders.push('1end') + return 1 + }, + }) + + const observer2 = new MutationObserver(queryClient, { + mutationFn: async () => { + orders.push('2start') + await sleep(20) + orders.push('2end') + return 2 + }, + }) + void observer1.mutate() + void observer2.mutate() + + await waitFor(() => { + expect(observer1.getCurrentResult().isPaused).toBeTruthy() + expect(observer2.getCurrentResult().isPaused).toBeTruthy() + }) + + onlineManager.setOnline(true) + + await waitFor(() => { expect(observer1.getCurrentResult().status).toBe('success') + expect(observer2.getCurrentResult().status).toBe('success') }) + + expect(orders).toEqual(['1start', '2start', '2end', '1end']) }) - test('should resume paused mutations one after the other when invoked manually at the same time', async () => { + test('should resume paused mutations one after the other when in the same scope when invoked manually at the same time', async () => { const consoleMock = vi.spyOn(console, 'error') consoleMock.mockImplementation(() => undefined) onlineManager.setOnline(false) @@ -1523,6 +1563,9 @@ describe('queryClient', () => { const orders: Array = [] const observer1 = new MutationObserver(queryClient, { + scope: { + id: 'scope', + }, mutationFn: async () => { orders.push('1start') await sleep(50) @@ -1532,6 +1575,9 @@ describe('queryClient', () => { }) const observer2 = new MutationObserver(queryClient, { + scope: { + id: 'scope', + }, mutationFn: async () => { orders.push('2start') await sleep(20) @@ -1656,15 +1702,52 @@ describe('queryClient', () => { const observer = new MutationObserver(queryClient, { mutationFn: async () => { - results.push('mutation') + results.push('mutation1-start') await sleep(50) + results.push('mutation1-end') return 1 }, }) void observer.mutate() - expect(observer.getCurrentResult().isPaused).toBeTruthy() + const observer2 = new MutationObserver(queryClient, { + scope: { + id: 'scope', + }, + mutationFn: async () => { + results.push('mutation2-start') + await sleep(50) + results.push('mutation2-end') + return 2 + }, + }) + + void observer2.mutate() + + const observer3 = new MutationObserver(queryClient, { + scope: { + id: 'scope', + }, + mutationFn: async () => { + results.push('mutation3-start') + await sleep(50) + results.push('mutation3-end') + return 3 + }, + }) + + void observer3.mutate() + + await waitFor(() => + expect(observer.getCurrentResult().isPaused).toBeTruthy(), + ) + await waitFor(() => + expect(observer2.getCurrentResult().isPaused).toBeTruthy(), + ) + await waitFor(() => + expect(observer3.getCurrentResult().isPaused).toBeTruthy(), + ) onlineManager.setOnline(true) @@ -1673,7 +1756,16 @@ describe('queryClient', () => { }) // refetch from coming online should happen after mutations have finished - expect(results).toStrictEqual(['data1', 'mutation', 'data2']) + expect(results).toStrictEqual([ + 'data1', + 'mutation1-start', + 'mutation2-start', + 'mutation1-end', + 'mutation2-end', + 'mutation3-start', // 3 starts after 2 because they are in the same scope + 'mutation3-end', + 'data2', + ]) unsubscribe() }) diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index 4e6b58e4f0..0b0f090686 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -1,13 +1,14 @@ -import type { QueryClient } from './queryClient' -import type { Query, QueryState } from './query' import type { MutationKey, MutationMeta, MutationOptions, + MutationScope, QueryKey, QueryMeta, QueryOptions, } from './types' +import type { QueryClient } from './queryClient' +import type { Query, QueryState } from './query' import type { Mutation, MutationState } from './mutation' // TYPES @@ -28,6 +29,7 @@ interface DehydratedMutation { mutationKey?: MutationKey state: MutationState meta?: MutationMeta + scope?: MutationScope } interface DehydratedQuery { @@ -48,6 +50,7 @@ function dehydrateMutation(mutation: Mutation): DehydratedMutation { return { mutationKey: mutation.options.mutationKey, state: mutation.state, + ...(mutation.options.scope && { scope: mutation.options.scope }), ...(mutation.meta && { meta: mutation.meta }), } } @@ -115,15 +118,14 @@ export function hydrate( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const queries = (dehydratedState as DehydratedState).queries || [] - mutations.forEach((dehydratedMutation) => { + mutations.forEach(({ state, ...mutationOptions }) => { mutationCache.build( client, { ...options?.defaultOptions?.mutations, - mutationKey: dehydratedMutation.mutationKey, - meta: dehydratedMutation.meta, + ...mutationOptions, }, - dehydratedMutation.state, + state, ) }) diff --git a/packages/query-core/src/mutation.ts b/packages/query-core/src/mutation.ts index 79d87c0d73..69f9d6286b 100644 --- a/packages/query-core/src/mutation.ts +++ b/packages/query-core/src/mutation.ts @@ -1,6 +1,6 @@ import { notifyManager } from './notifyManager' import { Removable } from './removable' -import { canFetch, createRetryer } from './retryer' +import { createRetryer } from './retryer' import type { DefaultError, MutationMeta, @@ -17,7 +17,6 @@ interface MutationConfig { mutationId: number mutationCache: MutationCache options: MutationOptions - defaultOptions?: MutationOptions state?: MutationState } @@ -46,6 +45,7 @@ interface FailedAction { interface PendingAction { type: 'pending' + isPaused: boolean variables?: TVariables context?: TContext } @@ -89,7 +89,6 @@ export class Mutation< readonly mutationId: number #observers: Array> - #defaultOptions?: MutationOptions #mutationCache: MutationCache #retryer?: Retryer @@ -97,7 +96,6 @@ export class Mutation< super() this.mutationId = config.mutationId - this.#defaultOptions = config.defaultOptions this.#mutationCache = config.mutationCache this.#observers = [] this.state = config.state || getDefaultState() @@ -107,9 +105,9 @@ export class Mutation< } setOptions( - options?: MutationOptions, + options: MutationOptions, ): void { - this.options = { ...this.#defaultOptions, ...options } + this.options = options this.updateGcTime(this.options.gcTime) } @@ -164,36 +162,34 @@ export class Mutation< } async execute(variables: TVariables): Promise { - const executeMutation = () => { - this.#retryer = createRetryer({ - fn: () => { - if (!this.options.mutationFn) { - return Promise.reject(new Error('No mutationFn found')) - } - return this.options.mutationFn(variables) - }, - onFail: (failureCount, error) => { - this.#dispatch({ type: 'failed', failureCount, error }) - }, - onPause: () => { - this.#dispatch({ type: 'pause' }) - }, - onContinue: () => { - this.#dispatch({ type: 'continue' }) - }, - retry: this.options.retry ?? 0, - retryDelay: this.options.retryDelay, - networkMode: this.options.networkMode, - }) - - return this.#retryer.promise - } + this.#retryer = createRetryer({ + fn: () => { + if (!this.options.mutationFn) { + return Promise.reject(new Error('No mutationFn found')) + } + return this.options.mutationFn(variables) + }, + onFail: (failureCount, error) => { + this.#dispatch({ type: 'failed', failureCount, error }) + }, + onPause: () => { + this.#dispatch({ type: 'pause' }) + }, + onContinue: () => { + this.#dispatch({ type: 'continue' }) + }, + retry: this.options.retry ?? 0, + retryDelay: this.options.retryDelay, + networkMode: this.options.networkMode, + canRun: () => this.#mutationCache.canRun(this), + }) const restored = this.state.status === 'pending' + const isPaused = !this.#retryer.canStart() try { if (!restored) { - this.#dispatch({ type: 'pending', variables }) + this.#dispatch({ type: 'pending', variables, isPaused }) // Notify cache callback await this.#mutationCache.config.onMutate?.( variables, @@ -205,10 +201,11 @@ export class Mutation< type: 'pending', context, variables, + isPaused, }) } } - const data = await executeMutation() + const data = await this.#retryer.start() // Notify cache callback await this.#mutationCache.config.onSuccess?.( @@ -268,6 +265,8 @@ export class Mutation< } finally { this.#dispatch({ type: 'error', error: error as TError }) } + } finally { + this.#mutationCache.runNext(this) } } @@ -300,7 +299,7 @@ export class Mutation< failureCount: 0, failureReason: null, error: null, - isPaused: !canFetch(this.options.networkMode), + isPaused: action.isPaused, status: 'pending', variables: action.variables, submittedAt: Date.now(), diff --git a/packages/query-core/src/mutationCache.ts b/packages/query-core/src/mutationCache.ts index 628b63e1ed..1663b50cef 100644 --- a/packages/query-core/src/mutationCache.ts +++ b/packages/query-core/src/mutationCache.ts @@ -82,14 +82,13 @@ type MutationCacheListener = (event: MutationCacheNotifyEvent) => void // CLASS export class MutationCache extends Subscribable { - #mutations: Array> + #mutations: Map>> #mutationId: number - #resuming: Promise | undefined constructor(public config: MutationCacheConfig = {}) { super() - this.#mutations = [] - this.#mutationId = 0 + this.#mutations = new Map() + this.#mutationId = Date.now() } build( @@ -110,25 +109,59 @@ export class MutationCache extends Subscribable { } add(mutation: Mutation): void { - this.#mutations.push(mutation) + const scope = scopeFor(mutation) + const mutations = this.#mutations.get(scope) ?? [] + mutations.push(mutation) + this.#mutations.set(scope, mutations) this.notify({ type: 'added', mutation }) } remove(mutation: Mutation): void { - this.#mutations = this.#mutations.filter((x) => x !== mutation) + const scope = scopeFor(mutation) + if (this.#mutations.has(scope)) { + const mutations = this.#mutations + .get(scope) + ?.filter((x) => x !== mutation) + if (mutations) { + if (mutations.length === 0) { + this.#mutations.delete(scope) + } else { + this.#mutations.set(scope, mutations) + } + } + } + this.notify({ type: 'removed', mutation }) } + canRun(mutation: Mutation): boolean { + const firstPendingMutation = this.#mutations + .get(scopeFor(mutation)) + ?.find((m) => m.state.status === 'pending') + + // we can run if there is no current pending mutation (start use-case) + // or if WE are the first pending mutation (continue use-case) + return !firstPendingMutation || firstPendingMutation === mutation + } + + runNext(mutation: Mutation): Promise { + const foundMutation = this.#mutations + .get(scopeFor(mutation)) + ?.find((m) => m !== mutation && m.state.isPaused) + + return foundMutation?.continue() ?? Promise.resolve() + } + clear(): void { notifyManager.batch(() => { - this.#mutations.forEach((mutation) => { + this.getAll().forEach((mutation) => { this.remove(mutation) }) }) } getAll(): Array { - return this.#mutations + return [...this.#mutations.values()].flat() } find< @@ -141,15 +174,13 @@ export class MutationCache extends Subscribable { ): Mutation | undefined { const defaultedFilters = { exact: true, ...filters } - return this.#mutations.find((mutation) => + return this.getAll().find((mutation) => matchMutation(defaultedFilters, mutation), - ) + ) as Mutation | undefined } findAll(filters: MutationFilters = {}): Array { - return this.#mutations.filter((mutation) => - matchMutation(filters, mutation), - ) + return this.getAll().filter((mutation) => matchMutation(filters, mutation)) } notify(event: MutationCacheNotifyEvent) { @@ -161,21 +192,16 @@ export class MutationCache extends Subscribable { } resumePausedMutations(): Promise { - this.#resuming = (this.#resuming ?? Promise.resolve()) - .then(() => { - const pausedMutations = this.#mutations.filter((x) => x.state.isPaused) - return notifyManager.batch(() => - pausedMutations.reduce( - (promise, mutation) => - promise.then(() => mutation.continue().catch(noop)), - Promise.resolve() as Promise, - ), - ) - }) - .then(() => { - this.#resuming = undefined - }) + const pausedMutations = this.getAll().filter((x) => x.state.isPaused) - return this.#resuming + return notifyManager.batch(() => + Promise.all( + pausedMutations.map((mutation) => mutation.continue().catch(noop)), + ), + ) } } + +function scopeFor(mutation: Mutation) { + return mutation.options.scope?.id ?? String(mutation.mutationId) +} diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 5cfaa23561..21c1a7cb5e 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -527,9 +527,10 @@ export class Query< retry: context.options.retry, retryDelay: context.options.retryDelay, networkMode: context.options.networkMode, + canRun: () => true, }) - return this.#retryer.promise + return this.#retryer.start() } #dispatch(action: Action): void { diff --git a/packages/query-core/src/retryer.ts b/packages/query-core/src/retryer.ts index 3a0dace821..fb1584aad9 100644 --- a/packages/query-core/src/retryer.ts +++ b/packages/query-core/src/retryer.ts @@ -16,6 +16,7 @@ interface RetryerConfig { retry?: RetryValue retryDelay?: RetryDelayValue networkMode: NetworkMode | undefined + canRun: () => boolean } export interface Retryer { @@ -24,6 +25,8 @@ export interface Retryer { continue: () => Promise cancelRetry: () => void continueRetry: () => void + canStart: () => boolean + start: () => Promise } export type RetryValue = boolean | number | ShouldRetryFunction @@ -69,7 +72,7 @@ export function createRetryer( let isRetryCancelled = false let failureCount = 0 let isResolved = false - let continueFn: ((value?: unknown) => boolean) | undefined + let continueFn: ((value?: unknown) => void) | undefined let promiseResolve: (data: TData) => void let promiseReject: (error: TError) => void @@ -93,9 +96,12 @@ export function createRetryer( isRetryCancelled = false } - const shouldPause = () => - !focusManager.isFocused() || - (config.networkMode !== 'always' && !onlineManager.isOnline()) + const canContinue = () => + focusManager.isFocused() && + (config.networkMode === 'always' || onlineManager.isOnline()) && + config.canRun() + + const canStart = () => canFetch(config.networkMode) && config.canRun() const resolve = (value: any) => { if (!isResolved) { @@ -118,11 +124,9 @@ export function createRetryer( const pause = () => { return new Promise((continueResolve) => { continueFn = (value) => { - const canContinue = isResolved || !shouldPause() - if (canContinue) { + if (isResolved || canContinue()) { continueResolve(value) } - return canContinue } config.onPause?.() }).then(() => { @@ -184,10 +188,7 @@ export function createRetryer( sleep(delay) // Pause if the document is not visible or when the device is offline .then(() => { - if (shouldPause()) { - return pause() - } - return + return canContinue() ? undefined : pause() }) .then(() => { if (isRetryCancelled) { @@ -199,21 +200,24 @@ export function createRetryer( }) } - // Start loop - if (canFetch(config.networkMode)) { - run() - } else { - pause().then(run) - } - return { promise, cancel, continue: () => { - const didContinue = continueFn?.() - return didContinue ? promise : Promise.resolve() + continueFn?.() + return promise }, cancelRetry, continueRetry, + canStart, + start: () => { + // Start loop + if (canStart()) { + run() + } else { + pause().then(run) + } + return promise + }, } } diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 71fb6e1bf5..fd95ac2e24 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -717,6 +717,10 @@ export type MutationKey = ReadonlyArray export type MutationStatus = 'idle' | 'pending' | 'success' | 'error' +export type MutationScope = { + id: string +} + export type MutationMeta = Register extends { mutationMeta: infer TMutationMeta } @@ -762,6 +766,7 @@ export interface MutationOptions< gcTime?: number _defaulted?: boolean meta?: MutationMeta + scope?: MutationScope } export interface MutationObserverOptions< diff --git a/packages/react-query/src/__tests__/useMutation.test.tsx b/packages/react-query/src/__tests__/useMutation.test.tsx index 49ab1d632b..a57fe6cba9 100644 --- a/packages/react-query/src/__tests__/useMutation.test.tsx +++ b/packages/react-query/src/__tests__/useMutation.test.tsx @@ -592,7 +592,7 @@ describe('useMutation', () => { await sleep(10) count++ return count > 1 - ? Promise.resolve('data') + ? Promise.resolve(`data${count}`) : Promise.reject(new Error('oops')) }, retry: 1, @@ -631,7 +631,7 @@ describe('useMutation', () => { onlineMock.mockReturnValue(true) queryClient.getMutationCache().resumePausedMutations() - await waitFor(() => rendered.getByText('data: data')) + await waitFor(() => rendered.getByText('data: data2')) expect( queryClient.getMutationCache().findAll({ mutationKey: key })[0]?.state, @@ -640,7 +640,7 @@ describe('useMutation', () => { isPaused: false, failureCount: 0, failureReason: null, - data: 'data', + data: 'data2', }) onlineMock.mockRestore()