Skip to content

Commit 4be3ad7

Browse files
Han5991TkDodoautofix-ci[bot]
authored
fix(react-query): allow retryOnMount when throwOnError is function (#9336) (#9338)
* test(react-query): add tests should retry on mount when throwOnError returns false * fix(react-query): refine throwOnError retry logic for error boundary cases When throwOnError is a function, allow retryOnMount to proceed even when error boundary hasn't reset, while maintaining the prevention behavior for boolean throwOnError values. * fix(react-query): enhance throwOnError logic in error boundaries Refine behavior to handle function-type throwOnError, allowing retries when appropriate. Ensure boolean throwOnError values still prevent retries when the error boundary isn't reset. * test: Add tests for `throwOnError` behavior in `useQuery` This commit introduces tests to verify the behavior of the `throwOnError` callback in scenarios where `retryOnMount` is enabled. It ensures proper handling of retries based on the error state and the `throwOnError` function's return value. These tests improve the reliability and coverage of error handling logic in `useQuery`. * fix(react-query): improve throwOnError logic with query state validation - Pass query object to ensurePreventErrorBoundaryRetry for accurate state checking - Preserve query deduplication behavior while fixing throwOnError function handling - Fixes issue where throwOnError function couldn't access query error state * fix(react-query): fix test flakiness and query cache timing (#9338) - Replace vi.waitFor with vi.advanceTimersByTimeAsync in tests - Use separate render results to avoid stale DOM references - Inline fresh query lookup in ensurePreventErrorBoundaryRetry after _experimental_beforeQuery * ci: apply automated fixes * fix(react-query): refine retryOnMount logic and error boundary handling - move query retrieval after `_experimental_beforeQuery` in `useBaseQuery` - refactor `ensurePreventErrorBoundaryRetry` for better clarity - make `query` parameter required in `ensurePreventErrorBoundaryRetry` * ci: apply automated fixes * feat(react-query): make query param required but nullable in ensurePreventErrorBoundaryRetry * Apply suggestions from code review * chore: size-limit * chore: changeset --------- Co-authored-by: Dominik Dorfmeister <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 7f47906 commit 4be3ad7

File tree

6 files changed

+206
-19
lines changed

6 files changed

+206
-19
lines changed

.changeset/long-ties-rescue.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/react-query': patch
3+
---
4+
5+
fix(react-query): allow retryOnMount when throwOnError is function

.size-limit.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
{
33
"name": "react full",
44
"path": "packages/react-query/build/modern/index.js",
5-
"limit": "12.10 kB",
5+
"limit": "12.13 kB",
66
"ignore": ["react", "react-dom"]
77
},
88
{
99
"name": "react minimal",
1010
"path": "packages/react-query/build/modern/index.js",
11-
"limit": "9.12 kB",
11+
"limit": "9.15 kB",
1212
"import": "{ useQuery, QueryClient, QueryClientProvider }",
1313
"ignore": ["react", "react-dom"]
1414
}

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

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5921,6 +5921,7 @@ describe('useQuery', () => {
59215921
it('should be able to toggle subscribed', async () => {
59225922
const key = queryKey()
59235923
const queryFn = vi.fn(() => Promise.resolve('data'))
5924+
59245925
function Page() {
59255926
const [subscribed, setSubscribed] = React.useState(true)
59265927
const { data } = useQuery({
@@ -5965,6 +5966,7 @@ describe('useQuery', () => {
59655966
it('should not be attached to the query when subscribed is false', async () => {
59665967
const key = queryKey()
59675968
const queryFn = vi.fn(() => Promise.resolve('data'))
5969+
59685970
function Page() {
59695971
const { data } = useQuery({
59705972
queryKey: key,
@@ -5993,6 +5995,7 @@ describe('useQuery', () => {
59935995
it('should not re-render when data is added to the cache when subscribed is false', async () => {
59945996
const key = queryKey()
59955997
let renders = 0
5998+
59965999
function Page() {
59976000
const { data } = useQuery({
59986001
queryKey: key,
@@ -6192,6 +6195,7 @@ describe('useQuery', () => {
61926195
await sleep(5)
61936196
return { numbers: { current: { id } } }
61946197
}
6198+
61956199
function Test() {
61966200
const [id, setId] = React.useState(1)
61976201

@@ -6257,6 +6261,7 @@ describe('useQuery', () => {
62576261
await sleep(5)
62586262
return { numbers: { current: { id } } }
62596263
}
6264+
62606265
function Test() {
62616266
const [id, setId] = React.useState(1)
62626267

@@ -6762,10 +6767,12 @@ describe('useQuery', () => {
67626767
it('should console.error when there is no queryFn', () => {
67636768
const consoleErrorMock = vi.spyOn(console, 'error')
67646769
const key = queryKey()
6770+
67656771
function Example() {
67666772
useQuery({ queryKey: key })
67676773
return <></>
67686774
}
6775+
67696776
renderWithClient(queryClient, <Example />)
67706777

67716778
expect(consoleErrorMock).toHaveBeenCalledTimes(1)
@@ -6775,4 +6782,172 @@ describe('useQuery', () => {
67756782

67766783
consoleErrorMock.mockRestore()
67776784
})
6785+
6786+
it('should retry on mount when throwOnError returns false', async () => {
6787+
const key = queryKey()
6788+
let fetchCount = 0
6789+
const queryFn = vi.fn().mockImplementation(() => {
6790+
fetchCount++
6791+
console.log(`Fetching... (attempt ${fetchCount})`)
6792+
return Promise.reject(new Error('Simulated 500 error'))
6793+
})
6794+
6795+
function Component() {
6796+
const { status, error } = useQuery({
6797+
queryKey: key,
6798+
queryFn,
6799+
throwOnError: () => false,
6800+
retryOnMount: true,
6801+
staleTime: Infinity,
6802+
retry: false,
6803+
})
6804+
6805+
return (
6806+
<div>
6807+
<div data-testid="status">{status}</div>
6808+
{error && <div data-testid="error">{error.message}</div>}
6809+
</div>
6810+
)
6811+
}
6812+
6813+
const rendered1 = renderWithClient(queryClient, <Component />)
6814+
await vi.advanceTimersByTimeAsync(0)
6815+
expect(rendered1.getByTestId('status')).toHaveTextContent('error')
6816+
expect(rendered1.getByTestId('error')).toHaveTextContent(
6817+
'Simulated 500 error',
6818+
)
6819+
expect(fetchCount).toBe(1)
6820+
rendered1.unmount()
6821+
6822+
const initialFetchCount = fetchCount
6823+
6824+
const rendered2 = renderWithClient(queryClient, <Component />)
6825+
await vi.advanceTimersByTimeAsync(0)
6826+
expect(rendered2.getByTestId('status')).toHaveTextContent('error')
6827+
6828+
expect(fetchCount).toBe(initialFetchCount + 1)
6829+
expect(queryFn).toHaveBeenCalledTimes(2)
6830+
})
6831+
6832+
it('should not retry on mount when throwOnError function returns true', async () => {
6833+
const key = queryKey()
6834+
let fetchCount = 0
6835+
const queryFn = vi.fn().mockImplementation(() => {
6836+
fetchCount++
6837+
console.log(`Fetching... (attempt ${fetchCount})`)
6838+
return Promise.reject(new Error('Simulated 500 error'))
6839+
})
6840+
6841+
function Component() {
6842+
const { status, error } = useQuery({
6843+
queryKey: key,
6844+
queryFn,
6845+
throwOnError: () => true,
6846+
retryOnMount: true,
6847+
staleTime: Infinity,
6848+
retry: false,
6849+
})
6850+
6851+
return (
6852+
<div>
6853+
<div data-testid="status">{status}</div>
6854+
{error && <div data-testid="error">{error.message}</div>}
6855+
</div>
6856+
)
6857+
}
6858+
6859+
const rendered1 = renderWithClient(
6860+
queryClient,
6861+
<ErrorBoundary
6862+
fallbackRender={({ error }) => (
6863+
<div>
6864+
<div data-testid="status">error</div>
6865+
<div data-testid="error">{error?.message}</div>
6866+
</div>
6867+
)}
6868+
>
6869+
<Component />
6870+
</ErrorBoundary>,
6871+
)
6872+
await vi.advanceTimersByTimeAsync(0)
6873+
expect(rendered1.getByTestId('status')).toHaveTextContent('error')
6874+
expect(rendered1.getByTestId('error')).toHaveTextContent(
6875+
'Simulated 500 error',
6876+
)
6877+
expect(fetchCount).toBe(1)
6878+
6879+
rendered1.unmount()
6880+
6881+
const initialFetchCount = fetchCount
6882+
6883+
const rendered2 = renderWithClient(
6884+
queryClient,
6885+
<ErrorBoundary
6886+
fallbackRender={({ error }) => (
6887+
<div>
6888+
<div data-testid="status">error</div>
6889+
<div data-testid="error">{error?.message}</div>
6890+
</div>
6891+
)}
6892+
>
6893+
<Component />
6894+
</ErrorBoundary>,
6895+
)
6896+
await vi.advanceTimersByTimeAsync(0)
6897+
expect(rendered2.getByTestId('status')).toHaveTextContent('error')
6898+
6899+
// Should not retry because throwOnError returns true
6900+
expect(fetchCount).toBe(initialFetchCount)
6901+
expect(queryFn).toHaveBeenCalledTimes(1)
6902+
})
6903+
6904+
it('should handle throwOnError function based on actual error state', async () => {
6905+
const key = queryKey()
6906+
let fetchCount = 0
6907+
const queryFn = vi.fn().mockImplementation(() => {
6908+
fetchCount++
6909+
console.log(`Fetching... (attempt ${fetchCount})`)
6910+
return Promise.reject(new Error('Simulated 500 error'))
6911+
})
6912+
6913+
function Component() {
6914+
const { status, error } = useQuery({
6915+
queryKey: key,
6916+
queryFn,
6917+
throwOnError: (error) => error.message.includes('404'),
6918+
retryOnMount: true,
6919+
staleTime: Infinity,
6920+
retry: false,
6921+
})
6922+
6923+
return (
6924+
<div>
6925+
<div data-testid="status">{status}</div>
6926+
{error && <div data-testid="error">{error.message}</div>}
6927+
</div>
6928+
)
6929+
}
6930+
6931+
const rendered1 = renderWithClient(queryClient, <Component />)
6932+
6933+
await vi.advanceTimersByTimeAsync(0)
6934+
expect(rendered1.getByTestId('status')).toHaveTextContent('error')
6935+
expect(rendered1.getByTestId('error')).toHaveTextContent(
6936+
'Simulated 500 error',
6937+
)
6938+
expect(fetchCount).toBe(1)
6939+
6940+
rendered1.unmount()
6941+
6942+
const initialFetchCount = fetchCount
6943+
6944+
const rendered2 = renderWithClient(queryClient, <Component />)
6945+
6946+
await vi.advanceTimersByTimeAsync(0)
6947+
expect(rendered2.getByTestId('status')).toHaveTextContent('error')
6948+
6949+
// Should retry because throwOnError returns false (500 error doesn't include '404')
6950+
expect(fetchCount).toBe(initialFetchCount + 1)
6951+
expect(queryFn).toHaveBeenCalledTimes(2)
6952+
})
67786953
})

packages/react-query/src/errorBoundaryUtils.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,17 @@ export const ensurePreventErrorBoundaryRetry = <
2525
TQueryKey
2626
>,
2727
errorResetBoundary: QueryErrorResetBoundaryValue,
28+
query: Query<TQueryFnData, TError, TQueryData, TQueryKey> | undefined,
2829
) => {
30+
const throwOnError =
31+
query?.state.error && typeof options.throwOnError === 'function'
32+
? shouldThrowError(options.throwOnError, [query.state.error, query])
33+
: options.throwOnError
34+
2935
if (
3036
options.suspense ||
31-
options.throwOnError ||
32-
options.experimental_prefetchInRender
37+
options.experimental_prefetchInRender ||
38+
throwOnError
3339
) {
3440
// Prevent retrying failed query if the error boundary has not been reset yet
3541
if (!errorResetBoundary.isReset()) {

packages/react-query/src/useBaseQuery.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,19 @@ export function useBaseQuery<
5353
const errorResetBoundary = useQueryErrorResetBoundary()
5454
const client = useQueryClient(queryClient)
5555
const defaultedOptions = client.defaultQueryOptions(options)
56-
5756
;(client.getDefaultOptions().queries as any)?._experimental_beforeQuery?.(
5857
defaultedOptions,
5958
)
6059

60+
const query = client
61+
.getQueryCache()
62+
.get<
63+
TQueryFnData,
64+
TError,
65+
TQueryData,
66+
TQueryKey
67+
>(defaultedOptions.queryHash)
68+
6169
if (process.env.NODE_ENV !== 'production') {
6270
if (!defaultedOptions.queryFn) {
6371
console.error(
@@ -72,8 +80,7 @@ export function useBaseQuery<
7280
: 'optimistic'
7381

7482
ensureSuspenseTimers(defaultedOptions)
75-
ensurePreventErrorBoundaryRetry(defaultedOptions, errorResetBoundary)
76-
83+
ensurePreventErrorBoundaryRetry(defaultedOptions, errorResetBoundary, query)
7784
useClearResetErrorBoundary(errorResetBoundary)
7885

7986
// this needs to be invoked before creating the Observer because that can create a cache entry
@@ -127,14 +134,7 @@ export function useBaseQuery<
127134
result,
128135
errorResetBoundary,
129136
throwOnError: defaultedOptions.throwOnError,
130-
query: client
131-
.getQueryCache()
132-
.get<
133-
TQueryFnData,
134-
TError,
135-
TQueryData,
136-
TQueryKey
137-
>(defaultedOptions.queryHash),
137+
query,
138138
suspense: defaultedOptions.suspense,
139139
})
140140
) {
@@ -155,7 +155,7 @@ export function useBaseQuery<
155155
? // Fetch immediately on render in order to ensure `.promise` is resolved even if the component is unmounted
156156
fetchOptimistic(defaultedOptions, observer, errorResetBoundary)
157157
: // subscribe to the "cache promise" so that we can finalize the currentThenable once data comes in
158-
client.getQueryCache().get(defaultedOptions.queryHash)?.promise
158+
query?.promise
159159

160160
promise?.catch(noop).finally(() => {
161161
// `.updateResult()` will trigger `.#currentThenable` to finalize

packages/react-query/src/useQueries.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,10 @@ export function useQueries<
242242
[queries, client, isRestoring],
243243
)
244244

245-
defaultedQueries.forEach((query) => {
246-
ensureSuspenseTimers(query)
247-
ensurePreventErrorBoundaryRetry(query, errorResetBoundary)
245+
defaultedQueries.forEach((queryOptions) => {
246+
ensureSuspenseTimers(queryOptions)
247+
const query = client.getQueryCache().get(queryOptions.queryHash)
248+
ensurePreventErrorBoundaryRetry(queryOptions, errorResetBoundary, query)
248249
})
249250

250251
useClearResetErrorBoundary(errorResetBoundary)

0 commit comments

Comments
 (0)