Skip to content

Commit 09a375b

Browse files
authored
fix(queryObserver): cache select errors (#3554)
there is no need to re-run the select function if it is referentially stable, even when it throws an error. if no dependency has changed, we can re-use the cached error, as the select function should be idempotent and thus return the same error anyway. further, if we have cached data from a previously successful select, that should be returned as `data` alongside the error.
1 parent df80cc8 commit 09a375b

File tree

3 files changed

+117
-32
lines changed

3 files changed

+117
-32
lines changed

src/core/queryObserver.ts

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,9 @@ export class QueryObserver<
6868
TQueryKey
6969
>
7070
private previousQueryResult?: QueryObserverResult<TData, TError>
71-
private previousSelectError: Error | null
72-
private previousSelect?: {
73-
fn: (data: TQueryData) => TData
74-
result: TData
75-
}
71+
private selectError: Error | null
72+
private selectFn?: (data: TQueryData) => TData
73+
private selectResult?: TData
7674
private staleTimeoutId?: number
7775
private refetchIntervalId?: number
7876
private currentRefetchInterval?: number | false
@@ -93,7 +91,7 @@ export class QueryObserver<
9391
this.client = client
9492
this.options = options
9593
this.trackedProps = []
96-
this.previousSelectError = null
94+
this.selectError = null
9795
this.bindMethods()
9896
this.setOptions(options)
9997
}
@@ -508,27 +506,21 @@ export class QueryObserver<
508506
if (
509507
prevResult &&
510508
state.data === prevResultState?.data &&
511-
options.select === this.previousSelect?.fn &&
512-
!this.previousSelectError
509+
options.select === this.selectFn
513510
) {
514-
data = this.previousSelect.result
511+
data = this.selectResult
515512
} else {
516513
try {
514+
this.selectFn = options.select
517515
data = options.select(state.data)
518516
if (options.structuralSharing !== false) {
519517
data = replaceEqualDeep(prevResult?.data, data)
520518
}
521-
this.previousSelect = {
522-
fn: options.select,
523-
result: data,
524-
}
525-
this.previousSelectError = null
519+
this.selectResult = data
520+
this.selectError = null
526521
} catch (selectError) {
527522
getLogger().error(selectError)
528-
error = selectError
529-
this.previousSelectError = selectError
530-
errorUpdatedAt = Date.now()
531-
status = 'error'
523+
this.selectError = selectError
532524
}
533525
}
534526
}
@@ -565,13 +557,10 @@ export class QueryObserver<
565557
placeholderData
566558
)
567559
}
568-
this.previousSelectError = null
560+
this.selectError = null
569561
} catch (selectError) {
570562
getLogger().error(selectError)
571-
error = selectError
572-
this.previousSelectError = selectError
573-
errorUpdatedAt = Date.now()
574-
status = 'error'
563+
this.selectError = selectError
575564
}
576565
}
577566
}
@@ -583,6 +572,13 @@ export class QueryObserver<
583572
}
584573
}
585574

575+
if (this.selectError) {
576+
error = this.selectError as any
577+
data = this.selectResult
578+
errorUpdatedAt = Date.now()
579+
status = 'error'
580+
}
581+
586582
const result: QueryObserverBaseResult<TData, TError> = {
587583
status,
588584
isLoading: status === 'loading',

src/core/tests/queryObserver.test.tsx

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -245,26 +245,25 @@ describe('queryObserver', () => {
245245
expect(observerResult2.data).toMatchObject({ myCount: 1 })
246246
})
247247

248-
test('should always run the selector again if selector throws an error', async () => {
248+
test('should always run the selector again if selector throws an error and selector is not referentially stable', async () => {
249249
const consoleMock = mockConsoleError()
250250
const key = queryKey()
251251
const results: QueryObserverResult[] = []
252-
const select = () => {
253-
throw new Error('selector error')
254-
}
255252
const queryFn = () => ({ count: 1 })
256253
const observer = new QueryObserver(queryClient, {
257254
queryKey: key,
258255
queryFn,
259-
select,
256+
select: () => {
257+
throw new Error('selector error')
258+
},
260259
})
261260
const unsubscribe = observer.subscribe(result => {
262261
results.push(result)
263262
})
264263
await sleep(1)
265264
await observer.refetch()
266265
unsubscribe()
267-
expect(results.length).toBe(5)
266+
expect(results.length).toBe(4)
268267
expect(results[0]).toMatchObject({
269268
status: 'loading',
270269
isFetching: true,
@@ -285,11 +284,60 @@ describe('queryObserver', () => {
285284
isFetching: false,
286285
data: undefined,
287286
})
288-
expect(results[4]).toMatchObject({
287+
consoleMock.mockRestore()
288+
})
289+
290+
test('should return stale data if selector throws an error', async () => {
291+
const consoleMock = mockConsoleError()
292+
const key = queryKey()
293+
const results: QueryObserverResult[] = []
294+
let shouldError = false
295+
const error = new Error('select error')
296+
const observer = new QueryObserver(queryClient, {
297+
queryKey: key,
298+
queryFn: () => (shouldError ? 2 : 1),
299+
select: num => {
300+
if (shouldError) {
301+
throw error
302+
}
303+
shouldError = true
304+
return String(num)
305+
},
306+
})
307+
308+
const unsubscribe = observer.subscribe(result => {
309+
results.push(result)
310+
})
311+
await sleep(10)
312+
await observer.refetch()
313+
unsubscribe()
314+
315+
expect(results.length).toBe(4)
316+
expect(results[0]).toMatchObject({
317+
status: 'loading',
318+
isFetching: true,
319+
data: undefined,
320+
error: null,
321+
})
322+
expect(results[1]).toMatchObject({
323+
status: 'success',
324+
isFetching: false,
325+
data: '1',
326+
error: null,
327+
})
328+
expect(results[2]).toMatchObject({
329+
status: 'success',
330+
isFetching: true,
331+
data: '1',
332+
error: null,
333+
})
334+
expect(results[3]).toMatchObject({
289335
status: 'error',
290336
isFetching: false,
291-
data: undefined,
337+
data: '1',
338+
error,
292339
})
340+
293341
consoleMock.mockRestore()
294342
})
295343

src/react/tests/useQuery.test.tsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,47 @@ describe('useQuery', () => {
820820
consoleMock.mockRestore()
821821
})
822822

823+
it('should not re-run a stable select when it re-renders if selector throws an error', async () => {
824+
const consoleMock = mockConsoleError()
825+
const key = queryKey()
826+
const error = new Error('Select Error')
827+
let runs = 0
828+
829+
function Page() {
830+
const [, rerender] = React.useReducer(() => ({}), {})
831+
const state = useQuery<string, Error>(
832+
key,
833+
() => (runs === 0 ? 'test' : 'test2'),
834+
{
835+
select: React.useCallback(() => {
836+
runs++
837+
throw error
838+
}, []),
839+
}
840+
)
841+
return (
842+
<div>
843+
<div>error: {state.error?.message}</div>
844+
<button onClick={rerender}>rerender</button>
845+
<button onClick={() => state.refetch()}>refetch</button>
846+
</div>
847+
)
848+
}
849+
850+
const rendered = renderWithClient(queryClient, <Page />)
851+
852+
await waitFor(() => rendered.getByText('error: Select Error'))
853+
expect(runs).toEqual(1)
854+
fireEvent.click(rendered.getByRole('button', { name: 'rerender' }))
855+
await sleep(10)
856+
expect(runs).toEqual(1)
857+
fireEvent.click(rendered.getByRole('button', { name: 'refetch' }))
858+
await sleep(10)
859+
expect(runs).toEqual(2)
860+
861+
consoleMock.mockRestore()
862+
})
863+
823864
it('should re-render when dataUpdatedAt changes but data remains the same', async () => {
824865
const key = queryKey()
825866
const states: UseQueryResult<string>[] = []
@@ -4134,7 +4175,7 @@ describe('useQuery', () => {
41344175
await waitFor(() => rendered.getByText('Data: selected 3'))
41354176
})
41364177

4137-
it('select should structually share data', async () => {
4178+
it('select should structurally share data', async () => {
41384179
const key1 = queryKey()
41394180
const states: Array<Array<number>> = []
41404181

0 commit comments

Comments
 (0)