Skip to content

Commit 4d5cc66

Browse files
authored
fix(core): optimistic results reflect correct state (#7114)
* fix(core): optimistic results reflect correct state when computing the optimistic result, we need to set / reset all properties that the `fetch` action in the query reducer will also set. This fix extracts the logic into a function and calls it in both places * chore: comment
1 parent 10ae75e commit 4d5cc66

File tree

3 files changed

+146
-33
lines changed

3 files changed

+146
-33
lines changed

packages/query-core/src/query.ts

+22-9
Original file line numberDiff line numberDiff line change
@@ -552,16 +552,8 @@ export class Query<
552552
case 'fetch':
553553
return {
554554
...state,
555-
fetchFailureCount: 0,
556-
fetchFailureReason: null,
555+
...fetchState(state.data, this.options),
557556
fetchMeta: action.meta ?? null,
558-
fetchStatus: canFetch(this.options.networkMode)
559-
? 'fetching'
560-
: 'paused',
561-
...(state.data === undefined && {
562-
error: null,
563-
status: 'pending',
564-
}),
565557
}
566558
case 'success':
567559
return {
@@ -620,6 +612,27 @@ export class Query<
620612
}
621613
}
622614

615+
export function fetchState<
616+
TQueryFnData,
617+
TError,
618+
TData,
619+
TQueryKey extends QueryKey,
620+
>(
621+
data: TData | undefined,
622+
options: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
623+
) {
624+
return {
625+
fetchFailureCount: 0,
626+
fetchFailureReason: null,
627+
fetchStatus: canFetch(options.networkMode) ? 'fetching' : 'paused',
628+
...(data === undefined &&
629+
({
630+
error: null,
631+
status: 'pending',
632+
} as const)),
633+
} as const
634+
}
635+
623636
function getDefaultState<
624637
TQueryFnData,
625638
TError,

packages/query-core/src/queryObserver.ts

+24-24
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import {
99
import { notifyManager } from './notifyManager'
1010
import { focusManager } from './focusManager'
1111
import { Subscribable } from './subscribable'
12-
import { canFetch } from './retryer'
13-
import type { QueryClient } from './queryClient'
12+
import { fetchState } from './query'
1413
import type { FetchOptions, Query, QueryState } from './query'
14+
import type { QueryClient } from './queryClient'
1515
import type {
1616
DefaultError,
1717
DefaultedQueryObserverOptions,
@@ -437,7 +437,7 @@ export class QueryObserver<
437437
: this.#currentQueryInitialState
438438

439439
const { state } = query
440-
let { error, errorUpdatedAt, fetchStatus, status } = state
440+
let newState = { ...state }
441441
let isPlaceholderData = false
442442
let data: TData | undefined
443443

@@ -451,31 +451,31 @@ export class QueryObserver<
451451
mounted && shouldFetchOptionally(query, prevQuery, options, prevOptions)
452452

453453
if (fetchOnMount || fetchOptionally) {
454-
fetchStatus = canFetch(query.options.networkMode)
455-
? 'fetching'
456-
: 'paused'
457-
if (state.data === undefined) {
458-
status = 'pending'
454+
newState = {
455+
...newState,
456+
...fetchState(state.data, query.options),
459457
}
460458
}
461459
if (options._optimisticResults === 'isRestoring') {
462-
fetchStatus = 'idle'
460+
newState.fetchStatus = 'idle'
463461
}
464462
}
465463

464+
let { error, errorUpdatedAt, status } = newState
465+
466466
// Select data if needed
467-
if (options.select && state.data !== undefined) {
467+
if (options.select && newState.data !== undefined) {
468468
// Memoize select result
469469
if (
470470
prevResult &&
471-
state.data === prevResultState?.data &&
471+
newState.data === prevResultState?.data &&
472472
options.select === this.#selectFn
473473
) {
474474
data = this.#selectResult
475475
} else {
476476
try {
477477
this.#selectFn = options.select
478-
data = options.select(state.data)
478+
data = options.select(newState.data)
479479
data = replaceData(prevResult?.data, data, options)
480480
this.#selectResult = data
481481
this.#selectError = null
@@ -486,7 +486,7 @@ export class QueryObserver<
486486
}
487487
// Use query data
488488
else {
489-
data = state.data as unknown as TData
489+
data = newState.data as unknown as TData
490490
}
491491

492492
// Show placeholder data if needed
@@ -541,36 +541,36 @@ export class QueryObserver<
541541
status = 'error'
542542
}
543543

544-
const isFetching = fetchStatus === 'fetching'
544+
const isFetching = newState.fetchStatus === 'fetching'
545545
const isPending = status === 'pending'
546546
const isError = status === 'error'
547547

548548
const isLoading = isPending && isFetching
549-
const hasData = state.data !== undefined
549+
const hasData = data !== undefined
550550

551551
const result: QueryObserverBaseResult<TData, TError> = {
552552
status,
553-
fetchStatus,
553+
fetchStatus: newState.fetchStatus,
554554
isPending,
555555
isSuccess: status === 'success',
556556
isError,
557557
isInitialLoading: isLoading,
558558
isLoading,
559559
data,
560-
dataUpdatedAt: state.dataUpdatedAt,
560+
dataUpdatedAt: newState.dataUpdatedAt,
561561
error,
562562
errorUpdatedAt,
563-
failureCount: state.fetchFailureCount,
564-
failureReason: state.fetchFailureReason,
565-
errorUpdateCount: state.errorUpdateCount,
566-
isFetched: state.dataUpdateCount > 0 || state.errorUpdateCount > 0,
563+
failureCount: newState.fetchFailureCount,
564+
failureReason: newState.fetchFailureReason,
565+
errorUpdateCount: newState.errorUpdateCount,
566+
isFetched: newState.dataUpdateCount > 0 || newState.errorUpdateCount > 0,
567567
isFetchedAfterMount:
568-
state.dataUpdateCount > queryInitialState.dataUpdateCount ||
569-
state.errorUpdateCount > queryInitialState.errorUpdateCount,
568+
newState.dataUpdateCount > queryInitialState.dataUpdateCount ||
569+
newState.errorUpdateCount > queryInitialState.errorUpdateCount,
570570
isFetching,
571571
isRefetching: isFetching && !isPending,
572572
isLoadingError: isError && !hasData,
573-
isPaused: fetchStatus === 'paused',
573+
isPaused: newState.fetchStatus === 'paused',
574574
isPlaceholderData,
575575
isRefetchError: isError && hasData,
576576
isStale: isStale(query, options),

packages/react-query/src/__tests__/useQuery.test.tsx

+100
Original file line numberDiff line numberDiff line change
@@ -6375,4 +6375,104 @@ describe('useQuery', () => {
63756375
await waitFor(() => rendered.getByText('status: success'))
63766376
await waitFor(() => rendered.getByText('data: data'))
63776377
})
6378+
6379+
it('should return correct optimistic result when fetching after error', async () => {
6380+
const key = queryKey()
6381+
const error = new Error('oh no')
6382+
6383+
const results: Array<UseQueryResult<string>> = []
6384+
6385+
function Page() {
6386+
const query = useQuery({
6387+
queryKey: key,
6388+
queryFn: async () => {
6389+
await sleep(10)
6390+
return Promise.reject(error)
6391+
},
6392+
retry: false,
6393+
notifyOnChangeProps: 'all',
6394+
})
6395+
6396+
results.push(query)
6397+
6398+
return (
6399+
<div>
6400+
<div>
6401+
status: {query.status}, {query.fetchStatus}
6402+
</div>
6403+
<div>error: {query.error?.message}</div>
6404+
</div>
6405+
)
6406+
}
6407+
6408+
function App() {
6409+
const [enabled, setEnabled] = React.useState(true)
6410+
6411+
return (
6412+
<div>
6413+
<button onClick={() => setEnabled(!enabled)}>toggle</button>
6414+
{enabled && <Page />}
6415+
</div>
6416+
)
6417+
}
6418+
6419+
const rendered = renderWithClient(queryClient, <App />)
6420+
6421+
await waitFor(() => rendered.getByText('status: error, idle'))
6422+
6423+
fireEvent.click(rendered.getByRole('button', { name: 'toggle' }))
6424+
fireEvent.click(rendered.getByRole('button', { name: 'toggle' }))
6425+
6426+
await waitFor(() => rendered.getByText('status: error, idle'))
6427+
6428+
expect(results).toHaveLength(4)
6429+
6430+
// initial fetch
6431+
expect(results[0]).toMatchObject({
6432+
status: 'pending',
6433+
fetchStatus: 'fetching',
6434+
error: null,
6435+
errorUpdatedAt: 0,
6436+
errorUpdateCount: 0,
6437+
isLoading: true,
6438+
failureCount: 0,
6439+
failureReason: null,
6440+
})
6441+
6442+
// error state
6443+
expect(results[1]).toMatchObject({
6444+
status: 'error',
6445+
fetchStatus: 'idle',
6446+
error,
6447+
errorUpdateCount: 1,
6448+
isLoading: false,
6449+
failureCount: 1,
6450+
failureReason: error,
6451+
})
6452+
expect(results[1]?.errorUpdatedAt).toBeGreaterThan(0)
6453+
6454+
// refetch, optimistic state, no errors anymore
6455+
expect(results[2]).toMatchObject({
6456+
status: 'pending',
6457+
fetchStatus: 'fetching',
6458+
error: null,
6459+
errorUpdateCount: 1,
6460+
isLoading: true,
6461+
failureCount: 0,
6462+
failureReason: null,
6463+
})
6464+
expect(results[2]?.errorUpdatedAt).toBeGreaterThan(0)
6465+
6466+
// final state
6467+
expect(results[3]).toMatchObject({
6468+
status: 'error',
6469+
fetchStatus: 'idle',
6470+
error: error,
6471+
errorUpdateCount: 2,
6472+
isLoading: false,
6473+
failureCount: 1,
6474+
failureReason: error,
6475+
})
6476+
expect(results[3]?.errorUpdatedAt).toBeGreaterThan(0)
6477+
})
63786478
})

0 commit comments

Comments
 (0)