From ebe153542c370c4cb351f89c809d0237de425508 Mon Sep 17 00:00:00 2001 From: Bart Langelaan Date: Wed, 22 Oct 2025 23:08:08 +0200 Subject: [PATCH] Don't cancel fetchQuery when unmounting useQuery --- .changeset/petite-towns-rule.md | 5 ++ .../query-core/src/__tests__/query.test.tsx | 16 ++--- packages/query-core/src/queryClient.ts | 17 +++-- .../src/__tests__/useQuery.test.tsx | 68 +++++++++++++++++++ 4 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 .changeset/petite-towns-rule.md diff --git a/.changeset/petite-towns-rule.md b/.changeset/petite-towns-rule.md new file mode 100644 index 0000000000..a4c61b29e3 --- /dev/null +++ b/.changeset/petite-towns-rule.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +When running queryClient.fetchQuery, the query will no longer be cancelled if other observers are unsubscribed diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index f11bf173d3..dbf307f912 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -217,7 +217,8 @@ describe('query', () => { queryKey: key, queryFn: async ({ signal }) => { await sleep(100) - return 'data2' + String(signal) + signal.throwIfAborted() + return 'data2' }, }) @@ -231,9 +232,9 @@ describe('query', () => { await vi.advanceTimersByTimeAsync(90) // Fetch should complete successfully without throwing a CancelledError - await expect(promise).resolves.toBe('data') + await expect(promise).resolves.toBe('data2') - expect(queryCache.find({ queryKey: key })?.state.data).toBe('data') + expect(queryCache.find({ queryKey: key })?.state.data).toBe('data2') }) test('should provide context to queryFn', () => { @@ -290,7 +291,7 @@ describe('query', () => { test('should not continue when last observer unsubscribed if the signal was consumed', async () => { const key = queryKey() - queryClient.prefetchQuery({ + const observer = new QueryObserver(queryClient, { queryKey: key, queryFn: async ({ signal }) => { await sleep(100) @@ -298,14 +299,9 @@ describe('query', () => { }, }) - await vi.advanceTimersByTimeAsync(10) - // Subscribe and unsubscribe to simulate cancellation because the last observer unsubscribed - const observer = new QueryObserver(queryClient, { - queryKey: key, - enabled: false, - }) const unsubscribe = observer.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(10) unsubscribe() await vi.advanceTimersByTimeAsync(90) diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 80cc36668a..4ddf6c0522 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -13,6 +13,7 @@ import { focusManager } from './focusManager' import { onlineManager } from './onlineManager' import { notifyManager } from './notifyManager' import { infiniteQueryBehavior } from './infiniteQueryBehavior' +import { QueryObserver } from './queryObserver' import type { CancelOptions, DefaultError, @@ -362,11 +363,19 @@ export class QueryClient { const query = this.#queryCache.build(this, defaultedOptions) - return query.isStaleByTime( + const isDataStale = query.isStaleByTime( resolveStaleTime(defaultedOptions.staleTime, query), - ) - ? query.fetch(defaultedOptions) - : Promise.resolve(query.state.data as TData) + ); + + if (!isDataStale) { + return Promise.resolve(query.state.data as TData) + } + + + const observer = new QueryObserver(this,defaultedOptions) + query.addObserver(observer); + + return query.fetch(defaultedOptions).finally(() => query.removeObserver(observer)) } prefetchQuery< diff --git a/packages/react-query/src/__tests__/useQuery.test.tsx b/packages/react-query/src/__tests__/useQuery.test.tsx index 39393379c0..65e34c1d96 100644 --- a/packages/react-query/src/__tests__/useQuery.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.test.tsx @@ -13,6 +13,7 @@ import { dehydrate, hydrate, keepPreviousData, + queryOptions, skipToken, useQuery, } from '..' @@ -6775,4 +6776,71 @@ describe('useQuery', () => { consoleErrorMock.mockRestore() }) + + it('should not cancel a running fetchQuery call when unmounting useQuery', async () => { + const key = queryKey() + + let abortSignal: AbortSignal | undefined + + const options = queryOptions({ + queryKey: key, + queryFn: async ({ signal }) => { + abortSignal = signal + if (signal) await sleep(20) + return 'data' + }, + }) + + function UseQuery() { + const { data } = useQuery(options) + return
useQuery data: {data}
+ } + function FetchQuery() { + const [data, setData] = React.useState('loading') + const firstRender = React.useRef(true) + React.useEffect(() => { + if (firstRender.current) { + firstRender.current = false + + queryClient.fetchQuery(options).then((result) => { + setData(result) + }) + } + }, [setData]) + + return
fetchQuery data: {data}
+ } + + function Page() { + const [renderUseQuery, setRenderUseQuery] = React.useState(true) + React.useEffect(() => { + const timer = setTimeout(() => { + setRenderUseQuery(false) + }, 10) + return () => clearTimeout(timer) + }, []) + return ( + <> + {renderUseQuery && } + + + ) + } + + const rendered = renderWithClient(queryClient, ) + + // This unmounts useQuery after 2 seconds, fetchQuery should continue running + await act(async () => { + await vi.advanceTimersByTimeAsync(11) + }) + + expect(abortSignal?.aborted).toBeFalsy() + + // Advance time enough for fetchQuery to resolve + await act(async () => { + await vi.advanceTimersByTimeAsync(11) + }) + + expect(rendered.getByText('fetchQuery data: data')).toBeInTheDocument() + }) })