Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/petite-towns-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/query-core': patch
---

When running queryClient.fetchQuery, the query will no longer be cancelled if other observers are unsubscribed
16 changes: 6 additions & 10 deletions packages/query-core/src/__tests__/query.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@ describe('query', () => {
queryKey: key,
queryFn: async ({ signal }) => {
await sleep(100)
return 'data2' + String(signal)
signal.throwIfAborted()
return 'data2'
},
})

Expand All @@ -231,9 +232,9 @@ describe('query', () => {
await vi.advanceTimersByTimeAsync(90)

// Fetch should complete successfully without throwing a CancelledError
await expect(promise).resolves.toBe('data')
await expect(promise).resolves.toBe('data2')

expect(queryCache.find({ queryKey: key })?.state.data).toBe('data')
expect(queryCache.find({ queryKey: key })?.state.data).toBe('data2')
})

test('should provide context to queryFn', () => {
Expand Down Expand Up @@ -290,22 +291,17 @@ describe('query', () => {
test('should not continue when last observer unsubscribed if the signal was consumed', async () => {
const key = queryKey()

queryClient.prefetchQuery({
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: async ({ signal }) => {
await sleep(100)
return signal.aborted ? 'aborted' : 'data'
},
})

await vi.advanceTimersByTimeAsync(10)

// Subscribe and unsubscribe to simulate cancellation because the last observer unsubscribed
const observer = new QueryObserver(queryClient, {
queryKey: key,
enabled: false,
})
const unsubscribe = observer.subscribe(() => undefined)
await vi.advanceTimersByTimeAsync(10)
unsubscribe()

await vi.advanceTimersByTimeAsync(90)
Expand Down
17 changes: 13 additions & 4 deletions packages/query-core/src/queryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { focusManager } from './focusManager'
import { onlineManager } from './onlineManager'
import { notifyManager } from './notifyManager'
import { infiniteQueryBehavior } from './infiniteQueryBehavior'
import { QueryObserver } from './queryObserver'
import type {
CancelOptions,
DefaultError,
Expand Down Expand Up @@ -362,11 +363,19 @@ export class QueryClient {

const query = this.#queryCache.build(this, defaultedOptions)

return query.isStaleByTime(
const isDataStale = query.isStaleByTime(
resolveStaleTime(defaultedOptions.staleTime, query),
)
? query.fetch(defaultedOptions)
: Promise.resolve(query.state.data as TData)
);

if (!isDataStale) {
return Promise.resolve(query.state.data as TData)
}


const observer = new QueryObserver(this,defaultedOptions)
query.addObserver(observer);

return query.fetch(defaultedOptions).finally(() => query.removeObserver(observer))
}

prefetchQuery<
Expand Down
68 changes: 68 additions & 0 deletions packages/react-query/src/__tests__/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
dehydrate,
hydrate,
keepPreviousData,
queryOptions,
skipToken,
useQuery,
} from '..'
Expand Down Expand Up @@ -6775,4 +6776,71 @@ describe('useQuery', () => {

consoleErrorMock.mockRestore()
})

it('should not cancel a running fetchQuery call when unmounting useQuery', async () => {
const key = queryKey()

let abortSignal: AbortSignal | undefined

const options = queryOptions({
queryKey: key,
queryFn: async ({ signal }) => {
abortSignal = signal
if (signal) await sleep(20)
return 'data'
},
})

function UseQuery() {
const { data } = useQuery(options)
return <div>useQuery data: {data}</div>
}
function FetchQuery() {
const [data, setData] = React.useState<string>('loading')
const firstRender = React.useRef(true)
React.useEffect(() => {
if (firstRender.current) {
firstRender.current = false

queryClient.fetchQuery(options).then((result) => {
setData(result)
})
}
}, [setData])

return <div>fetchQuery data: {data}</div>
}

function Page() {
const [renderUseQuery, setRenderUseQuery] = React.useState(true)
React.useEffect(() => {
const timer = setTimeout(() => {
setRenderUseQuery(false)
}, 10)
return () => clearTimeout(timer)
}, [])
return (
<>
{renderUseQuery && <UseQuery />}
<FetchQuery />
</>
)
}

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

// This unmounts useQuery after 2 seconds, fetchQuery should continue running
await act(async () => {
await vi.advanceTimersByTimeAsync(11)
})

expect(abortSignal?.aborted).toBeFalsy()

// Advance time enough for fetchQuery to resolve
await act(async () => {
await vi.advanceTimersByTimeAsync(11)
})

expect(rendered.getByText('fetchQuery data: data')).toBeInTheDocument()
})
})