From b51699f13eac466c3bd122955f7b6d1f3d86ec78 Mon Sep 17 00:00:00 2001 From: MarkXian Date: Mon, 13 Jul 2026 17:23:46 +0800 Subject: [PATCH] fix(query-core): don't reject imperative fetch with CancelledError when a superseding fetch is started When a fetch is silently cancelled because a new fetch supersedes it (e.g. `invalidateQueries` refetching an active observer with `cancelRefetch: true`), callers that joined the in-flight fetch via the early-return in `Query.fetch` received the raw retryer promise, which rejects with a silent `CancelledError`. The async `fetch` body already piggybacks onto the superseding fetch on silent cancellation, but the early-return path bypassed it. Route both the early-return and the catch-block piggyback through a shared helper that follows the chain of superseding fetches until one settles, while still rethrowing when the silent cancellation isn't from a superseding fetch (e.g. query destroyed). Closes #8060 --- .../src/__tests__/queryClient.test.tsx | 35 +++++++++++++++++++ packages/query-core/src/query.ts | 31 ++++++++++++++-- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index 2ed4c4c193a..698b558a13f 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -1057,6 +1057,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.toEqual(expect.any(Number)) + }) }) describe('refetchQueries', () => { diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 62bc9a16082..e9de2c31bf6 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -397,6 +397,27 @@ export class Query< } } + // When a fetch is silently cancelled, a new fetch has been started to + // supersede it. Any caller holding the superseded promise should follow + // that new fetch instead of receiving the internal `CancelledError`. This + // walks the chain of superseding fetches until one settles for real. When + // the silent cancellation does not come from a superseding fetch (e.g. the + // query was destroyed), `this.#retryer` still points at the settled promise, + // so we rethrow instead of looping. + #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, @@ -414,8 +435,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) } } @@ -590,7 +615,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