diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index f68670104a..c51b6cc715 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -2004,6 +2004,41 @@ describe('queryClient', () => { error: null, }) }) + + it('should resolve (not reject with CancelledError) when the query is invalidated mid-fetch while an active observer refetches it', async () => { + const key = queryKey() + let count = 0 + const queryFn = () => sleep(100).then(() => ++count) + + // prime the query so it has data (`data !== undefined`) + const priming = queryClient.fetchQuery({ queryKey: key, queryFn }) + await vi.advanceTimersByTimeAsync(100) + await priming + + // keep an active observer mounted; being stale it starts a background + // refetch that the imperative `fetchQuery` below piggybacks onto + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + staleTime: 0, + }) + const unsubscribe = observer.subscribe(() => undefined) + + // imperative fetch that joins the in-flight observer fetch + const fetchPromise = queryClient.fetchQuery({ queryKey: key, queryFn }) + + // one tick later invalidate -> refetches the active observer with + // `cancelRefetch: true`, silently cancelling the in-flight fetch + await vi.advanceTimersByTimeAsync(10) + void queryClient.invalidateQueries({ queryKey: key }) + + await vi.advanceTimersByTimeAsync(200) + unsubscribe() + + // the imperative fetch should resolve with the superseding fetch's data + // instead of rejecting with a silent CancelledError + await expect(fetchPromise).resolves.toBe(3) + }) }) describe('refetchQueries', () => { diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 53811c8ebb..bdef536a08 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -398,6 +398,24 @@ export class Query< } } + // A silent cancellation may be caused by a superseding fetch. If the + // current retryer is different from the cancelled promise, follow it. + // Otherwise (for example, when the query is destroyed), preserve the + // cancellation. Repeat this for a chain of superseding fetches. + #continueOnSilentCancel(promise: Promise): Promise { + return promise.catch((error): TData | Promise => { + if ( + error instanceof CancelledError && + error.silent && + this.#retryer && + this.#retryer.promise !== promise + ) { + return this.#continueOnSilentCancel(this.#retryer.promise) + } + throw error + }) + } + async fetch( options?: QueryOptions, fetchOptions?: FetchOptions, @@ -415,8 +433,12 @@ export class Query< } else if (this.#retryer) { // make sure that retries that were potentially cancelled due to unmounts can continue this.#retryer.continueRetry() - // Return current promise if we are already fetching - return this.#retryer.promise + // Return current promise if we are already fetching. + // If that fetch gets silently cancelled because a new fetch supersedes + // it (e.g. `invalidateQueries` refetching an active observer), piggyback + // onto the new fetch instead of rejecting the caller with a + // `CancelledError`. + return this.#continueOnSilentCancel(this.#retryer.promise) } } @@ -591,7 +613,7 @@ export class Query< if (error.silent) { // silent cancellation implies a new fetch is going to be started, // so we piggyback onto that promise - return this.#retryer.promise + return this.#continueOnSilentCancel(this.#retryer.promise) } else if (error.revert) { // transform error into reverted state data // if the initial fetch was cancelled, we have no data, so we have