Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/petite-emus-lick.md
Original file line number Diff line number Diff line change
@@ -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
73 changes: 65 additions & 8 deletions packages/preact-query/src/__tests__/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<div>
status: {state.status}, fetchStatus: {state.fetchStatus}
</div>
<div>data: {state.data}</div>
</div>
)
}

function Page() {
const [show, setShow] = useState(true)

return (
<div>
{show && <Component />}
<button onClick={() => setShow(false)}>hide</button>
</div>
)
}

const onlineMock = mockOnlineManagerIsOnline(false)

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

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,
Expand Down Expand Up @@ -5648,7 +5707,7 @@ describe('useQuery', () => {

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

rendered.getByText('status: pending, fetchStatus: paused')
rendered.getByText('status: success, fetchStatus: paused')

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

Expand All @@ -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()
Expand Down Expand Up @@ -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}`
},
})

Expand Down Expand Up @@ -5786,7 +5843,7 @@ describe('useQuery', () => {
status: 'success',
})

expect(count).toBe(1)
expect(count).toBe(2)

onlineMock.mockRestore()
})
Expand Down
43 changes: 43 additions & 0 deletions packages/query-core/src/__tests__/query.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
8 changes: 7 additions & 1 deletion packages/query-core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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' })
Expand Down
72 changes: 65 additions & 7 deletions packages/react-query/src/__tests__/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<div>
status: {state.status}, fetchStatus: {state.fetchStatus}
</div>
<div>data: {state.data}</div>
</div>
)
}

function Page() {
const [show, setShow] = React.useState(true)

return (
<div>
{show && <Component />}
<button onClick={() => setShow(false)}>hide</button>
</div>
)
}

const onlineMock = mockOnlineManagerIsOnline(false)

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

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,
Expand Down Expand Up @@ -5641,7 +5700,7 @@ describe('useQuery', () => {

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

rendered.getByText('status: pending, fetchStatus: paused')
rendered.getByText('status: success, fetchStatus: paused')

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

Expand Down Expand Up @@ -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}`
},
})

Expand Down Expand Up @@ -5779,7 +5837,7 @@ describe('useQuery', () => {
status: 'success',
})

expect(count).toBe(1)
expect(count).toBe(2)

onlineMock.mockRestore()
})
Expand Down
Loading
Loading