Skip to content

Commit 379670d

Browse files
authored
fix(core): make sure queries revert synchronously (#9601)
the switch from the callback approach to async/await with catch was necessary to be able to transform errors into revert-state data for imperative query calls; however, this was a change in behavior because catch in async/await doesn't happen immediately - it runs in the next microtask. That opened up an opportunity for a slight race condition if we re-start a fetch in-between. And who does that? Of course, React Strict Mode. This PR moves the actual state reverting back to a callback (so it happens synchronously), while still keeping the try/catch refactoring merely to transform the promise and the usual error handling
1 parent 428c19f commit 379670d

File tree

3 files changed

+64
-9
lines changed

3 files changed

+64
-9
lines changed

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1192,4 +1192,54 @@ describe('query', () => {
11921192
expect(initialDataFn).toHaveBeenCalledTimes(1)
11931193
expect(query.state.data).toBe('initial data')
11941194
})
1195+
1196+
test('should not override fetching state when revert happens after new observer subscribes', async () => {
1197+
const key = queryKey()
1198+
let count = 0
1199+
1200+
const queryFn = vi.fn(async ({ signal: _signal }) => {
1201+
// Destructure `signal` to intentionally consume it so observer-removal uses revert-cancel path
1202+
await sleep(50)
1203+
return 'data' + count++
1204+
})
1205+
1206+
const query = new Query({
1207+
client: queryClient,
1208+
queryKey: key,
1209+
queryHash: hashQueryKeyByOptions(key),
1210+
options: { queryFn },
1211+
})
1212+
1213+
const observer1 = new QueryObserver(queryClient, {
1214+
queryKey: key,
1215+
queryFn,
1216+
})
1217+
1218+
query.addObserver(observer1)
1219+
const promise1 = query.fetch()
1220+
1221+
await vi.advanceTimersByTimeAsync(10)
1222+
1223+
query.removeObserver(observer1)
1224+
1225+
const observer2 = new QueryObserver(queryClient, {
1226+
queryKey: key,
1227+
queryFn,
1228+
})
1229+
1230+
query.addObserver(observer2)
1231+
1232+
query.fetch()
1233+
1234+
await expect(promise1).rejects.toBeInstanceOf(CancelledError)
1235+
await vi.waitFor(() => expect(query.state.fetchStatus).toBe('idle'))
1236+
1237+
expect(queryFn).toHaveBeenCalledTimes(2)
1238+
1239+
expect(query.state).toMatchObject({
1240+
fetchStatus: 'idle',
1241+
status: 'success',
1242+
data: 'data1',
1243+
})
1244+
})
11951245
})

packages/query-core/src/query.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,15 @@ export class Query<
507507
| Promise<TData>
508508
| undefined,
509509
fn: context.fetchFn as () => Promise<TData>,
510-
abort: abortController.abort.bind(abortController),
510+
onCancel: (error) => {
511+
if (error instanceof CancelledError && error.revert) {
512+
this.setState({
513+
...this.#revertState,
514+
fetchStatus: 'idle' as const,
515+
})
516+
}
517+
abortController.abort()
518+
},
511519
onFail: (failureCount, error) => {
512520
this.#dispatch({ type: 'failed', failureCount, error })
513521
},
@@ -550,13 +558,9 @@ export class Query<
550558
if (error instanceof CancelledError) {
551559
if (error.silent) {
552560
// silent cancellation implies a new fetch is going to be started,
553-
// so we hatch onto that promise
561+
// so we piggyback onto that promise
554562
return this.#retryer.promise
555563
} else if (error.revert) {
556-
this.setState({
557-
...this.#revertState,
558-
fetchStatus: 'idle' as const,
559-
})
560564
// transform error into reverted state data
561565
// if the initial fetch was cancelled, we have no data, so we have
562566
// to get reject with a CancelledError

packages/query-core/src/retryer.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { CancelOptions, DefaultError, NetworkMode } from './types'
1010
interface RetryerConfig<TData = unknown, TError = DefaultError> {
1111
fn: () => TData | Promise<TData>
1212
initialPromise?: Promise<TData>
13-
abort?: () => void
13+
onCancel?: (error: TError) => void
1414
onFail?: (failureCount: number, error: TError) => void
1515
onPause?: () => void
1616
onContinue?: () => void
@@ -86,9 +86,10 @@ export function createRetryer<TData = unknown, TError = DefaultError>(
8686

8787
const cancel = (cancelOptions?: CancelOptions): void => {
8888
if (!isResolved()) {
89-
reject(new CancelledError(cancelOptions))
89+
const error = new CancelledError(cancelOptions) as TError
90+
reject(error)
9091

91-
config.abort?.()
92+
config.onCancel?.(error)
9293
}
9394
}
9495
const cancelRetry = () => {

0 commit comments

Comments
 (0)