diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index a34c8630dc..30ac4e044a 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -2,8 +2,7 @@ import { ensureQueryFn, noop, replaceData, - resolveEnabled, - resolveStaleTime, + resolveOption, skipToken, timeUntilStale, } from './utils' @@ -16,7 +15,6 @@ import type { CancelOptions, DefaultError, FetchStatus, - InitialDataFunction, OmitKeyof, QueryFunctionContext, QueryKey, @@ -268,7 +266,7 @@ export class Query< isActive(): boolean { return this.observers.some( - (observer) => resolveEnabled(observer.options.enabled, this) !== false, + (observer) => resolveOption(observer.options.enabled, this) !== false, ) } @@ -287,7 +285,7 @@ export class Query< if (this.getObserversCount() > 0) { return this.observers.some( (observer) => - resolveStaleTime(observer.options.staleTime, this) === 'static', + resolveOption(observer.options.staleTime, this) === 'static', ) } @@ -390,7 +388,7 @@ export class Query< ): Promise { if ( this.state.fetchStatus !== 'idle' && - // If the promise in the retyer is already rejected, we have to definitely + // If the promise in the retryer is already rejected, we have to definitely // re-start the fetch; there is a chance that the query is still in a // pending state when that happens this.#retryer?.status() !== 'rejected' @@ -718,17 +716,12 @@ function getDefaultState< >( options: QueryOptions, ): QueryState { - const data = - typeof options.initialData === 'function' - ? (options.initialData as InitialDataFunction)() - : options.initialData + const data = resolveOption(options.initialData) const hasData = data !== undefined const initialDataUpdatedAt = hasData - ? typeof options.initialDataUpdatedAt === 'function' - ? (options.initialDataUpdatedAt as () => number | undefined)() - : options.initialDataUpdatedAt + ? resolveOption(options.initialDataUpdatedAt) : 0 return { diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 80cc36668a..ac41c9ceca 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -1,10 +1,9 @@ import { - functionalUpdate, hashKey, hashQueryKeyByOptions, noop, partialMatchKey, - resolveStaleTime, + resolveOption, skipToken, } from './utils' import { QueryCache } from './queryCache' @@ -155,7 +154,7 @@ export class QueryClient { if ( options.revalidateIfStale && - query.isStaleByTime(resolveStaleTime(defaultedOptions.staleTime, query)) + query.isStaleByTime(resolveOption(defaultedOptions.staleTime, query)) ) { void this.prefetchQuery(defaultedOptions) } @@ -197,7 +196,7 @@ export class QueryClient { defaultedOptions.queryHash, ) const prevData = query?.state.data - const data = functionalUpdate(updater, prevData) + const data = resolveOption(updater, prevData) if (data === undefined) { return undefined @@ -362,9 +361,7 @@ export class QueryClient { const query = this.#queryCache.build(this, defaultedOptions) - return query.isStaleByTime( - resolveStaleTime(defaultedOptions.staleTime, query), - ) + return query.isStaleByTime(resolveOption(defaultedOptions.staleTime, query)) ? query.fetch(defaultedOptions) : Promise.resolve(query.state.data as TData) } diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 1fe6aae676..ef683e5cee 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -8,8 +8,7 @@ import { isValidTimeout, noop, replaceData, - resolveEnabled, - resolveStaleTime, + resolveOption, shallowEqualObjects, timeUntilStale, } from './utils' @@ -21,7 +20,6 @@ import type { PendingThenable, Thenable } from './thenable' import type { DefaultError, DefaultedQueryObserverOptions, - PlaceholderDataFunction, QueryKey, QueryObserverBaseResult, QueryObserverOptions, @@ -154,7 +152,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 resolveOption(this.options.enabled, this.#currentQuery) !== 'boolean' ) { throw new Error( @@ -198,10 +196,10 @@ export class QueryObserver< if ( mounted && (this.#currentQuery !== prevQuery || - resolveEnabled(this.options.enabled, this.#currentQuery) !== - resolveEnabled(prevOptions.enabled, this.#currentQuery) || - resolveStaleTime(this.options.staleTime, this.#currentQuery) !== - resolveStaleTime(prevOptions.staleTime, this.#currentQuery)) + resolveOption(this.options.enabled, this.#currentQuery) !== + resolveOption(prevOptions.enabled, this.#currentQuery) || + resolveOption(this.options.staleTime, this.#currentQuery) !== + resolveOption(prevOptions.staleTime, this.#currentQuery)) ) { this.#updateStaleTimeout() } @@ -212,8 +210,8 @@ export class QueryObserver< if ( mounted && (this.#currentQuery !== prevQuery || - resolveEnabled(this.options.enabled, this.#currentQuery) !== - resolveEnabled(prevOptions.enabled, this.#currentQuery) || + resolveOption(this.options.enabled, this.#currentQuery) !== + resolveOption(prevOptions.enabled, this.#currentQuery) || nextRefetchInterval !== this.#currentRefetchInterval) ) { this.#updateRefetchInterval(nextRefetchInterval) @@ -352,10 +350,7 @@ export class QueryObserver< #updateStaleTimeout(): void { this.#clearStaleTimeout() - const staleTime = resolveStaleTime( - this.options.staleTime, - this.#currentQuery, - ) + const staleTime = resolveOption(this.options.staleTime, this.#currentQuery) if (isServer || this.#currentResult.isStale || !isValidTimeout(staleTime)) { return @@ -376,9 +371,7 @@ export class QueryObserver< #computeRefetchInterval() { return ( - (typeof this.options.refetchInterval === 'function' - ? this.options.refetchInterval(this.#currentQuery) - : this.options.refetchInterval) ?? false + resolveOption(this.options.refetchInterval, this.#currentQuery) ?? false ) } @@ -389,7 +382,7 @@ export class QueryObserver< if ( isServer || - resolveEnabled(this.options.enabled, this.#currentQuery) === false || + resolveOption(this.options.enabled, this.#currentQuery) === false || !isValidTimeout(this.#currentRefetchInterval) || this.#currentRefetchInterval === 0 ) { @@ -497,15 +490,11 @@ export class QueryObserver< skipSelect = true } else { // compute placeholderData - placeholderData = - typeof options.placeholderData === 'function' - ? ( - options.placeholderData as unknown as PlaceholderDataFunction - )( - this.#lastQueryWithDefinedData?.state.data, - this.#lastQueryWithDefinedData as any, - ) - : options.placeholderData + placeholderData = resolveOption( + options.placeholderData, + this.#lastQueryWithDefinedData?.state.data, + this.#lastQueryWithDefinedData as any, + ) } if (placeholderData !== undefined) { @@ -584,7 +573,7 @@ export class QueryObserver< isStale: isStale(query, options), refetch: this.refetch, promise: this.#currentThenable, - isEnabled: resolveEnabled(options.enabled, query) !== false, + isEnabled: resolveOption(options.enabled, query) !== false, } const nextResult = result as QueryObserverResult @@ -668,10 +657,7 @@ export class QueryObserver< } const { notifyOnChangeProps } = this.options - const notifyOnChangePropsValue = - typeof notifyOnChangeProps === 'function' - ? notifyOnChangeProps() - : notifyOnChangeProps + const notifyOnChangePropsValue = resolveOption(notifyOnChangeProps) if ( notifyOnChangePropsValue === 'all' || @@ -749,7 +735,7 @@ function shouldLoadOnMount( options: QueryObserverOptions, ): boolean { return ( - resolveEnabled(options.enabled, query) !== false && + resolveOption(options.enabled, query) !== false && query.state.data === undefined && !(query.state.status === 'error' && options.retryOnMount === false) ) @@ -774,10 +760,10 @@ function shouldFetchOn( (typeof options)['refetchOnReconnect'], ) { if ( - resolveEnabled(options.enabled, query) !== false && - resolveStaleTime(options.staleTime, query) !== 'static' + resolveOption(options.enabled, query) !== false && + resolveOption(options.staleTime, query) !== 'static' ) { - const value = typeof field === 'function' ? field(query) : field + const value = resolveOption(field, query) return value === 'always' || (value !== false && isStale(query, options)) } @@ -792,7 +778,7 @@ function shouldFetchOptionally( ): boolean { return ( (query !== prevQuery || - resolveEnabled(prevOptions.enabled, query) === false) && + resolveOption(prevOptions.enabled, query) === false) && (!options.suspense || query.state.status !== 'error') && isStale(query, options) ) @@ -803,8 +789,8 @@ function isStale( options: QueryObserverOptions, ): boolean { return ( - resolveEnabled(options.enabled, query) !== false && - query.isStaleByTime(resolveStaleTime(options.staleTime, query)) + resolveOption(options.enabled, query) !== false && + query.isStaleByTime(resolveOption(options.staleTime, query)) ) } diff --git a/packages/query-core/src/retryer.ts b/packages/query-core/src/retryer.ts index f4ada851c9..0eb4164a76 100644 --- a/packages/query-core/src/retryer.ts +++ b/packages/query-core/src/retryer.ts @@ -1,7 +1,7 @@ import { focusManager } from './focusManager' import { onlineManager } from './onlineManager' import { pendingThenable } from './thenable' -import { isServer, sleep } from './utils' +import { isServer, resolveOption, sleep } from './utils' import type { Thenable } from './thenable' import type { CancelOptions, DefaultError, NetworkMode } from './types' @@ -168,10 +168,7 @@ export function createRetryer( // Do we need to retry the request? const retry = config.retry ?? (isServer ? 0 : 3) const retryDelay = config.retryDelay ?? defaultRetryDelay - const delay = - typeof retryDelay === 'function' - ? retryDelay(failureCount, error) - : retryDelay + const delay = resolveOption(retryDelay, failureCount, error) const shouldRetry = retry === true || (typeof retry === 'number' && failureCount < retry) || diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 2c56280e03..9f52fbd2c4 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -166,7 +166,13 @@ export type QueryFunctionContext< export type InitialDataFunction = () => T | undefined -type NonFunctionGuard = T extends Function ? never : T +/** + * `NonFunctionGuard` ensures T is not a function type. + * + * If T is a function, it resolves to `never`, effectively removing T + * from unions and preventing ambiguity in value-or-function patterns. + */ +export type NonFunctionGuard = T extends Function ? never : T export type PlaceholderDataFunction< TQueryFnData = unknown, diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 2b46cc1c74..607b228cac 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -1,18 +1,16 @@ import { timeoutManager } from './timeoutManager' +import type { Mutation } from './mutation' +import type { FetchOptions, Query } from './query' import type { DefaultError, - Enabled, FetchStatus, MutationKey, MutationStatus, + NonFunctionGuard, QueryFunction, QueryKey, QueryOptions, - StaleTime, - StaleTimeFunction, } from './types' -import type { Mutation } from './mutation' -import type { FetchOptions, Query } from './query' // TYPES @@ -69,7 +67,9 @@ export interface MutationFilters< status?: MutationStatus } -export type Updater = TOutput | ((input: TInput) => TOutput) +export type Updater = + | NonFunctionGuard + | ((input: TInput) => TOutput) export type QueryTypeFilter = 'all' | 'active' | 'inactive' @@ -81,13 +81,79 @@ export function noop(): void export function noop(): undefined export function noop() {} -export function functionalUpdate( - updater: Updater, - input: TInput, -): TOutput { - return typeof updater === 'function' - ? (updater as (_: TInput) => TOutput)(input) - : updater +/** + * Resolves a value that can either be a direct value or a function that computes the value. + * + * This utility eliminates the need for repetitive `typeof value === 'function'` checks + * throughout the codebase and provides a clean way to handle the common pattern where + * options can be static values or dynamic functions. + * + * The NonFunctionGuard constraint eliminates ambiguity by ensuring T can never be a function + * type. This makes the value-or-function pattern type-safe and unambiguous. + * + * The function provides two overloads: one that includes `| undefined` for optional values + * (where the value might not be provided), and another without `| undefined` for required + * values. This allows proper type inference for both optional config parameters and + * required ones while maintaining type safety. + * + * @template T - The type of the resolved value (constrained to non-function types) + * @template TArgs - Array of argument types when resolving function variants + * @param value - Either a direct value of type T or a function that returns T + * @param args - Arguments to pass to the function if value is a function + * @returns The resolved value of type T + * + * @example + * ```ts + * // Zero-argument function resolution (like initialData) + * const initialData: string | (() => string) = 'hello' + * const resolved = resolveOption(initialData) // 'hello' + * + * const initialDataFn: string | (() => string) = () => 'world' + * const resolved2 = resolveOption(initialDataFn) // 'world' + * ``` + * + * @example + * ```ts + * // Function with arguments (like staleTime, retryDelay) + * const staleTime: number | ((query: Query) => number) = (query) => query.state.dataUpdatedAt + 5000 + * const resolved = resolveOption(staleTime, query) // number + * + * const retryDelay: number | ((failureCount: number, error: Error) => number) = 1000 + * const resolved2 = resolveOption(retryDelay, 3, new Error()) // 1000 + * ``` + * + * @example + * ```ts + * // Replaces verbose patterns like: + * // const delay = typeof retryDelay === 'function' + * // ? retryDelay(failureCount, error) + * // : retryDelay + * + * // With: + * const delay = resolveOption(retryDelay, failureCount, error) + * ``` + */ +export function resolveOption>( + valueOrFn: NonFunctionGuard | ((...args: TArgs) => T) | undefined, + ...args: TArgs +): T | undefined +// Overload for when value is guaranteed to be present +export function resolveOption>( + valueOrFn: NonFunctionGuard | ((...args: TArgs) => T), + ...args: TArgs +): T +// Implementation +export function resolveOption>( + valueOrFn: NonFunctionGuard | ((...args: TArgs) => T) | undefined, + ...args: TArgs +): T | undefined { + if (typeof valueOrFn === 'function') { + // Because of our NonFunctionGuard utility, TypeScript now correctly + // infers that if valueOrFn is a function, it must be the producer `(...args: TArgs) => T`. + return (valueOrFn as (...args: TArgs) => T)(...args) + } + // If it's not a function, it must be of type T or undefined. + return valueOrFn as T | undefined } export function isValidTimeout(value: unknown): value is number { @@ -98,32 +164,6 @@ export function timeUntilStale(updatedAt: number, staleTime?: number): number { return Math.max(updatedAt + (staleTime || 0) - Date.now(), 0) } -export function resolveStaleTime< - TQueryFnData = unknown, - TError = DefaultError, - TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, ->( - staleTime: - | undefined - | StaleTimeFunction, - query: Query, -): StaleTime | undefined { - return typeof staleTime === 'function' ? staleTime(query) : staleTime -} - -export function resolveEnabled< - TQueryFnData = unknown, - TError = DefaultError, - TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, ->( - enabled: undefined | Enabled, - query: Query, -): boolean | undefined { - return typeof enabled === 'function' ? enabled(query) : enabled -} - export function matchQuery( filters: QueryFilters, query: Query,