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 numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand Down
28 changes: 25 additions & 3 deletions packages/query-core/src/query.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<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@@ -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)
}
}

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