diff --git a/.changeset/silent-cancels-leak.md b/.changeset/silent-cancels-leak.md new file mode 100644 index 00000000000..9f7b263a079 --- /dev/null +++ b/.changeset/silent-cancels-leak.md @@ -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`. diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index 5ac3f20663b..45fb85c4150 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -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') diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index aa18e975af7..2b392e41574 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -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