From 1785a4dd2dc175035d7333afe6477837c4bc8d35 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 18 Apr 2026 19:50:30 +0200 Subject: [PATCH 1/3] feat(core): callback for retryOnMount --- .changeset/silver-places-pick.md | 5 + docs/framework/react/reference/useQuery.md | 5 +- docs/framework/solid/reference/useQuery.md | 5 +- .../QueryResetErrorBoundary.test.tsx | 4 +- .../src/__tests__/useQuery.test.tsx | 10 +- packages/query-core/src/query.ts | 4 +- packages/query-core/src/queryObserver.ts | 27 +++--- packages/query-core/src/types.ts | 12 ++- packages/query-core/src/utils.ts | 10 +- .../QueryResetErrorBoundary.test.tsx | 4 +- .../src/__tests__/useQuery.test.tsx | 97 +++++++++++++++++-- .../src/__tests__/useQuery.test.tsx | 8 +- 12 files changed, 144 insertions(+), 47 deletions(-) create mode 100644 .changeset/silver-places-pick.md diff --git a/.changeset/silver-places-pick.md b/.changeset/silver-places-pick.md new file mode 100644 index 00000000000..04d201a72fd --- /dev/null +++ b/.changeset/silver-places-pick.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': minor +--- + +feat(query-core): accept callback function for retryOnMount diff --git a/docs/framework/react/reference/useQuery.md b/docs/framework/react/reference/useQuery.md index c17fe8b79db..b02775a62cf 100644 --- a/docs/framework/react/reference/useQuery.md +++ b/docs/framework/react/reference/useQuery.md @@ -86,8 +86,9 @@ const { - If set to a `number`, e.g. `3`, failed queries will retry until the failed query count meets that number. - If set to a function, it will be called with `failureCount` (starting at `0` for the first retry) and `error` to determine if a retry should be attempted. - defaults to `3` on the client and `0` on the server -- `retryOnMount: boolean` - - If set to `false`, the query will not be retried on mount if it contains an error. Defaults to `true`. +- `retryOnMount: boolean | (query: Query) => boolean` + - If set to `false`, the query will not be retried on mount if it contains an error and has no data. Defaults to `true`. + - If set to a function, the function will be executed with the query to compute the value. - `retryDelay: number | (retryAttempt: number, error: TError) => number` - This function receives a `retryAttempt` integer and the actual Error and returns the delay to apply before the next attempt in milliseconds. - A function like `attempt => Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000)` applies exponential backoff. diff --git a/docs/framework/solid/reference/useQuery.md b/docs/framework/solid/reference/useQuery.md index 38b9751d758..876c5c99729 100644 --- a/docs/framework/solid/reference/useQuery.md +++ b/docs/framework/solid/reference/useQuery.md @@ -277,8 +277,9 @@ function App() { - If `true`, failed queries will retry infinitely. - If set to a `number`, e.g. `3`, failed queries will retry until the failed query count meets that number. - defaults to `3` on the client and `0` on the server - - ##### `retryOnMount: boolean` - - If set to `false`, the query will not be retried on mount if it contains an error. Defaults to `true`. + - ##### `retryOnMount: boolean | (query: Query) => boolean` + - If set to `false`, the query will not be retried on mount if it contains an error and has no data. Defaults to `true`. + - If set to a function, the function will be executed with the query to compute the value. - ##### `retryDelay: number | (retryAttempt: number, error: TError) => number` - This function receives a `retryAttempt` integer and the actual Error and returns the delay to apply before the next attempt in milliseconds. - A function like `attempt => Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000)` applies exponential backoff. diff --git a/packages/preact-query/src/__tests__/QueryResetErrorBoundary.test.tsx b/packages/preact-query/src/__tests__/QueryResetErrorBoundary.test.tsx index 5d873cf1800..0b35921737e 100644 --- a/packages/preact-query/src/__tests__/QueryResetErrorBoundary.test.tsx +++ b/packages/preact-query/src/__tests__/QueryResetErrorBoundary.test.tsx @@ -753,7 +753,7 @@ describe('QueryErrorResetBoundary', () => { }), retry: false, throwOnError: true, - retryOnMount: true, + retryOnMount: () => true, }, ], }) @@ -818,7 +818,7 @@ describe('QueryErrorResetBoundary', () => { return 'data' }), retry: false, - retryOnMount: true, + retryOnMount: () => true, }, ], }) diff --git a/packages/preact-query/src/__tests__/useQuery.test.tsx b/packages/preact-query/src/__tests__/useQuery.test.tsx index 1f26a863951..4146cc2a571 100644 --- a/packages/preact-query/src/__tests__/useQuery.test.tsx +++ b/packages/preact-query/src/__tests__/useQuery.test.tsx @@ -4919,7 +4919,7 @@ describe('useQuery', () => { queryFn, enabled, retry: false, - retryOnMount: false, + retryOnMount: () => false, refetchOnMount: false, refetchOnWindowFocus: false, }) @@ -4979,7 +4979,7 @@ describe('useQuery', () => { return 'data' }, retry: false, - retryOnMount: false, + retryOnMount: () => false, refetchOnMount: false, refetchOnWindowFocus: false, }) @@ -5033,7 +5033,7 @@ describe('useQuery', () => { queryFn: () => sleep(10).then(() => Promise.reject(new Error('Error'))), retry: false, - retryOnMount: false, + retryOnMount: () => false, refetchOnMount: false, refetchOnWindowFocus: false, }) @@ -6012,7 +6012,7 @@ describe('useQuery', () => { queryKey: key, queryFn, retry: false, - retryOnMount: false, + retryOnMount: () => false, }) states.push(state) @@ -6433,7 +6433,7 @@ describe('useQuery', () => { ? () => sleep(10).then(() => Promise.resolve('data')) : skipToken, retry: false, - retryOnMount: false, + retryOnMount: () => false, refetchOnMount: false, refetchOnWindowFocus: false, }) diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 7dfaa587721..b896290d90f 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -2,7 +2,7 @@ import { ensureQueryFn, noop, replaceData, - resolveEnabled, + resolveQueryBoolean, resolveStaleTime, skipToken, timeUntilStale, @@ -271,7 +271,7 @@ export class Query< isActive(): boolean { return this.observers.some( - (observer) => resolveEnabled(observer.options.enabled, this) !== false, + (observer) => resolveQueryBoolean(observer.options.enabled, this) !== false, ) } diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index fa950bcfff3..77dae69c97a 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -8,7 +8,7 @@ import { isValidTimeout, noop, replaceData, - resolveEnabled, + resolveQueryBoolean, resolveStaleTime, shallowEqualObjects, timeUntilStale, @@ -153,7 +153,7 @@ export class QueryObserver< this.options.enabled !== undefined && typeof this.options.enabled !== 'boolean' && typeof this.options.enabled !== 'function' && - typeof resolveEnabled(this.options.enabled, this.#currentQuery) !== + typeof resolveQueryBoolean(this.options.enabled, this.#currentQuery) !== 'boolean' ) { throw new Error( @@ -197,8 +197,8 @@ export class QueryObserver< if ( mounted && (this.#currentQuery !== prevQuery || - resolveEnabled(this.options.enabled, this.#currentQuery) !== - resolveEnabled(prevOptions.enabled, this.#currentQuery) || + resolveQueryBoolean(this.options.enabled, this.#currentQuery) !== + resolveQueryBoolean(prevOptions.enabled, this.#currentQuery) || resolveStaleTime(this.options.staleTime, this.#currentQuery) !== resolveStaleTime(prevOptions.staleTime, this.#currentQuery)) ) { @@ -211,8 +211,8 @@ export class QueryObserver< if ( mounted && (this.#currentQuery !== prevQuery || - resolveEnabled(this.options.enabled, this.#currentQuery) !== - resolveEnabled(prevOptions.enabled, this.#currentQuery) || + resolveQueryBoolean(this.options.enabled, this.#currentQuery) !== + resolveQueryBoolean(prevOptions.enabled, this.#currentQuery) || nextRefetchInterval !== this.#currentRefetchInterval) ) { this.#updateRefetchInterval(nextRefetchInterval) @@ -394,7 +394,7 @@ export class QueryObserver< if ( environmentManager.isServer() || - resolveEnabled(this.options.enabled, this.#currentQuery) === false || + resolveQueryBoolean(this.options.enabled, this.#currentQuery) === false || !isValidTimeout(this.#currentRefetchInterval) || this.#currentRefetchInterval === 0 ) { @@ -589,7 +589,7 @@ export class QueryObserver< isStale: isStale(query, options), refetch: this.refetch, promise: this.#currentThenable, - isEnabled: resolveEnabled(options.enabled, query) !== false, + isEnabled: resolveQueryBoolean(options.enabled, query) !== false, } const nextResult = result as QueryObserverResult @@ -750,9 +750,10 @@ function shouldLoadOnMount( options: QueryObserverOptions, ): boolean { return ( - resolveEnabled(options.enabled, query) !== false && + resolveQueryBoolean(options.enabled, query) !== false && query.state.data === undefined && - !(query.state.status === 'error' && options.retryOnMount === false) + !(query.state.status === 'error' && + resolveQueryBoolean(options.retryOnMount, query) === false) ) } @@ -775,7 +776,7 @@ function shouldFetchOn( (typeof options)['refetchOnReconnect'], ) { if ( - resolveEnabled(options.enabled, query) !== false && + resolveQueryBoolean(options.enabled, query) !== false && resolveStaleTime(options.staleTime, query) !== 'static' ) { const value = typeof field === 'function' ? field(query) : field @@ -793,7 +794,7 @@ function shouldFetchOptionally( ): boolean { return ( (query !== prevQuery || - resolveEnabled(prevOptions.enabled, query) === false) && + resolveQueryBoolean(prevOptions.enabled, query) === false) && (!options.suspense || query.state.status !== 'error') && isStale(query, options) ) @@ -804,7 +805,7 @@ function isStale( options: QueryObserverOptions, ): boolean { return ( - resolveEnabled(options.enabled, query) !== false && + resolveQueryBoolean(options.enabled, query) !== false && query.isStaleByTime(resolveStaleTime(options.staleTime, query)) ) } diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 4f3f4caed20..a3b41678a8a 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -110,7 +110,7 @@ export type StaleTimeFunction< | StaleTime | ((query: Query) => StaleTime) -export type Enabled< +export type QueryBooleanOption< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, @@ -326,7 +326,7 @@ export interface QueryObserverOptions< * Accepts a boolean or function that returns a boolean. * Defaults to `true`. */ - enabled?: Enabled + enabled?: QueryBooleanOption /** * The time in milliseconds after data is considered stale. * If set to `Infinity`, the data will never be considered stale. @@ -391,9 +391,15 @@ export interface QueryObserverOptions< ) => boolean | 'always') /** * If set to `false`, the query will not be retried on mount if it contains an error. + * If set to a function, the function will be executed with the query to compute the value. * Defaults to `true`. */ - retryOnMount?: boolean + retryOnMount?: QueryBooleanOption< + TQueryFnData, + TError, + TQueryData, + TQueryKey + > /** * If set, the component will only re-render if any of the listed properties change. * When set to `['data', 'error']`, the component will only re-render when the `data` or `error` properties change. diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index b29e8ded456..b97b2cc5a33 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -1,10 +1,10 @@ import { timeoutManager } from './timeoutManager' import type { DefaultError, - Enabled, FetchStatus, MutationKey, MutationStatus, + QueryBooleanOption, QueryFunction, QueryKey, QueryOptions, @@ -126,16 +126,18 @@ export function resolveStaleTime< return typeof staleTime === 'function' ? staleTime(query) : staleTime } -export function resolveEnabled< +export function resolveQueryBoolean< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( - enabled: undefined | Enabled, + option: + | undefined + | QueryBooleanOption, query: Query, ): boolean | undefined { - return typeof enabled === 'function' ? enabled(query) : enabled + return typeof option === 'function' ? option(query) : option } export function matchQuery( diff --git a/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx b/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx index 016e7f34d01..f9973bba2cc 100644 --- a/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx +++ b/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx @@ -829,7 +829,7 @@ describe('QueryErrorResetBoundary', () => { }), retry: false, throwOnError: true, - retryOnMount: true, + retryOnMount: () => true, }, ], }) @@ -894,7 +894,7 @@ describe('QueryErrorResetBoundary', () => { return 'data' }), retry: false, - retryOnMount: true, + retryOnMount: () => true, }, ], }) diff --git a/packages/react-query/src/__tests__/useQuery.test.tsx b/packages/react-query/src/__tests__/useQuery.test.tsx index 66e8ed27288..b0d83d13da5 100644 --- a/packages/react-query/src/__tests__/useQuery.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.test.tsx @@ -4898,7 +4898,7 @@ describe('useQuery', () => { queryFn, enabled, retry: false, - retryOnMount: false, + retryOnMount: () => false, refetchOnMount: false, refetchOnWindowFocus: false, }) @@ -4959,7 +4959,7 @@ describe('useQuery', () => { } }, retry: false, - retryOnMount: false, + retryOnMount: () => false, refetchOnMount: false, refetchOnWindowFocus: false, }) @@ -5013,7 +5013,7 @@ describe('useQuery', () => { queryFn: () => sleep(10).then(() => Promise.reject(new Error('Error'))), retry: false, - retryOnMount: false, + retryOnMount: () => false, refetchOnMount: false, refetchOnWindowFocus: false, }) @@ -5996,7 +5996,7 @@ describe('useQuery', () => { queryKey: key, queryFn, retry: false, - retryOnMount: false, + retryOnMount: () => false, }) states.push(state) @@ -6419,7 +6419,7 @@ describe('useQuery', () => { ? () => sleep(10).then(() => Promise.resolve('data')) : skipToken, retry: false, - retryOnMount: false, + retryOnMount: () => false, refetchOnMount: false, refetchOnWindowFocus: false, }) @@ -6729,7 +6729,7 @@ describe('useQuery', () => { queryKey: key, queryFn, throwOnError: () => false, - retryOnMount: true, + retryOnMount: () => true, staleTime: Infinity, retry: false, }) @@ -6775,7 +6775,7 @@ describe('useQuery', () => { queryKey: key, queryFn, throwOnError: () => true, - retryOnMount: true, + retryOnMount: () => true, staleTime: Infinity, retry: false, }) @@ -6847,7 +6847,7 @@ describe('useQuery', () => { queryKey: key, queryFn, throwOnError: (error) => error.message.includes('404'), - retryOnMount: true, + retryOnMount: () => true, staleTime: Infinity, retry: false, }) @@ -6883,6 +6883,87 @@ describe('useQuery', () => { expect(queryFn).toHaveBeenCalledTimes(2) }) + it('should pass the query to retryOnMount callback', async () => { + const key = queryKey() + const queryFn = vi.fn().mockRejectedValue(new Error('oops')) + const retryOnMount = vi.fn((query) => query.state.status !== 'error') + + function Page() { + const { status } = useQuery({ + queryKey: key, + queryFn, + retry: false, + retryOnMount, + }) + + return
{status}
+ } + + const rendered1 = renderWithClient(queryClient, ) + await vi.advanceTimersByTimeAsync(0) + expect(rendered1.getByText('error')).toBeInTheDocument() + rendered1.unmount() + + renderWithClient(queryClient, ) + await vi.advanceTimersByTimeAsync(0) + + expect(retryOnMount).toHaveBeenCalled() + const query = retryOnMount.mock.calls.at(-1)![0] + expect(query.state.status).toBe('error') + expect(query.state.data).toBeUndefined() + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should not call retryOnMount callback when the query has data', async () => { + const key = queryKey() + let count = 0 + const retryOnMount = vi.fn(() => false) + const queryFn = vi.fn().mockImplementation(async () => { + await sleep(10) + count++ + if (count === 1) { + return 'data' + } + throw new Error('oops') + }) + + function Page() { + const { data, error, refetch } = useQuery({ + queryKey: key, + queryFn, + retry: false, + staleTime: 0, + refetchOnMount: true, + retryOnMount, + }) + + return ( +
+
{data ?? 'no data'}
+
{error instanceof Error ? error.message : 'no error'}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: 'refetch' })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('data')).toBeInTheDocument() + expect(rendered.getByText('oops')).toBeInTheDocument() + + rendered.unmount() + + renderWithClient(queryClient, ) + await vi.advanceTimersByTimeAsync(11) + + expect(retryOnMount).not.toHaveBeenCalled() + expect(queryFn).toHaveBeenCalledTimes(3) + }) + it('should not fetch for the duration of the restoring period when isRestoring is true', async () => { const key = queryKey() const queryFn = vi diff --git a/packages/solid-query/src/__tests__/useQuery.test.tsx b/packages/solid-query/src/__tests__/useQuery.test.tsx index 904f82e0c47..085c7489347 100644 --- a/packages/solid-query/src/__tests__/useQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test.tsx @@ -5059,7 +5059,7 @@ describe('useQuery', () => { queryFn, enabled: props.enabled, retry: false, - retryOnMount: false, + retryOnMount: () => false, refetchOnMount: false, refetchOnWindowFocus: false, })) @@ -5126,7 +5126,7 @@ describe('useQuery', () => { props.id % 2 === 1 ? Promise.reject(new Error('Error')) : 'data', ), retry: false, - retryOnMount: false, + retryOnMount: () => false, refetchOnMount: false, refetchOnWindowFocus: false, })) @@ -5186,7 +5186,7 @@ describe('useQuery', () => { queryKey: [props.id], queryFn: () => sleep(10).then(() => Promise.reject(new Error('Error'))), retry: false, - retryOnMount: false, + retryOnMount: () => false, refetchOnMount: false, refetchOnWindowFocus: false, })) @@ -6188,7 +6188,7 @@ describe('useQuery', () => { queryKey: key, queryFn, retry: false, - retryOnMount: false, + retryOnMount: () => false, })) createRenderEffect(() => { From 8081ff60f59e7e6c4f932edc933b3c9e4f5968ec Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:53:27 +0000 Subject: [PATCH 2/3] ci: apply automated fixes --- packages/query-core/src/query.ts | 3 ++- packages/query-core/src/queryObserver.ts | 6 ++++-- packages/query-core/src/types.ts | 7 +------ 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index b896290d90f..9d2731e690a 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -271,7 +271,8 @@ export class Query< isActive(): boolean { return this.observers.some( - (observer) => resolveQueryBoolean(observer.options.enabled, this) !== false, + (observer) => + resolveQueryBoolean(observer.options.enabled, this) !== false, ) } diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 77dae69c97a..954c969d548 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -752,8 +752,10 @@ function shouldLoadOnMount( return ( resolveQueryBoolean(options.enabled, query) !== false && query.state.data === undefined && - !(query.state.status === 'error' && - resolveQueryBoolean(options.retryOnMount, query) === false) + !( + query.state.status === 'error' && + resolveQueryBoolean(options.retryOnMount, query) === false + ) ) } diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index a3b41678a8a..7e7aab7df46 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -394,12 +394,7 @@ export interface QueryObserverOptions< * If set to a function, the function will be executed with the query to compute the value. * Defaults to `true`. */ - retryOnMount?: QueryBooleanOption< - TQueryFnData, - TError, - TQueryData, - TQueryKey - > + retryOnMount?: QueryBooleanOption /** * If set, the component will only re-render if any of the listed properties change. * When set to `['data', 'error']`, the component will only re-render when the `data` or `error` properties change. From 5ea44491a220357cde52ca97abe84b7615819f0f Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 18 Apr 2026 20:06:35 +0200 Subject: [PATCH 3/3] fix: vue --- packages/vue-query/src/queryOptions.ts | 9 +++++++-- packages/vue-query/src/useQuery.ts | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/vue-query/src/queryOptions.ts b/packages/vue-query/src/queryOptions.ts index 38132b85bab..46d4ff55f95 100644 --- a/packages/vue-query/src/queryOptions.ts +++ b/packages/vue-query/src/queryOptions.ts @@ -2,9 +2,9 @@ import type { DeepUnwrapRef, ShallowOption } from './types' import type { DataTag, DefaultError, - Enabled, InitialDataFunction, NonUndefinedGuard, + QueryBooleanOption, QueryKey, QueryObserverOptions, } from '@tanstack/query-core' @@ -23,7 +23,12 @@ export type QueryOptions< TQueryData, TQueryKey >]: Property extends 'enabled' - ? () => Enabled> + ? () => QueryBooleanOption< + TQueryFnData, + TError, + TQueryData, + DeepUnwrapRef + > : QueryObserverOptions< TQueryFnData, TError, diff --git a/packages/vue-query/src/useQuery.ts b/packages/vue-query/src/useQuery.ts index a64d5fc2d75..116e91baefe 100644 --- a/packages/vue-query/src/useQuery.ts +++ b/packages/vue-query/src/useQuery.ts @@ -3,9 +3,9 @@ import { useBaseQuery } from './useBaseQuery' import type { DefaultError, DefinedQueryObserverResult, - Enabled, InitialDataFunction, NonUndefinedGuard, + QueryBooleanOption, QueryKey, QueryObserverOptions, } from '@tanstack/query-core' @@ -36,7 +36,7 @@ export type UseQueryOptions< >]: Property extends 'enabled' ? | MaybeRefOrGetter - | (() => Enabled< + | (() => QueryBooleanOption< TQueryFnData, TError, TQueryData,