Skip to content

Commit a89aab9

Browse files
fix(core): cancel paused initial fetch when last observer unsubscribes (TanStack#10291)
* fix(core): cancel paused initial fetch when last observer unsubscribes * ci: apply automated fixes * fix: preact and solid tests * changeset --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent b799510 commit a89aab9

File tree

6 files changed

+264
-28
lines changed

6 files changed

+264
-28
lines changed

.changeset/petite-emus-lick.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@tanstack/preact-query': patch
3+
'@tanstack/react-query': patch
4+
'@tanstack/solid-query': patch
5+
'@tanstack/query-core': patch
6+
---
7+
8+
fix(core): cancel paused initial fetch when last observer unsubscribes

packages/preact-query/src/__tests__/useQuery.test.tsx

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5609,10 +5609,69 @@ describe('useQuery', () => {
56095609
onlineMock.mockRestore()
56105610
})
56115611

5612-
it('online queries should fetch if paused and we go online even if already unmounted (because not cancelled)', async () => {
5612+
it('online queries should not fetch if paused initial load and we go online after unmount', async () => {
56135613
const key = queryKey()
56145614
let count = 0
56155615

5616+
function Component() {
5617+
const state = useQuery({
5618+
queryKey: key,
5619+
queryFn: async ({ signal: _signal }) => {
5620+
count++
5621+
await sleep(10)
5622+
return `signal${count}`
5623+
},
5624+
})
5625+
5626+
return (
5627+
<div>
5628+
<div>
5629+
status: {state.status}, fetchStatus: {state.fetchStatus}
5630+
</div>
5631+
<div>data: {state.data}</div>
5632+
</div>
5633+
)
5634+
}
5635+
5636+
function Page() {
5637+
const [show, setShow] = useState(true)
5638+
5639+
return (
5640+
<div>
5641+
{show && <Component />}
5642+
<button onClick={() => setShow(false)}>hide</button>
5643+
</div>
5644+
)
5645+
}
5646+
5647+
const onlineMock = mockOnlineManagerIsOnline(false)
5648+
5649+
const rendered = renderWithClient(queryClient, <Page />)
5650+
5651+
rendered.getByText('status: pending, fetchStatus: paused')
5652+
5653+
fireEvent.click(rendered.getByRole('button', { name: /hide/i }))
5654+
5655+
onlineMock.mockReturnValue(true)
5656+
queryClient.getQueryCache().onOnline()
5657+
5658+
await vi.advanceTimersByTimeAsync(11)
5659+
expect(queryClient.getQueryState(key)).toMatchObject({
5660+
fetchStatus: 'idle',
5661+
status: 'pending',
5662+
})
5663+
5664+
expect(count).toBe(0)
5665+
5666+
onlineMock.mockRestore()
5667+
})
5668+
5669+
it('online queries should re-fetch if paused and we go online even if already unmounted (because not cancelled)', async () => {
5670+
const key = queryKey()
5671+
let count = 0
5672+
5673+
queryClient.setQueryData(key, 'initial')
5674+
56165675
function Component() {
56175676
const state = useQuery({
56185677
queryKey: key,
@@ -5648,7 +5707,7 @@ describe('useQuery', () => {
56485707

56495708
const rendered = renderWithClient(queryClient, <Page />)
56505709

5651-
rendered.getByText('status: pending, fetchStatus: paused')
5710+
rendered.getByText('status: success, fetchStatus: paused')
56525711

56535712
fireEvent.click(rendered.getByRole('button', { name: /hide/i }))
56545713

@@ -5661,7 +5720,6 @@ describe('useQuery', () => {
56615720
status: 'success',
56625721
})
56635722

5664-
// give it a bit more time to make sure queryFn is not called again
56655723
expect(count).toBe(1)
56665724

56675725
onlineMock.mockRestore()
@@ -5722,17 +5780,16 @@ describe('useQuery', () => {
57225780
onlineMock.mockRestore()
57235781
})
57245782

5725-
it('online queries should not fetch if paused and we go online if already unmounted when signal consumed', async () => {
5783+
it('online queries should fetch if paused and we go online even if already unmounted when refetch was not cancelled', async () => {
57265784
const key = queryKey()
57275785
let count = 0
57285786

57295787
function Component() {
57305788
const state = useQuery({
57315789
queryKey: key,
5732-
queryFn: async ({ signal: _signal }) => {
5790+
queryFn: async () => {
57335791
count++
5734-
await sleep(10)
5735-
return `signal${count}`
5792+
return `data${count}`
57365793
},
57375794
})
57385795

@@ -5786,7 +5843,7 @@ describe('useQuery', () => {
57865843
status: 'success',
57875844
})
57885845

5789-
expect(count).toBe(1)
5846+
expect(count).toBe(2)
57905847

57915848
onlineMock.mockRestore()
57925849
})

packages/query-core/src/__tests__/query.test.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,49 @@ describe('query', () => {
188188
}
189189
})
190190

191+
it('should cancel a paused initial fetch when the last observer unsubscribes', async () => {
192+
const key = queryKey()
193+
const onlineMock = mockOnlineManagerIsOnline(false)
194+
let count = 0
195+
196+
const observer = new QueryObserver(queryClient, {
197+
queryKey: key,
198+
queryFn: async ({ signal: _signal }) => {
199+
count++
200+
await sleep(10)
201+
return `data${count}`
202+
},
203+
})
204+
205+
const unsubscribe = observer.subscribe(() => undefined)
206+
const query = queryCache.find({ queryKey: key })!
207+
208+
expect(query.state).toMatchObject({
209+
fetchStatus: 'paused',
210+
status: 'pending',
211+
})
212+
213+
unsubscribe()
214+
215+
expect(query.state).toMatchObject({
216+
fetchStatus: 'idle',
217+
status: 'pending',
218+
})
219+
220+
onlineMock.mockReturnValue(true)
221+
queryClient.getQueryCache().onOnline()
222+
223+
await vi.advanceTimersByTimeAsync(11)
224+
225+
expect(query.state).toMatchObject({
226+
fetchStatus: 'idle',
227+
status: 'pending',
228+
})
229+
expect(count).toBe(0)
230+
231+
onlineMock.mockRestore()
232+
})
233+
191234
test('should not throw a CancelledError when fetchQuery is in progress and the last observer unsubscribes when AbortSignal is consumed', async () => {
192235
const key = queryKey()
193236

packages/query-core/src/query.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ export class Query<
359359
// If the transport layer does not support cancellation
360360
// we'll let the query continue so the result can be cached
361361
if (this.#retryer) {
362-
if (this.#abortSignalConsumed) {
362+
if (this.#abortSignalConsumed || this.#isInitialPausedFetch()) {
363363
this.#retryer.cancel({ revert: true })
364364
} else {
365365
this.#retryer.cancelRetry()
@@ -377,6 +377,12 @@ export class Query<
377377
return this.observers.length
378378
}
379379

380+
#isInitialPausedFetch(): boolean {
381+
return (
382+
this.state.fetchStatus === 'paused' && this.state.status === 'pending'
383+
)
384+
}
385+
380386
invalidate(): void {
381387
if (!this.state.isInvalidated) {
382388
this.#dispatch({ type: 'invalidate' })

packages/react-query/src/__tests__/useQuery.test.tsx

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5602,10 +5602,69 @@ describe('useQuery', () => {
56025602
onlineMock.mockRestore()
56035603
})
56045604

5605-
it('online queries should fetch if paused and we go online even if already unmounted (because not cancelled)', async () => {
5605+
it('online queries should not fetch if paused initial load and we go online after unmount', async () => {
56065606
const key = queryKey()
56075607
let count = 0
56085608

5609+
function Component() {
5610+
const state = useQuery({
5611+
queryKey: key,
5612+
queryFn: async ({ signal: _signal }) => {
5613+
count++
5614+
await sleep(10)
5615+
return `signal${count}`
5616+
},
5617+
})
5618+
5619+
return (
5620+
<div>
5621+
<div>
5622+
status: {state.status}, fetchStatus: {state.fetchStatus}
5623+
</div>
5624+
<div>data: {state.data}</div>
5625+
</div>
5626+
)
5627+
}
5628+
5629+
function Page() {
5630+
const [show, setShow] = React.useState(true)
5631+
5632+
return (
5633+
<div>
5634+
{show && <Component />}
5635+
<button onClick={() => setShow(false)}>hide</button>
5636+
</div>
5637+
)
5638+
}
5639+
5640+
const onlineMock = mockOnlineManagerIsOnline(false)
5641+
5642+
const rendered = renderWithClient(queryClient, <Page />)
5643+
5644+
rendered.getByText('status: pending, fetchStatus: paused')
5645+
5646+
fireEvent.click(rendered.getByRole('button', { name: /hide/i }))
5647+
5648+
onlineMock.mockReturnValue(true)
5649+
queryClient.getQueryCache().onOnline()
5650+
5651+
await vi.advanceTimersByTimeAsync(11)
5652+
expect(queryClient.getQueryState(key)).toMatchObject({
5653+
fetchStatus: 'idle',
5654+
status: 'pending',
5655+
})
5656+
5657+
expect(count).toBe(0)
5658+
5659+
onlineMock.mockRestore()
5660+
})
5661+
5662+
it('online queries should re-fetch if paused and we go online even if already unmounted (because not cancelled)', async () => {
5663+
const key = queryKey()
5664+
let count = 0
5665+
5666+
queryClient.setQueryData(key, 'initial')
5667+
56095668
function Component() {
56105669
const state = useQuery({
56115670
queryKey: key,
@@ -5641,7 +5700,7 @@ describe('useQuery', () => {
56415700

56425701
const rendered = renderWithClient(queryClient, <Page />)
56435702

5644-
rendered.getByText('status: pending, fetchStatus: paused')
5703+
rendered.getByText('status: success, fetchStatus: paused')
56455704

56465705
fireEvent.click(rendered.getByRole('button', { name: /hide/i }))
56475706

@@ -5715,17 +5774,16 @@ describe('useQuery', () => {
57155774
onlineMock.mockRestore()
57165775
})
57175776

5718-
it('online queries should not fetch if paused and we go online if already unmounted when signal consumed', async () => {
5777+
it('online queries should fetch if paused and we go online even if already unmounted when refetch was not cancelled', async () => {
57195778
const key = queryKey()
57205779
let count = 0
57215780

57225781
function Component() {
57235782
const state = useQuery({
57245783
queryKey: key,
5725-
queryFn: async ({ signal: _signal }) => {
5784+
queryFn: async () => {
57265785
count++
5727-
await sleep(10)
5728-
return `signal${count}`
5786+
return `data${count}`
57295787
},
57305788
})
57315789

@@ -5779,7 +5837,7 @@ describe('useQuery', () => {
57795837
status: 'success',
57805838
})
57815839

5782-
expect(count).toBe(1)
5840+
expect(count).toBe(2)
57835841

57845842
onlineMock.mockRestore()
57855843
})

0 commit comments

Comments
 (0)