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 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/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..17fdfebdac6 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,12 @@ 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() }) 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) }) })