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
35 changes: 35 additions & 0 deletions packages/query-core/src/__tests__/queryClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
31 changes: 28 additions & 3 deletions packages/query-core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TData>): Promise<TData> {
return promise.catch((error): TData | Promise<TData> => {
if (
error instanceof CancelledError &&
error.silent &&
this.#retryer &&
this.#retryer.promise !== promise
) {
return this.#continueOnSilentCancel(this.#retryer.promise)
}
throw error
})
}

async fetch(
options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
fetchOptions?: FetchOptions<TQueryFnData>,
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down