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
5 changes: 5 additions & 0 deletions .changeset/silent-cancels-leak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/query-core': patch
---

Fix `Query.fetch()` rejecting with an internal, silent `CancelledError` when `removeQueries`/`resetQueries`/`clear()` cancels an in-flight fetch without starting a replacement one. It now resolves with the last known data instead, matching the behavior already used for `cancelRefetch`.
71 changes: 71 additions & 0 deletions packages/query-core/src/__tests__/query.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,77 @@ describe('query', () => {
expect(queryFn).toHaveBeenCalledTimes(2)
})

it('should not reject a promise when removeQueries silently cancels an in-flight fetch and stale data exists', async () => {
const key = queryKey()

queryClient.setQueryData(key, 'initial')
const queryFn = vi
.fn()
.mockImplementation(() => sleep(100).then(() => 'new data'))

const promise = queryClient.fetchQuery({
queryKey: key,
queryFn,
})

await vi.advanceTimersByTimeAsync(0)
expect(queryFn).toHaveBeenCalledTimes(1)

// remove the query while the fetch above is still in flight; this does
// not start a replacement fetch, unlike refetchQueries({ cancelRefetch: true })
queryClient.removeQueries({ queryKey: key })

// the promise should resolve with the last known data instead of
// rejecting with an internal, silent CancelledError
await expect(promise).resolves.toBe('initial')
})

it('should reject a promise when removeQueries silently cancels an in-flight fetch and no data exists yet', async () => {
const key = queryKey()

const queryFn = vi
.fn()
.mockImplementation(() => sleep(100).then(() => 'data'))

const promise = queryClient.fetchQuery({
queryKey: key,
queryFn,
})
// swallow the expected rejection so it doesn't surface as an unhandled rejection
promise.catch(() => undefined)

await vi.advanceTimersByTimeAsync(0)
expect(queryFn).toHaveBeenCalledTimes(1)

queryClient.removeQueries({ queryKey: key })

await expect(promise).rejects.toBeInstanceOf(CancelledError)
})

it('should not reject a promise when resetQueries silently cancels an in-flight fetch and cached data exists (no initialData)', async () => {
const key = queryKey()

queryClient.setQueryData(key, 'initial')
const queryFn = vi
.fn()
.mockImplementation(() => sleep(100).then(() => 'new data'))

const promise = queryClient.fetchQuery({
queryKey: key,
queryFn,
})

await vi.advanceTimersByTimeAsync(0)
expect(queryFn).toHaveBeenCalledTimes(1)

// resetQueries destroys the query (silent cancel) and then immediately
// overwrites state with the query's initial state, so `this.state.data`
// alone is no longer enough to recover the last known data here.
queryClient.resetQueries({ queryKey: key })

await expect(promise).resolves.toBe('initial')
})

it('should have an error log when queryFn data is not serializable', async () => {
const consoleMock = vi.spyOn(console, 'error')

Expand Down
19 changes: 16 additions & 3 deletions packages/query-core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,9 +585,22 @@ export class Query<
} catch (error) {
if (error instanceof CancelledError) {
if (error.silent) {
// silent cancellation implies a new fetch is going to be started,
// so we piggyback onto that promise
return this.#retryer.promise
// silent cancellation usually implies a new fetch is going to be
// started, so we piggyback onto that promise
if (this.#retryer !== retryer) {
return this.#retryer.promise
}
// no replacement fetch was started (e.g. the query was removed or
// reset while fetching), so fall back to existing data instead of
// leaking this internal cancellation to the caller. `reset()` may
// have already overwritten `this.state` with the initial state by
// the time we get here, so also fall back to the state captured
// just before this fetch started.
const data = this.state.data ?? this.#revertState.data
if (data !== undefined) {
return data
}
throw error
} 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