Skip to content

Commit 0991576

Browse files
authored
fix(query-core): query cancellation and reverting (#9293)
* fix: we don't want errors that get reverted internally to throw on imperative methods to achieve that, we let the retryer's `onError` "transform" the result back to potentially TData, and if so, we'll resolve the promise to that instead; that means calls to `fetchQuery` will be "successful" when a cancellation happens in the meantime, but they will then resolve to the reverted data; also, if the initial fetch is cancelled, we would still throw, as there is no data to revert to. * refactor: async/await * minimal test adjustments * test: fix it * fix: revertState is now a local variable * fix: make sure "silent" reverts do not reject ongoing promises up until now, silent reverts have only stopped the query from going into error state, but the exposed promise was still rejected now, the promise won't reject because a silent revert indicates another refetch happening, so we'd want to get that promise's result instead this also means "silent" is an internal thing that shouldn't be used by consumers, because it can lead to never resolving promises instead * chore: bring back isCancelledError and deprecate it * test: revert changes * test: minimal adjustment to timings
1 parent 31f51b9 commit 0991576

File tree

7 files changed

+181
-86
lines changed

7 files changed

+181
-86
lines changed

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

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1398,8 +1398,5 @@ describe('dehydration and rehydration', () => {
13981398
// Need to await the original promise or else it will get a cancellation
13991399
// error and test will fail
14001400
await originalPromise
1401-
1402-
clientQueryClient.clear()
1403-
serverQueryClient.clear()
14041401
})
14051402
})

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,9 @@ describe('InfiniteQueryBehavior', () => {
315315

316316
// Cancel the query
317317
const query = observer.getCurrentQuery()
318-
query.cancel()
318+
await query.cancel()
319+
320+
vi.advanceTimersByTime(10)
319321

320322
expect(observerResult).toMatchObject({
321323
isFetching: false,

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

Lines changed: 112 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import {
55
sleep,
66
} from '@tanstack/query-test-utils'
77
import {
8+
CancelledError,
89
Query,
910
QueryClient,
1011
QueryObserver,
1112
dehydrate,
1213
hydrate,
13-
isCancelledError,
1414
} from '..'
1515
import { hashQueryKeyByOptions } from '../utils'
1616
import { mockOnlineManagerIsOnline, setIsServer } from './utils'
@@ -190,14 +190,52 @@ describe('query', () => {
190190
await promise
191191
expect.unreachable()
192192
} catch {
193-
expect(isCancelledError(result)).toBe(true)
194-
expect(result instanceof Error).toBe(true)
193+
expect(result).toBeInstanceOf(CancelledError)
195194
} finally {
196195
// Reset visibilityState to original value
197196
visibilityMock.mockRestore()
198197
}
199198
})
200199

200+
test('should not throw a CancelledError when fetchQuery is in progress and the last observer unsubscribes when AbortSignal is consumed', async () => {
201+
const key = queryKey()
202+
203+
const observer = new QueryObserver(queryClient, {
204+
queryKey: key,
205+
queryFn: async () => {
206+
await sleep(100)
207+
return 'data'
208+
},
209+
})
210+
211+
const unsubscribe = observer.subscribe(() => undefined)
212+
await vi.advanceTimersByTimeAsync(100)
213+
214+
expect(queryCache.find({ queryKey: key })?.state.data).toBe('data')
215+
216+
const promise = queryClient.fetchQuery({
217+
queryKey: key,
218+
queryFn: async ({ signal }) => {
219+
await sleep(100)
220+
return 'data2' + String(signal)
221+
},
222+
})
223+
224+
// Ensure the fetch is in progress
225+
await vi.advanceTimersByTimeAsync(10)
226+
227+
// Unsubscribe while fetch is in progress
228+
unsubscribe()
229+
// await queryClient.cancelQueries()
230+
231+
await vi.advanceTimersByTimeAsync(90)
232+
233+
// Fetch should complete successfully without throwing a CancelledError
234+
await expect(promise).resolves.toBe('data')
235+
236+
expect(queryCache.find({ queryKey: key })?.state.data).toBe('data')
237+
})
238+
201239
test('should provide context to queryFn', () => {
202240
const key = queryKey()
203241

@@ -330,7 +368,7 @@ describe('query', () => {
330368
expect(signal.aborted).toBe(true)
331369
expect(onAbort).toHaveBeenCalledTimes(1)
332370
expect(abortListener).toHaveBeenCalledTimes(1)
333-
expect(isCancelledError(error)).toBe(true)
371+
expect(error).toBeInstanceOf(CancelledError)
334372
})
335373

336374
test('should not continue if explicitly cancelled', async () => {
@@ -362,7 +400,7 @@ describe('query', () => {
362400
await vi.advanceTimersByTimeAsync(100)
363401

364402
expect(queryFn).toHaveBeenCalledTimes(1)
365-
expect(isCancelledError(error)).toBe(true)
403+
expect(error).toBeInstanceOf(CancelledError)
366404
})
367405

368406
test('should not error if reset while pending', async () => {
@@ -375,7 +413,18 @@ describe('query', () => {
375413
throw new Error()
376414
})
377415

378-
queryClient.fetchQuery({ queryKey: key, queryFn, retry: 3, retryDelay: 10 })
416+
let error
417+
418+
queryClient
419+
.fetchQuery({
420+
queryKey: key,
421+
queryFn,
422+
retry: 3,
423+
retryDelay: 10,
424+
})
425+
.catch((e) => {
426+
error = e
427+
})
379428

380429
// Ensure the query is pending
381430
const query = queryCache.find({ queryKey: key })!
@@ -390,6 +439,12 @@ describe('query', () => {
390439
expect(queryFn).toHaveBeenCalledTimes(1) // have been called,
391440
expect(query.state.error).toBe(null) // not have an error, and
392441
expect(query.state.fetchStatus).toBe('idle') // not be loading any longer
442+
expect(query.state.data).toBe(undefined) // have no data
443+
444+
// the call to fetchQuery must reject
445+
// because it was reset and not reverted
446+
// so it would resolve with undefined otherwise
447+
expect(error).toBeInstanceOf(CancelledError)
393448
})
394449

395450
test('should reset to default state when created from hydration', async () => {
@@ -426,7 +481,7 @@ describe('query', () => {
426481
await vi.advanceTimersByTimeAsync(100)
427482

428483
expect(queryFn).toHaveBeenCalledTimes(1)
429-
expect(isCancelledError(query.state.error)).toBe(true)
484+
expect(query.state.error).toBeInstanceOf(CancelledError)
430485
const result = query.fetch()
431486
await vi.advanceTimersByTimeAsync(50)
432487
await expect(result).resolves.toBe('data')
@@ -459,7 +514,7 @@ describe('query', () => {
459514
await vi.advanceTimersByTimeAsync(10)
460515

461516
expect(query.state.error).toBe(error)
462-
expect(isCancelledError(query.state.error)).toBe(false)
517+
expect(query.state.error).not.toBeInstanceOf(CancelledError)
463518
})
464519

465520
test('the previous query status should be kept when refetching', async () => {
@@ -897,7 +952,7 @@ describe('query', () => {
897952
unsubscribe()
898953
})
899954

900-
test('should always revert to idle state (#5958)', async () => {
955+
test('should always revert to idle state (#5968)', async () => {
901956
let mockedData = [1]
902957

903958
const key = queryKey()
@@ -931,24 +986,69 @@ describe('query', () => {
931986
const unsubscribe = observer.subscribe(() => undefined)
932987
await vi.advanceTimersByTimeAsync(50) // let it resolve
933988

989+
expect(observer.getCurrentResult().data).toBe('1')
990+
expect(observer.getCurrentResult().fetchStatus).toBe('idle')
991+
934992
mockedData = [1, 2] // update "server" state in the background
935993

936-
queryClient.invalidateQueries({ queryKey: key })
937-
queryClient.invalidateQueries({ queryKey: key })
994+
void queryClient.invalidateQueries({ queryKey: key })
995+
await vi.advanceTimersByTimeAsync(5)
996+
void queryClient.invalidateQueries({ queryKey: key })
997+
await vi.advanceTimersByTimeAsync(5)
938998
unsubscribe() // unsubscribe to simulate unmount
999+
await vi.advanceTimersByTimeAsync(5)
1000+
1001+
// reverted to previous data and idle fetchStatus
1002+
expect(queryCache.find({ queryKey: key })?.state).toMatchObject({
1003+
status: 'success',
1004+
data: '1',
1005+
fetchStatus: 'idle',
1006+
})
9391007

9401008
// set up a new observer to simulate a mount of new component
9411009
const newObserver = new QueryObserver(queryClient, {
9421010
queryKey: key,
9431011
queryFn,
9441012
})
945-
9461013
const spy = vi.fn()
9471014
newObserver.subscribe(({ data }) => spy(data))
9481015
await vi.advanceTimersByTimeAsync(60) // let it resolve
9491016
expect(spy).toHaveBeenCalledWith('1 - 2')
9501017
})
9511018

1019+
test('should not reject a promise when silently cancelled in the background', async () => {
1020+
const key = queryKey()
1021+
1022+
let x = 0
1023+
1024+
queryClient.setQueryData(key, 'initial')
1025+
const queryFn = vi.fn().mockImplementation(async () => {
1026+
await sleep(100)
1027+
return 'data' + x
1028+
})
1029+
1030+
const promise = queryClient.fetchQuery({
1031+
queryKey: key,
1032+
queryFn,
1033+
})
1034+
1035+
await vi.advanceTimersByTimeAsync(10)
1036+
1037+
expect(queryFn).toHaveBeenCalledTimes(1)
1038+
1039+
x = 1
1040+
1041+
// cancel ongoing re-fetches
1042+
void queryClient.refetchQueries({ queryKey: key }, { cancelRefetch: true })
1043+
1044+
await vi.advanceTimersByTimeAsync(10)
1045+
1046+
// The promise should not reject
1047+
await vi.waitFor(() => expect(promise).resolves.toBe('data1'))
1048+
1049+
expect(queryFn).toHaveBeenCalledTimes(2)
1050+
})
1051+
9521052
it('should have an error log when queryFn data is not serializable', async () => {
9531053
const consoleMock = vi.spyOn(console, 'error')
9541054

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1049,7 +1049,7 @@ describe('queryClient', () => {
10491049
queryKey: key1,
10501050
queryFn: () => 'data',
10511051
})
1052-
queryClient.fetchQuery({
1052+
queryClient.prefetchQuery({
10531053
queryKey: key1,
10541054
queryFn: () => sleep(1000).then(() => 'data2'),
10551055
})

0 commit comments

Comments
 (0)