Skip to content

Commit 2da342c

Browse files
authored
fix: update synchronously to make sure observers always have the latest state (#1757)
1 parent bd6c2b5 commit 2da342c

File tree

4 files changed

+143
-53
lines changed

4 files changed

+143
-53
lines changed

src/core/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { CancelledError } from './retryer'
12
export { QueryCache } from './queryCache'
23
export { QueryClient } from './queryClient'
34
export { QueryObserver } from './queryObserver'
@@ -14,6 +15,5 @@ export { isCancelledError } from './retryer'
1415

1516
// Types
1617
export * from './types'
17-
export type { CancelledError } from './retryer'
1818
export type { Query } from './query'
1919
export type { Logger } from './logger'

src/core/query.ts

Lines changed: 26 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export class Query<
135135
private cache: QueryCache
136136
private promise?: Promise<TData>
137137
private gcTimeout?: number
138-
private retryer?: Retryer<unknown, TError>
138+
private retryer?: Retryer<TData, TError>
139139
private observers: QueryObserver<any, any, any, any>[]
140140
private defaultOptions?: QueryOptions<TQueryFnData, TError, TData>
141141

@@ -387,28 +387,21 @@ export class Query<
387387

388388
// Try to fetch the data
389389
this.retryer = new Retryer({
390-
fn: context.fetchFn,
391-
onFail: () => {
392-
this.dispatch({ type: 'failed' })
393-
},
394-
onPause: () => {
395-
this.dispatch({ type: 'pause' })
396-
},
397-
onContinue: () => {
398-
this.dispatch({ type: 'continue' })
399-
},
400-
retry: context.options.retry,
401-
retryDelay: context.options.retryDelay,
402-
})
390+
fn: context.fetchFn as () => TData,
391+
onSuccess: data => {
392+
this.setData(data as TData)
403393

404-
this.promise = this.retryer.promise
405-
.then(data => this.setData(data as TData))
406-
.catch(error => {
407-
// Set error state if needed
394+
// Remove query after fetching if cache time is 0
395+
if (this.cacheTime === 0) {
396+
this.optionalRemove()
397+
}
398+
},
399+
onError: error => {
400+
// Optimistically update state if needed
408401
if (!(isCancelledError(error) && error.silent)) {
409402
this.dispatch({
410403
type: 'error',
411-
error,
404+
error: error as TError,
412405
})
413406
}
414407

@@ -426,18 +419,21 @@ export class Query<
426419
if (this.cacheTime === 0) {
427420
this.optionalRemove()
428421
}
422+
},
423+
onFail: () => {
424+
this.dispatch({ type: 'failed' })
425+
},
426+
onPause: () => {
427+
this.dispatch({ type: 'pause' })
428+
},
429+
onContinue: () => {
430+
this.dispatch({ type: 'continue' })
431+
},
432+
retry: context.options.retry,
433+
retryDelay: context.options.retryDelay,
434+
})
429435

430-
// Propagate error
431-
throw error
432-
})
433-
.then(data => {
434-
// Remove query after fetching if cache time is 0
435-
if (this.cacheTime === 0) {
436-
this.optionalRemove()
437-
}
438-
439-
return data
440-
})
436+
this.promise = this.retryer.promise
441437

442438
return this.promise
443439
}

src/core/retryer.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { functionalUpdate, sleep } from './utils'
66

77
interface RetryerConfig<TData = unknown, TError = unknown> {
88
fn: () => TData | Promise<TData>
9+
onError?: (error: TError) => void
10+
onSuccess?: (data: TData) => void
911
onFail?: (failureCount: number, error: TError) => void
1012
onPause?: () => void
1113
onContinue?: () => void
@@ -88,15 +90,21 @@ export class Retryer<TData = unknown, TError = unknown> {
8890
})
8991

9092
const resolve = (value: any) => {
91-
this.isResolved = true
92-
continueFn?.()
93-
promiseResolve(value)
93+
if (!this.isResolved) {
94+
this.isResolved = true
95+
config.onSuccess?.(value)
96+
continueFn?.()
97+
promiseResolve(value)
98+
}
9499
}
95100

96101
const reject = (value: any) => {
97-
this.isResolved = true
98-
continueFn?.()
99-
promiseReject(value)
102+
if (!this.isResolved) {
103+
this.isResolved = true
104+
config.onError?.(value)
105+
continueFn?.()
106+
promiseReject(value)
107+
}
100108
}
101109

102110
const pause = () => {
@@ -129,13 +137,15 @@ export class Retryer<TData = unknown, TError = unknown> {
129137

130138
// Create callback to cancel this fetch
131139
cancelFn = cancelOptions => {
132-
reject(new CancelledError(cancelOptions))
133-
134-
// Cancel transport if supported
135-
if (isCancelable(promiseOrValue)) {
136-
try {
137-
promiseOrValue.cancel()
138-
} catch {}
140+
if (!this.isResolved) {
141+
reject(new CancelledError(cancelOptions))
142+
143+
// Cancel transport if supported
144+
if (isCancelable(promiseOrValue)) {
145+
try {
146+
promiseOrValue.cancel()
147+
} catch {}
148+
}
139149
}
140150
}
141151

src/react/tests/useQuery.test.tsx

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from './utils'
1414
import {
1515
useQuery,
16+
CancelledError,
1617
QueryClient,
1718
UseQueryResult,
1819
QueryCache,
@@ -900,7 +901,7 @@ describe('useQuery', () => {
900901

901902
renderWithClient(queryClient, <Page />)
902903

903-
await waitFor(() => renderCount > 1)
904+
await sleep(10)
904905
expect(renderCount).toBe(2)
905906
expect(states.length).toBe(2)
906907
expect(states[0]).toMatchObject({ data: undefined })
@@ -1051,7 +1052,14 @@ describe('useQuery', () => {
10511052
queryClient.setQueryData(key, 'set')
10521053

10531054
function Page() {
1054-
const result = useQuery(key, () => 'fetched', { enabled: false })
1055+
const result = useQuery(
1056+
key,
1057+
async () => {
1058+
await sleep(1)
1059+
return 'fetched'
1060+
},
1061+
{ enabled: false }
1062+
)
10551063

10561064
results.push(result)
10571065

@@ -1082,7 +1090,8 @@ describe('useQuery', () => {
10821090
function Page() {
10831091
const state = useQuery(
10841092
key,
1085-
() => {
1093+
async () => {
1094+
await sleep(1)
10861095
count++
10871096
return count
10881097
},
@@ -2214,10 +2223,17 @@ describe('useQuery', () => {
22142223
let count = 0
22152224

22162225
function Page() {
2217-
const state = useQuery(key, () => count++, {
2218-
staleTime: Infinity,
2219-
refetchOnWindowFocus: 'always',
2220-
})
2226+
const state = useQuery(
2227+
key,
2228+
async () => {
2229+
await sleep(1)
2230+
return count++
2231+
},
2232+
{
2233+
staleTime: Infinity,
2234+
refetchOnWindowFocus: 'always',
2235+
}
2236+
)
22212237
states.push(state)
22222238
return null
22232239
}
@@ -2725,7 +2741,10 @@ describe('useQuery', () => {
27252741
queryClient.setQueryData(key, 'prefetched')
27262742

27272743
function Page() {
2728-
const state = useQuery(key, () => 'data')
2744+
const state = useQuery(key, async () => {
2745+
await sleep(1)
2746+
return 'data'
2747+
})
27292748
states.push(state)
27302749
return null
27312750
}
@@ -3269,6 +3288,70 @@ describe('useQuery', () => {
32693288
expect(cancelFn).toHaveBeenCalled()
32703289
})
32713290

3291+
it('should refetch when quickly switching to a failed query', async () => {
3292+
const key = queryKey()
3293+
const states: UseQueryResult<string>[] = []
3294+
3295+
const queryFn = () => {
3296+
let cancelFn = jest.fn()
3297+
3298+
const promise = new Promise<string>((resolve, reject) => {
3299+
cancelFn = jest.fn(() => reject('Cancelled'))
3300+
sleep(50).then(() => resolve('OK'))
3301+
})
3302+
3303+
;(promise as any).cancel = cancelFn
3304+
3305+
return promise
3306+
}
3307+
3308+
function Page() {
3309+
const [id, setId] = React.useState(1)
3310+
const [hasChanged, setHasChanged] = React.useState(false)
3311+
3312+
const state = useQuery([key, id], queryFn)
3313+
3314+
states.push(state)
3315+
3316+
React.useEffect(() => {
3317+
setId(prevId => (prevId === 1 ? 2 : 1))
3318+
setHasChanged(true)
3319+
}, [hasChanged])
3320+
3321+
return null
3322+
}
3323+
3324+
renderWithClient(queryClient, <Page />)
3325+
3326+
await sleep(100)
3327+
expect(states.length).toBe(5)
3328+
// Load query 1
3329+
expect(states[0]).toMatchObject({
3330+
status: 'loading',
3331+
error: null,
3332+
})
3333+
// Load query 2
3334+
expect(states[1]).toMatchObject({
3335+
status: 'loading',
3336+
error: null,
3337+
})
3338+
// Load query 1
3339+
expect(states[2]).toMatchObject({
3340+
status: 'loading',
3341+
error: expect.any(CancelledError),
3342+
})
3343+
// State update
3344+
expect(states[3]).toMatchObject({
3345+
status: 'loading',
3346+
error: expect.any(CancelledError),
3347+
})
3348+
// Loaded query 1
3349+
expect(states[4]).toMatchObject({
3350+
status: 'success',
3351+
error: null,
3352+
})
3353+
})
3354+
32723355
it('should update query state and refetch when reset with resetQueries', async () => {
32733356
const key = queryKey()
32743357
const states: UseQueryResult<number>[] = []
@@ -3277,7 +3360,8 @@ describe('useQuery', () => {
32773360
function Page() {
32783361
const state = useQuery(
32793362
key,
3280-
() => {
3363+
async () => {
3364+
await sleep(1)
32813365
count++
32823366
return count
32833367
},

0 commit comments

Comments
 (0)