From 0823a2ada00cd9cc9b4e8035c5974c7704b6e456 Mon Sep 17 00:00:00 2001 From: koreahghg Date: Mon, 31 Aug 2026 11:17:03 +0900 Subject: [PATCH 1/2] fix(query-core): stop leaking silent CancelledError when a fetch is removed/reset Query.fetch() assumed a silent cancellation always meant a replacement fetch was starting and piggybacked on `this.#retryer.promise`. That's true for cancelRefetch, but removeQueries/resetQueries/clear() cancel silently via destroy() without starting a new fetch, so the same already-rejected retryer was returned, leaking a raw internal CancelledError to callers of fetchQuery/query()/ensureQueryData and to suspense/throwOnError consumers. Now it only piggybacks when a new retryer was actually assigned, otherwise falls back to existing data like the sibling `revert` branch already does. --- .changeset/silent-cancels-leak.md | 5 ++ .../query-core/src/__tests__/query.test.tsx | 47 +++++++++++++++++++ packages/query-core/src/query.ts | 15 ++++-- 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 .changeset/silent-cancels-leak.md 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..7d73f618aac 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -1130,6 +1130,53 @@ 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 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..34096b1d9db 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -585,9 +585,18 @@ 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 + if (this.state.data !== undefined) { + return this.state.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 From 1bb6ca261acde60a54bd3e5ae08eb1f8739fbaa5 Mon Sep 17 00:00:00 2001 From: koreahghg Date: Mon, 31 Aug 2026 11:54:53 +0900 Subject: [PATCH 2/2] fix(query-core): preserve cached data across a silent Query.reset Query.reset() calls destroy() (silent cancel) and then immediately overwrites this.state with the query's initial state, before the in-flight fetch's catch block runs. The earlier fallback to this.state.data alone therefore missed this case, since by then the cached data had already been wiped and there was no initialData to recover it from, so it still threw the raw CancelledError. Fall back to this.#revertState.data (the state captured right before this fetch started) when this.state.data is undefined, since reset() doesn't touch that private field. --- .../query-core/src/__tests__/query.test.tsx | 24 +++++++++++++++++++ packages/query-core/src/query.ts | 10 +++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index 7d73f618aac..45fb85c4150 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -1177,6 +1177,30 @@ describe('query', () => { 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 34096b1d9db..2b392e41574 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -592,9 +592,13 @@ export class Query< } // 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 - if (this.state.data !== undefined) { - return this.state.data + // 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) {