From 3ef18abc9cb17fa86509366b7962eaf227e2a89b Mon Sep 17 00:00:00 2001 From: TkDodo Date: Wed, 18 Mar 2026 15:35:14 +0100 Subject: [PATCH 1/4] fix(core): cancel paused initial fetch when last observer unsubscribes --- .../query-core/src/__tests__/query.test.tsx | 43 +++++++++++ packages/query-core/src/query.ts | 6 +- .../src/__tests__/useQuery.test.tsx | 72 +++++++++++++++++-- 3 files changed, 113 insertions(+), 8 deletions(-) diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index 93e5ea515f8..dfb8c0381a3 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -188,6 +188,49 @@ describe('query', () => { } }) + it('should cancel a paused initial fetch when the last observer unsubscribes', async () => { + const key = queryKey() + const onlineMock = mockOnlineManagerIsOnline(false) + let count = 0 + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async ({ signal: _signal }) => { + count++ + await sleep(10) + return `data${count}` + }, + }) + + const unsubscribe = observer.subscribe(() => undefined) + const query = queryCache.find({ queryKey: key })! + + expect(query.state).toMatchObject({ + fetchStatus: 'paused', + status: 'pending', + }) + + unsubscribe() + + expect(query.state).toMatchObject({ + fetchStatus: 'idle', + status: 'pending', + }) + + onlineMock.mockReturnValue(true) + queryClient.getQueryCache().onOnline() + + await vi.advanceTimersByTimeAsync(11) + + expect(query.state).toMatchObject({ + fetchStatus: 'idle', + status: 'pending', + }) + expect(count).toBe(0) + + onlineMock.mockRestore() + }) + test('should not throw a CancelledError when fetchQuery is in progress and the last observer unsubscribes when AbortSignal is consumed', async () => { const key = queryKey() diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index b29b64c0e3e..b17eed690c2 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -359,7 +359,7 @@ export class Query< // If the transport layer does not support cancellation // we'll let the query continue so the result can be cached if (this.#retryer) { - if (this.#abortSignalConsumed) { + if (this.#abortSignalConsumed || this.#isInitialPausedFetch()) { this.#retryer.cancel({ revert: true }) } else { this.#retryer.cancelRetry() @@ -377,6 +377,10 @@ export class Query< return this.observers.length } + #isInitialPausedFetch(): boolean { + return this.state.fetchStatus === 'paused' && this.state.status === 'pending' + } + invalidate(): void { if (!this.state.isInvalidated) { this.#dispatch({ type: 'invalidate' }) diff --git a/packages/react-query/src/__tests__/useQuery.test.tsx b/packages/react-query/src/__tests__/useQuery.test.tsx index 99f2f39d036..8b7829915d7 100644 --- a/packages/react-query/src/__tests__/useQuery.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.test.tsx @@ -5602,10 +5602,69 @@ describe('useQuery', () => { onlineMock.mockRestore() }) - it('online queries should fetch if paused and we go online even if already unmounted (because not cancelled)', async () => { + it('online queries should not fetch if paused initial load and we go online after unmount', async () => { const key = queryKey() let count = 0 + function Component() { + const state = useQuery({ + queryKey: key, + queryFn: async ({ signal: _signal }) => { + count++ + await sleep(10) + return `signal${count}` + }, + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+
+ ) + } + + function Page() { + const [show, setShow] = React.useState(true) + + return ( +
+ {show && } + +
+ ) + } + + const onlineMock = mockOnlineManagerIsOnline(false) + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText('status: pending, fetchStatus: paused') + + fireEvent.click(rendered.getByRole('button', { name: /hide/i })) + + onlineMock.mockReturnValue(true) + queryClient.getQueryCache().onOnline() + + await vi.advanceTimersByTimeAsync(11) + expect(queryClient.getQueryState(key)).toMatchObject({ + fetchStatus: 'idle', + status: 'pending', + }) + + expect(count).toBe(0) + + onlineMock.mockRestore() + }) + + it('online queries should re-fetch if paused and we go online even if already unmounted (because not cancelled)', async () => { + const key = queryKey() + let count = 0 + + queryClient.setQueryData(key, 'initial') + function Component() { const state = useQuery({ queryKey: key, @@ -5641,7 +5700,7 @@ describe('useQuery', () => { const rendered = renderWithClient(queryClient, ) - rendered.getByText('status: pending, fetchStatus: paused') + rendered.getByText('status: success, fetchStatus: paused') fireEvent.click(rendered.getByRole('button', { name: /hide/i })) @@ -5715,17 +5774,16 @@ describe('useQuery', () => { onlineMock.mockRestore() }) - it('online queries should not fetch if paused and we go online if already unmounted when signal consumed', async () => { + it('online queries should fetch if paused and we go online even if already unmounted when refetch was not cancelled', async () => { const key = queryKey() let count = 0 function Component() { const state = useQuery({ queryKey: key, - queryFn: async ({ signal: _signal }) => { + queryFn: async () => { count++ - await sleep(10) - return `signal${count}` + return `data${count}` }, }) @@ -5779,7 +5837,7 @@ describe('useQuery', () => { status: 'success', }) - expect(count).toBe(1) + expect(count).toBe(2) onlineMock.mockRestore() }) From 06e9fef6521523b35dc64d0b6945a79603bb70d5 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:36:52 +0000 Subject: [PATCH 2/4] ci: apply automated fixes --- packages/query-core/src/query.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index b17eed690c2..17fdfebdac6 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -378,7 +378,9 @@ export class Query< } #isInitialPausedFetch(): boolean { - return this.state.fetchStatus === 'paused' && this.state.status === 'pending' + return ( + this.state.fetchStatus === 'paused' && this.state.status === 'pending' + ) } invalidate(): void { From 9026cf2fe1acb7cfc00732a185a522b62844a455 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 19 Mar 2026 08:51:51 +0100 Subject: [PATCH 3/4] fix: preact and solid tests --- .../src/__tests__/useQuery.test.tsx | 73 +++++++++++++-- .../src/__tests__/useQuery.test.tsx | 88 ++++++++++++++++--- 2 files changed, 141 insertions(+), 20 deletions(-) diff --git a/packages/preact-query/src/__tests__/useQuery.test.tsx b/packages/preact-query/src/__tests__/useQuery.test.tsx index c7b5995b85c..c9bfd6a61cb 100644 --- a/packages/preact-query/src/__tests__/useQuery.test.tsx +++ b/packages/preact-query/src/__tests__/useQuery.test.tsx @@ -5609,10 +5609,69 @@ describe('useQuery', () => { onlineMock.mockRestore() }) - it('online queries should fetch if paused and we go online even if already unmounted (because not cancelled)', async () => { + it('online queries should not fetch if paused initial load and we go online after unmount', async () => { const key = queryKey() let count = 0 + function Component() { + const state = useQuery({ + queryKey: key, + queryFn: async ({ signal: _signal }) => { + count++ + await sleep(10) + return `signal${count}` + }, + }) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+
+ ) + } + + function Page() { + const [show, setShow] = useState(true) + + return ( +
+ {show && } + +
+ ) + } + + const onlineMock = mockOnlineManagerIsOnline(false) + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText('status: pending, fetchStatus: paused') + + fireEvent.click(rendered.getByRole('button', { name: /hide/i })) + + onlineMock.mockReturnValue(true) + queryClient.getQueryCache().onOnline() + + await vi.advanceTimersByTimeAsync(11) + expect(queryClient.getQueryState(key)).toMatchObject({ + fetchStatus: 'idle', + status: 'pending', + }) + + expect(count).toBe(0) + + onlineMock.mockRestore() + }) + + it('online queries should re-fetch if paused and we go online even if already unmounted (because not cancelled)', async () => { + const key = queryKey() + let count = 0 + + queryClient.setQueryData(key, 'initial') + function Component() { const state = useQuery({ queryKey: key, @@ -5648,7 +5707,7 @@ describe('useQuery', () => { const rendered = renderWithClient(queryClient, ) - rendered.getByText('status: pending, fetchStatus: paused') + rendered.getByText('status: success, fetchStatus: paused') fireEvent.click(rendered.getByRole('button', { name: /hide/i })) @@ -5661,7 +5720,6 @@ describe('useQuery', () => { status: 'success', }) - // give it a bit more time to make sure queryFn is not called again expect(count).toBe(1) onlineMock.mockRestore() @@ -5722,17 +5780,16 @@ describe('useQuery', () => { onlineMock.mockRestore() }) - it('online queries should not fetch if paused and we go online if already unmounted when signal consumed', async () => { + it('online queries should fetch if paused and we go online even if already unmounted when refetch was not cancelled', async () => { const key = queryKey() let count = 0 function Component() { const state = useQuery({ queryKey: key, - queryFn: async ({ signal: _signal }) => { + queryFn: async () => { count++ - await sleep(10) - return `signal${count}` + return `data${count}` }, }) @@ -5786,7 +5843,7 @@ describe('useQuery', () => { status: 'success', }) - expect(count).toBe(1) + expect(count).toBe(2) onlineMock.mockRestore() }) diff --git a/packages/solid-query/src/__tests__/useQuery.test.tsx b/packages/solid-query/src/__tests__/useQuery.test.tsx index 9dd3a005cdc..490426dcb74 100644 --- a/packages/solid-query/src/__tests__/useQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test.tsx @@ -5744,17 +5744,17 @@ describe('useQuery', () => { expect(count).toBe(3) }) - it('online queries should fetch if paused and we go online even if already unmounted (because not cancelled)', async () => { + it('online queries should not fetch if paused initial load and we go online after unmount', async () => { const key = queryKey() let count = 0 function Component() { const state = useQuery(() => ({ queryKey: key, - queryFn: async () => { - await sleep(10) + queryFn: async ({ signal: _signal }) => { count++ - return 'data' + count + await sleep(10) + return `signal${count}` }, })) @@ -5798,6 +5798,69 @@ describe('useQuery', () => { onlineMock.mockRestore() window.dispatchEvent(new Event('online')) + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + fetchStatus: 'idle', + status: 'pending', + }) + + expect(count).toBe(0) + }) + + it('online queries should re-fetch if paused and we go online even if already unmounted (because not cancelled)', async () => { + const key = queryKey() + let count = 0 + + queryClient.setQueryData(key, 'initial') + + function Component() { + const state = useQuery(() => ({ + queryKey: key, + queryFn: async () => { + count++ + await sleep(10) + return 'data' + count + }, + })) + + return ( +
+
+ status: {state.status}, fetchStatus: {state.fetchStatus} +
+
data: {state.data}
+
+ ) + } + + function Page() { + const [show, setShow] = createSignal(true) + + return ( +
+ {show() && } + +
+ ) + } + + const onlineMock = mockOnlineManagerIsOnline(false) + + const rendered = render(() => ( + + + + )) + + expect( + rendered.getByText('status: success, fetchStatus: paused'), + ).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /hide/i })) + + onlineMock.mockReturnValue(true) + queryClient.getQueryCache().onOnline() + await vi.advanceTimersByTimeAsync(10) expect(queryClient.getQueryState(key)).toMatchObject({ fetchStatus: 'idle', @@ -5805,6 +5868,8 @@ describe('useQuery', () => { }) expect(count).toBe(1) + + onlineMock.mockRestore() }) it('online queries should not fetch if paused and we go online when cancelled and no refetchOnReconnect', async () => { @@ -5867,17 +5932,16 @@ describe('useQuery', () => { onlineMock.mockRestore() }) - it('online queries should not fetch if paused and we go online if already unmounted when signal consumed', async () => { + it('online queries should fetch if paused and we go online even if already unmounted when refetch was not cancelled', async () => { const key = queryKey() let count = 0 function Component() { const state = useQuery(() => ({ queryKey: key, - queryFn: async ({ signal: _signal }) => { - await sleep(10) + queryFn: async () => { count++ - return `signal${count}` + return `data${count}` }, })) @@ -5927,16 +5991,16 @@ describe('useQuery', () => { fireEvent.click(rendered.getByRole('button', { name: /hide/i })) onlineMock.mockReturnValue(true) - window.dispatchEvent(new Event('online')) + queryClient.getQueryCache().onOnline() + + await vi.advanceTimersByTimeAsync(10) expect(queryClient.getQueryState(key)).toMatchObject({ fetchStatus: 'idle', status: 'success', }) - expect(count).toBe(1) - - onlineMock.mockRestore() + expect(count).toBe(2) }) }) From 1b9f1604f7ca7bd54a469129b4ab7c889d23e0a1 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 19 Mar 2026 08:52:47 +0100 Subject: [PATCH 4/4] changeset --- .changeset/petite-emus-lick.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/petite-emus-lick.md diff --git a/.changeset/petite-emus-lick.md b/.changeset/petite-emus-lick.md new file mode 100644 index 00000000000..b3ea4c37438 --- /dev/null +++ b/.changeset/petite-emus-lick.md @@ -0,0 +1,8 @@ +--- +'@tanstack/preact-query': patch +'@tanstack/react-query': patch +'@tanstack/solid-query': patch +'@tanstack/query-core': patch +--- + +fix(core): cancel paused initial fetch when last observer unsubscribes