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)
})
})