From 80f076731469ebfca0692ea6805ba92272ab1123 Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Tue, 3 Feb 2026 20:45:20 -0500 Subject: [PATCH 1/3] fix: address performance, testing, and documentation issues Performance: - Convert observers from Array to Set for O(1) lookups - Add countMatching() to queryCache to avoid creating intermediate arrays - Cache Object.keys result in shallowEqualObjects Testing: - Add tests for vue-query-devtools, svelte-query-devtools, react-query-next-experimental - Replace placeholder test in query-devtools with actual component tests - Update existing tests for Set-based observers API Documentation: - Add JSDoc comments to QueryClient, QueryObserver, and core types Architecture: - Split types.ts (1,391 lines) into focused modules (query, mutation, observer, common, client) - Maintain backwards compatibility via re-exports Other: - Improve input validation and error handling in persistence layer Co-Authored-By: Claude Opus 4.5 --- .../src/index.ts | 2 +- packages/query-core/src/query.ts | 75 +- packages/query-core/src/queryCache.ts | 14 + packages/query-core/src/queryClient.ts | 215 ++- packages/query-core/src/queryObserver.ts | 70 + packages/query-core/src/types.ts | 1406 +---------------- packages/query-core/src/types/client.ts | 24 + packages/query-core/src/types/common.ts | 98 ++ packages/query-core/src/types/index.ts | 116 ++ packages/query-core/src/types/mutation.ts | 320 ++++ packages/query-core/src/types/observer.ts | 654 ++++++++ packages/query-core/src/types/query.ts | 434 +++++ packages/query-core/src/utils.ts | 9 +- .../src/__tests__/devtools.test.tsx | 186 ++- .../src/__tests__/persist.test.ts | 6 +- .../src/createPersister.ts | 63 +- .../query-persist-client-core/src/persist.ts | 7 +- .../package.json | 2 + .../src/HydrationStreamProvider.tsx | 15 +- .../HydrationStreamProvider.test.tsx | 46 + .../vite.config.ts | 13 +- .../src/__tests__/useQuery.promise.test.tsx | 6 +- .../src/__tests__/useQuery.test.tsx | 8 +- .../src/__tests__/useSuspenseQuery.test.tsx | 6 +- packages/svelte-query-devtools/package.json | 2 + .../src/__tests__/devtools.test.ts | 40 + packages/svelte-query-devtools/vite.config.ts | 13 +- packages/vue-query-devtools/package.json | 2 + .../src/__tests__/devtools.test.ts | 40 + packages/vue-query-devtools/vite.config.ts | 13 +- 30 files changed, 2440 insertions(+), 1465 deletions(-) create mode 100644 packages/query-core/src/types/client.ts create mode 100644 packages/query-core/src/types/common.ts create mode 100644 packages/query-core/src/types/index.ts create mode 100644 packages/query-core/src/types/mutation.ts create mode 100644 packages/query-core/src/types/observer.ts create mode 100644 packages/query-core/src/types/query.ts create mode 100644 packages/react-query-next-experimental/src/__tests__/HydrationStreamProvider.test.tsx create mode 100644 packages/svelte-query-devtools/src/__tests__/devtools.test.ts create mode 100644 packages/vue-query-devtools/src/__tests__/devtools.test.ts diff --git a/packages/query-broadcast-client-experimental/src/index.ts b/packages/query-broadcast-client-experimental/src/index.ts index e102b3c0b0..d0bc74d552 100644 --- a/packages/query-broadcast-client-experimental/src/index.ts +++ b/packages/query-broadcast-client-experimental/src/index.ts @@ -45,7 +45,7 @@ export function broadcastQueryClient({ }) } - if (queryEvent.type === 'removed' && observers.length > 0) { + if (queryEvent.type === 'removed' && observers.size > 0) { channel.postMessage({ type: 'removed', queryHash, diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index b29b64c0e3..a1a8dfd4f5 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -172,7 +172,7 @@ export class Query< #cache: QueryCache #client: QueryClient #retryer?: Retryer - observers: Array> + observers: Set> #defaultOptions?: QueryOptions #abortSignalConsumed: boolean @@ -182,7 +182,7 @@ export class Query< this.#abortSignalConsumed = false this.#defaultOptions = config.defaultOptions this.setOptions(config.options) - this.observers = [] + this.observers = new Set() this.#client = config.client this.#cache = this.#client.getQueryCache() this.queryKey = config.queryKey @@ -219,7 +219,7 @@ export class Query< } protected optionalRemove() { - if (!this.observers.length && this.state.fetchStatus === 'idle') { + if (this.observers.size === 0 && this.state.fetchStatus === 'idle') { this.#cache.remove(this) } } @@ -266,9 +266,12 @@ export class Query< } isActive(): boolean { - return this.observers.some( - (observer) => resolveEnabled(observer.options.enabled, this) !== false, - ) + for (const observer of this.observers) { + if (resolveEnabled(observer.options.enabled, this) !== false) { + return true + } + } + return false } isDisabled(): boolean { @@ -283,23 +286,25 @@ export class Query< } isStatic(): boolean { - if (this.getObserversCount() > 0) { - return this.observers.some( - (observer) => - resolveStaleTime(observer.options.staleTime, this) === 'static', - ) + for (const observer of this.observers) { + if (resolveStaleTime(observer.options.staleTime, this) === 'static') { + return true + } } - return false } isStale(): boolean { // check observers first, their `isStale` has the source of truth // calculated with `isStaleByTime` and it takes `enabled` into account - if (this.getObserversCount() > 0) { - return this.observers.some( - (observer) => observer.getCurrentResult().isStale, - ) + for (const observer of this.observers) { + if (observer.getCurrentResult().isStale) { + return true + } + } + + if (this.observers.size > 0) { + return false } return this.state.data === undefined || this.state.isInvalidated @@ -323,26 +328,32 @@ export class Query< } onFocus(): void { - const observer = this.observers.find((x) => x.shouldFetchOnWindowFocus()) - - observer?.refetch({ cancelRefetch: false }) + for (const observer of this.observers) { + if (observer.shouldFetchOnWindowFocus()) { + observer.refetch({ cancelRefetch: false }) + break + } + } // Continue fetch if currently paused this.#retryer?.continue() } onOnline(): void { - const observer = this.observers.find((x) => x.shouldFetchOnReconnect()) - - observer?.refetch({ cancelRefetch: false }) + for (const observer of this.observers) { + if (observer.shouldFetchOnReconnect()) { + observer.refetch({ cancelRefetch: false }) + break + } + } // Continue fetch if currently paused this.#retryer?.continue() } addObserver(observer: QueryObserver): void { - if (!this.observers.includes(observer)) { - this.observers.push(observer) + if (!this.observers.has(observer)) { + this.observers.add(observer) // Stop the query from being garbage collected this.clearGcTimeout() @@ -352,10 +363,10 @@ export class Query< } removeObserver(observer: QueryObserver): void { - if (this.observers.includes(observer)) { - this.observers = this.observers.filter((x) => x !== observer) + if (this.observers.has(observer)) { + this.observers.delete(observer) - if (!this.observers.length) { + if (this.observers.size === 0) { // If the transport layer does not support cancellation // we'll let the query continue so the result can be cached if (this.#retryer) { @@ -374,7 +385,7 @@ export class Query< } getObserversCount(): number { - return this.observers.length + return this.observers.size } invalidate(): void { @@ -413,9 +424,11 @@ export class Query< // Use the options from the first observer with a query function if no function is found. // This can happen when the query is hydrated or created with setQueryData. if (!this.options.queryFn) { - const observer = this.observers.find((x) => x.options.queryFn) - if (observer) { - this.setOptions(observer.options) + for (const observer of this.observers) { + if (observer.options.queryFn) { + this.setOptions(observer.options) + break + } } } diff --git a/packages/query-core/src/queryCache.ts b/packages/query-core/src/queryCache.ts index dd7123eaac..f1b233aae0 100644 --- a/packages/query-core/src/queryCache.ts +++ b/packages/query-core/src/queryCache.ts @@ -197,6 +197,20 @@ export class QueryCache extends Subscribable { : queries } + countMatching(filters: QueryFilters = {}): number { + const hasFilters = Object.keys(filters).length > 0 + if (!hasFilters) { + return this.getAll().length + } + let count = 0 + for (const query of this.#queries.values()) { + if (matchQuery(filters, query)) { + count++ + } + } + return count + } + notify(event: QueryCacheNotifyEvent): void { notifyManager.batch(() => { this.listeners.forEach((listener) => { diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 80cc36668a..737923c03a 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -58,6 +58,20 @@ interface MutationDefaults { // CLASS +/** + * The QueryClient is the central manager for all queries and mutations. + * It holds the query and mutation caches, manages defaults, and provides + * methods to interact with cached data imperatively. + * + * @example + * ```ts + * const queryClient = new QueryClient({ + * defaultOptions: { + * queries: { staleTime: 5 * 60 * 1000 }, + * }, + * }) + * ``` + */ export class QueryClient { #queryCache: QueryCache #mutationCache: MutationCache @@ -68,6 +82,10 @@ export class QueryClient { #unsubscribeFocus?: () => void #unsubscribeOnline?: () => void + /** + * Creates a new QueryClient instance. + * @param config - Optional configuration including query/mutation caches and default options + */ constructor(config: QueryClientConfig = {}) { this.#queryCache = config.queryCache || new QueryCache() this.#mutationCache = config.mutationCache || new MutationCache() @@ -77,6 +95,10 @@ export class QueryClient { this.#mountCount = 0 } + /** + * Mounts the client, subscribing to focus and online events. + * Called automatically by framework adapters (e.g., QueryClientProvider). + */ mount(): void { this.#mountCount++ if (this.#mountCount !== 1) return @@ -95,6 +117,10 @@ export class QueryClient { }) } + /** + * Unmounts the client, unsubscribing from focus and online events. + * Called automatically by framework adapters when the provider unmounts. + */ unmount(): void { this.#mountCount-- if (this.#mountCount !== 0) return @@ -106,13 +132,25 @@ export class QueryClient { this.#unsubscribeOnline = undefined } + /** + * Returns the number of queries currently fetching. + * @param filters - Optional filters to narrow down which queries to count + * @returns The count of queries with fetchStatus 'fetching' + */ isFetching = QueryFilters>( filters?: TQueryFilters, ): number { - return this.#queryCache.findAll({ ...filters, fetchStatus: 'fetching' }) - .length + return this.#queryCache.countMatching({ + ...filters, + fetchStatus: 'fetching', + }) } + /** + * Returns the number of mutations currently executing. + * @param filters - Optional filters to narrow down which mutations to count + * @returns The count of mutations with status 'pending' + */ isMutating< TMutationFilters extends MutationFilters = MutationFilters, >(filters?: TMutationFilters): number { @@ -137,6 +175,12 @@ export class QueryClient { .data } + /** + * Returns cached data if available, otherwise fetches the query. + * Useful for preloading data or ensuring data exists before rendering. + * @param options - Query options including queryKey and queryFn + * @returns Promise resolving to the query data + */ ensureQueryData< TQueryFnData, TError = DefaultError, @@ -163,6 +207,11 @@ export class QueryClient { return Promise.resolve(cachedData) } + /** + * Returns an array of [queryKey, data] tuples for all queries matching the filters. + * @param filters - Filters to select which queries to return data for + * @returns Array of tuples containing queryKey and cached data + */ getQueriesData< TQueryFnData = unknown, TQueryFilters extends QueryFilters = QueryFilters, @@ -173,6 +222,14 @@ export class QueryClient { }) } + /** + * Imperatively updates cached data for a query. + * Useful for optimistic updates or syncing data from other sources. + * @param queryKey - The query key to update + * @param updater - New data or a function receiving old data and returning new data + * @param options - Optional settings like updatedAt timestamp + * @returns The updated data, or undefined if the updater returned undefined + */ setQueryData< TQueryFnData = unknown, TTaggedQueryKey extends QueryKey = QueryKey, @@ -208,6 +265,13 @@ export class QueryClient { .setData(data, { ...options, manual: true }) } + /** + * Updates cached data for multiple queries matching the filters. + * @param filters - Filters to select which queries to update + * @param updater - New data or a function receiving old data and returning new data + * @param options - Optional settings like updatedAt timestamp + * @returns Array of tuples containing queryKey and updated data + */ setQueriesData< TQueryFnData, TQueryFilters extends QueryFilters = QueryFilters, @@ -229,6 +293,11 @@ export class QueryClient { ) } + /** + * Returns the full state object for a query, including status, error, and metadata. + * @param queryKey - The query key to get state for + * @returns The query state or undefined if the query doesn't exist + */ getQueryState< TQueryFnData = unknown, TError = DefaultError, @@ -244,6 +313,10 @@ export class QueryClient { )?.state } + /** + * Removes queries from the cache. Observers will be notified and become inactive. + * @param filters - Optional filters to select which queries to remove + */ removeQueries( filters?: QueryFilters, ): void { @@ -255,6 +328,13 @@ export class QueryClient { }) } + /** + * Resets queries to their initial state and optionally refetches them. + * Unlike invalidateQueries, this clears the data to initialData (if provided). + * @param filters - Optional filters to select which queries to reset + * @param options - Optional refetch options + * @returns Promise that resolves when active queries finish refetching + */ resetQueries( filters?: QueryFilters, options?: ResetOptions, @@ -275,6 +355,12 @@ export class QueryClient { }) } + /** + * Cancels in-flight queries. Useful before performing optimistic updates. + * @param filters - Optional filters to select which queries to cancel + * @param cancelOptions - Options like whether to revert optimistic updates + * @returns Promise that resolves when queries have been cancelled + */ cancelQueries( filters?: QueryFilters, cancelOptions: CancelOptions = {}, @@ -290,6 +376,13 @@ export class QueryClient { return Promise.all(promises).then(noop).catch(noop) } + /** + * Marks queries as stale and optionally refetches them. + * This is the primary way to trigger refetches after mutations. + * @param filters - Optional filters to select which queries to invalidate + * @param options - Options like whether to throw on error + * @returns Promise that resolves when active queries finish refetching + */ invalidateQueries( filters?: InvalidateQueryFilters, options: InvalidateOptions = {}, @@ -312,6 +405,13 @@ export class QueryClient { }) } + /** + * Refetches queries matching the filters. + * Unlike invalidateQueries, this always triggers a fetch regardless of staleness. + * @param filters - Optional filters to select which queries to refetch + * @param options - Options like cancelRefetch and throwOnError + * @returns Promise that resolves when all matching queries finish refetching + */ refetchQueries( filters?: RefetchQueryFilters, options: RefetchOptions = {}, @@ -320,24 +420,33 @@ export class QueryClient { ...options, cancelRefetch: options.cancelRefetch ?? true, } - const promises = notifyManager.batch(() => - this.#queryCache - .findAll(filters) - .filter((query) => !query.isDisabled() && !query.isStatic()) - .map((query) => { - let promise = query.fetch(undefined, fetchOptions) - if (!fetchOptions.throwOnError) { - promise = promise.catch(noop) - } - return query.state.fetchStatus === 'paused' - ? Promise.resolve() - : promise - }), - ) + const promises = notifyManager.batch(() => { + const result: Array> = [] + for (const query of this.#queryCache.findAll(filters)) { + if (query.isDisabled() || query.isStatic()) { + continue + } + let promise = query.fetch(undefined, fetchOptions) + if (!fetchOptions.throwOnError) { + promise = promise.catch(noop) + } + result.push( + query.state.fetchStatus === 'paused' ? Promise.resolve() : promise, + ) + } + return result + }) return Promise.all(promises).then(noop) } + /** + * Fetches a query and returns the data. If the data is fresh, returns cached data. + * Use this for imperative data fetching outside of components. + * @param options - Query options including queryKey and queryFn + * @returns Promise resolving to the query data + * @throws Rejects if the query fails (no automatic retries by default) + */ fetchQuery< TQueryFnData, TError = DefaultError, @@ -369,6 +478,11 @@ export class QueryClient { : Promise.resolve(query.state.data as TData) } + /** + * Prefetches a query in the background for later use. + * Unlike fetchQuery, errors are silently caught and the promise always resolves. + * @param options - Query options including queryKey and queryFn + */ prefetchQuery< TQueryFnData = unknown, TError = DefaultError, @@ -380,6 +494,11 @@ export class QueryClient { return this.fetchQuery(options).then(noop).catch(noop) } + /** + * Fetches an infinite query and returns the paginated data. + * @param options - Infinite query options including queryKey, queryFn, and page params + * @returns Promise resolving to InfiniteData containing pages and pageParams + */ fetchInfiniteQuery< TQueryFnData, TError = DefaultError, @@ -404,6 +523,11 @@ export class QueryClient { return this.fetchQuery(options as any) } + /** + * Prefetches an infinite query in the background for later use. + * Unlike fetchInfiniteQuery, errors are silently caught. + * @param options - Infinite query options including queryKey, queryFn, and page params + */ prefetchInfiniteQuery< TQueryFnData, TError = DefaultError, @@ -422,6 +546,11 @@ export class QueryClient { return this.fetchInfiniteQuery(options).then(noop).catch(noop) } + /** + * Returns cached infinite query data if available, otherwise fetches it. + * @param options - Infinite query options including queryKey and queryFn + * @returns Promise resolving to InfiniteData containing pages and pageParams + */ ensureInfiniteQueryData< TQueryFnData, TError = DefaultError, @@ -447,6 +576,10 @@ export class QueryClient { return this.ensureQueryData(options as any) } + /** + * Resumes any mutations that were paused due to lack of network connectivity. + * @returns Promise that resolves when paused mutations have been resumed + */ resumePausedMutations(): Promise { if (onlineManager.isOnline()) { return this.#mutationCache.resumePausedMutations() @@ -454,22 +587,41 @@ export class QueryClient { return Promise.resolve() } + /** + * Returns the query cache instance. + */ getQueryCache(): QueryCache { return this.#queryCache } + /** + * Returns the mutation cache instance. + */ getMutationCache(): MutationCache { return this.#mutationCache } + /** + * Returns the default options for queries and mutations. + */ getDefaultOptions(): DefaultOptions { return this.#defaultOptions } + /** + * Sets the default options for queries and mutations. + * @param options - New default options to apply + */ setDefaultOptions(options: DefaultOptions): void { this.#defaultOptions = options } + /** + * Sets default options for queries matching a specific query key pattern. + * These defaults are merged with global defaults when queries are created. + * @param queryKey - The query key pattern to match + * @param options - Default options to apply for matching queries + */ setQueryDefaults< TQueryFnData = unknown, TError = DefaultError, @@ -490,6 +642,11 @@ export class QueryClient { }) } + /** + * Gets the default options for queries matching a specific query key. + * @param queryKey - The query key to get defaults for + * @returns Merged default options for matching queries + */ getQueryDefaults( queryKey: QueryKey, ): OmitKeyof, 'queryKey'> { @@ -508,6 +665,11 @@ export class QueryClient { return result } + /** + * Sets default options for mutations matching a specific mutation key pattern. + * @param mutationKey - The mutation key pattern to match + * @param options - Default options to apply for matching mutations + */ setMutationDefaults< TData = unknown, TError = DefaultError, @@ -526,6 +688,11 @@ export class QueryClient { }) } + /** + * Gets the default options for mutations matching a specific mutation key. + * @param mutationKey - The mutation key to get defaults for + * @returns Merged default options for matching mutations + */ getMutationDefaults( mutationKey: MutationKey, ): OmitKeyof, 'mutationKey'> { @@ -545,6 +712,12 @@ export class QueryClient { return result } + /** + * Merges provided options with global and query-specific defaults. + * This is used internally to compute final options for queries. + * @param options - Options to merge with defaults + * @returns Options with all defaults applied + */ defaultQueryOptions< TQueryFnData = unknown, TError = DefaultError, @@ -626,6 +799,12 @@ export class QueryClient { > } + /** + * Merges provided options with global and mutation-specific defaults. + * This is used internally to compute final options for mutations. + * @param options - Options to merge with defaults + * @returns Options with all defaults applied + */ defaultMutationOptions>( options?: T, ): T { @@ -641,6 +820,10 @@ export class QueryClient { } as T } + /** + * Clears all queries and mutations from the cache. + * Use with caution as this removes all cached data. + */ clear(): void { this.#queryCache.clear() this.#mutationCache.clear() diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 463407a073..9b3c632216 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -37,6 +37,26 @@ interface ObserverFetchOptions extends FetchOptions { throwOnError?: boolean } +/** + * QueryObserver subscribes to a query and provides reactive updates. + * It manages the subscription lifecycle, handles refetching strategies, + * and transforms query data through select functions. + * + * Framework adapters (like useQuery in React) use this internally. + * For direct usage, subscribe via the subscribe() method from Subscribable. + * + * @example + * ```ts + * const observer = new QueryObserver(queryClient, { + * queryKey: ['todos'], + * queryFn: fetchTodos, + * }) + * + * const unsubscribe = observer.subscribe((result) => { + * console.log(result.data) + * }) + * ``` + */ export class QueryObserver< TQueryFnData = unknown, TError = DefaultError, @@ -68,6 +88,11 @@ export class QueryObserver< #currentRefetchInterval?: number | false #trackedProps = new Set() + /** + * Creates a new QueryObserver instance. + * @param client - The QueryClient to use + * @param options - Observer options including queryKey and queryFn + */ constructor( client: QueryClient, public options: QueryObserverOptions< @@ -128,6 +153,10 @@ export class QueryObserver< ) } + /** + * Destroys the observer, removing it from the query and clearing all timers. + * Called automatically when the last listener unsubscribes. + */ destroy(): void { this.listeners = new Set() this.#clearStaleTimeout() @@ -135,6 +164,11 @@ export class QueryObserver< this.#currentQuery.removeObserver(this) } + /** + * Updates the observer options. This may trigger a refetch if the query key + * changes or other conditions are met. + * @param options - New observer options + */ setOptions( options: QueryObserverOptions< TQueryFnData, @@ -219,6 +253,12 @@ export class QueryObserver< } } + /** + * Returns a result optimistically based on the current options. + * Used by framework adapters to get immediate results during render. + * @param options - Defaulted observer options + * @returns The optimistic query result + */ getOptimisticResult( options: DefaultedQueryObserverOptions< TQueryFnData, @@ -256,10 +296,21 @@ export class QueryObserver< return result } + /** + * Returns the current cached result for this observer. + * @returns The current query result + */ getCurrentResult(): QueryObserverResult { return this.#currentResult } + /** + * Wraps the result in a proxy to track which properties are accessed. + * Used for optimizing re-renders by only notifying when tracked properties change. + * @param result - The result to track + * @param onPropTracked - Optional callback when a property is accessed + * @returns A proxied result that tracks property access + */ trackResult( result: QueryObserverResult, onPropTracked?: (key: keyof QueryObserverResult) => void, @@ -286,14 +337,27 @@ export class QueryObserver< }) } + /** + * Manually marks a property as tracked for change detection. + * @param key - The property key to track + */ trackProp(key: keyof QueryObserverResult) { this.#trackedProps.add(key) } + /** + * Returns the current Query instance being observed. + * @returns The underlying Query object + */ getCurrentQuery(): Query { return this.#currentQuery } + /** + * Manually triggers a refetch of the query. + * @param options - Optional refetch options + * @returns Promise resolving to the query result + */ refetch({ ...options }: RefetchOptions = {}): Promise< QueryObserverResult > { @@ -302,6 +366,12 @@ export class QueryObserver< }) } + /** + * Fetches the query with the given options and returns an optimistic result. + * Used for prefetching or eagerly loading queries. + * @param options - Observer options for the fetch + * @returns Promise resolving to the query result + */ fetchOptimistic( options: QueryObserverOptions< TQueryFnData, diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 4f3f4caed2..aae80f955c 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -1,1391 +1,19 @@ /* istanbul ignore file */ -import type { QueryClient } from './queryClient' -import type { DehydrateOptions, HydrateOptions } from './hydration' -import type { MutationState } from './mutation' -import type { FetchDirection, Query, QueryBehavior } from './query' -import type { RetryDelayValue, RetryValue } from './retryer' -import type { QueryFilters, QueryTypeFilter, SkipToken } from './utils' -import type { QueryCache } from './queryCache' -import type { MutationCache } from './mutationCache' - -export type NonUndefinedGuard = T extends undefined ? never : T - -export type DistributiveOmit< - TObject, - TKey extends keyof TObject, -> = TObject extends any ? Omit : never - -export type OmitKeyof< - TObject, - TKey extends TStrictly extends 'safely' - ? - | keyof TObject - | (string & Record) - | (number & Record) - | (symbol & Record) - : keyof TObject, - TStrictly extends 'strictly' | 'safely' = 'strictly', -> = Omit - -export type Override = { - [AKey in keyof TTargetA]: AKey extends keyof TTargetB - ? TTargetB[AKey] - : TTargetA[AKey] -} - -export type NoInfer = [T][T extends any ? 0 : never] - -export interface Register { - // defaultError: Error - // queryMeta: Record - // mutationMeta: Record - // queryKey: ReadonlyArray - // mutationKey: ReadonlyArray -} - -export type DefaultError = Register extends { - defaultError: infer TError -} - ? TError - : Error - -export type QueryKey = Register extends { - queryKey: infer TQueryKey -} - ? TQueryKey extends ReadonlyArray - ? TQueryKey - : TQueryKey extends Array - ? TQueryKey - : ReadonlyArray - : ReadonlyArray - -export const dataTagSymbol = Symbol('dataTagSymbol') -export type dataTagSymbol = typeof dataTagSymbol -export const dataTagErrorSymbol = Symbol('dataTagErrorSymbol') -export type dataTagErrorSymbol = typeof dataTagErrorSymbol -export const unsetMarker = Symbol('unsetMarker') -export type UnsetMarker = typeof unsetMarker -export type AnyDataTag = { - [dataTagSymbol]: any - [dataTagErrorSymbol]: any -} -export type DataTag< - TType, - TValue, - TError = UnsetMarker, -> = TType extends AnyDataTag - ? TType - : TType & { - [dataTagSymbol]: TValue - [dataTagErrorSymbol]: TError - } - -export type InferDataFromTag = - TTaggedQueryKey extends DataTag - ? TaggedValue - : TQueryFnData - -export type InferErrorFromTag = - TTaggedQueryKey extends DataTag - ? TaggedError extends UnsetMarker - ? TError - : TaggedError - : TError - -export type QueryFunction< - T = unknown, - TQueryKey extends QueryKey = QueryKey, - TPageParam = never, -> = (context: QueryFunctionContext) => T | Promise - -export type StaleTime = number | 'static' - -export type StaleTimeFunction< - TQueryFnData = unknown, - TError = DefaultError, - TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, -> = - | StaleTime - | ((query: Query) => StaleTime) - -export type Enabled< - TQueryFnData = unknown, - TError = DefaultError, - TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, -> = - | boolean - | ((query: Query) => boolean) - -export type QueryPersister< - T = unknown, - TQueryKey extends QueryKey = QueryKey, - TPageParam = never, -> = [TPageParam] extends [never] - ? ( - queryFn: QueryFunction, - context: QueryFunctionContext, - query: Query, - ) => T | Promise - : ( - queryFn: QueryFunction, - context: QueryFunctionContext, - query: Query, - ) => T | Promise - -export type QueryFunctionContext< - TQueryKey extends QueryKey = QueryKey, - TPageParam = never, -> = [TPageParam] extends [never] - ? { - client: QueryClient - queryKey: TQueryKey - signal: AbortSignal - meta: QueryMeta | undefined - pageParam?: unknown - /** - * @deprecated - * if you want access to the direction, you can add it to the pageParam - */ - direction?: unknown - } - : { - client: QueryClient - queryKey: TQueryKey - signal: AbortSignal - pageParam: TPageParam - /** - * @deprecated - * if you want access to the direction, you can add it to the pageParam - */ - direction: FetchDirection - meta: QueryMeta | undefined - } - -export type InitialDataFunction = () => T | undefined - -type NonFunctionGuard = T extends Function ? never : T - -export type PlaceholderDataFunction< - TQueryFnData = unknown, - TError = DefaultError, - TQueryData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, -> = ( - previousData: TQueryData | undefined, - previousQuery: Query | undefined, -) => TQueryData | undefined - -export type QueriesPlaceholderDataFunction = ( - previousData: undefined, - previousQuery: undefined, -) => TQueryData | undefined - -export type QueryKeyHashFunction = ( - queryKey: TQueryKey, -) => string - -export type GetPreviousPageParamFunction = ( - firstPage: TQueryFnData, - allPages: Array, - firstPageParam: TPageParam, - allPageParams: Array, -) => TPageParam | undefined | null - -export type GetNextPageParamFunction = ( - lastPage: TQueryFnData, - allPages: Array, - lastPageParam: TPageParam, - allPageParams: Array, -) => TPageParam | undefined | null - -export interface InfiniteData { - pages: Array - pageParams: Array -} - -export type QueryMeta = Register extends { - queryMeta: infer TQueryMeta -} - ? TQueryMeta extends Record - ? TQueryMeta - : Record - : Record - -export type NetworkMode = 'online' | 'always' | 'offlineFirst' - -export type NotifyOnChangeProps = - | Array - | 'all' - | undefined - | (() => Array | 'all' | undefined) - -export interface QueryOptions< - TQueryFnData = unknown, - TError = DefaultError, - TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, - TPageParam = never, -> { - /** - * If `false`, failed queries will not retry by default. - * If `true`, failed queries will retry infinitely., failureCount: num - * If set to an integer number, e.g. 3, failed queries will retry until the failed query count meets that number. - * If set to a function `(failureCount, error) => boolean` failed queries will retry until the function returns false. - */ - retry?: RetryValue - retryDelay?: RetryDelayValue - networkMode?: NetworkMode - /** - * The time in milliseconds that unused/inactive cache data remains in memory. - * When a query's cache becomes unused or inactive, that cache data will be garbage collected after this duration. - * When different garbage collection times are specified, the longest one will be used. - * Setting it to `Infinity` will disable garbage collection. - */ - gcTime?: number - queryFn?: QueryFunction | SkipToken - persister?: QueryPersister< - NoInfer, - NoInfer, - NoInfer - > - queryHash?: string - queryKey?: TQueryKey - queryKeyHashFn?: QueryKeyHashFunction - initialData?: TData | InitialDataFunction - initialDataUpdatedAt?: number | (() => number | undefined) - behavior?: QueryBehavior - /** - * Set this to `false` to disable structural sharing between query results. - * Set this to a function which accepts the old and new data and returns resolved data of the same type to implement custom structural sharing logic. - * Defaults to `true`. - */ - structuralSharing?: - | boolean - | ((oldData: unknown | undefined, newData: unknown) => unknown) - _defaulted?: boolean - /** - * Additional payload to be stored on each query. - * Use this property to pass information that can be used in other places. - */ - meta?: QueryMeta - /** - * Maximum number of pages to store in the data of an infinite query. - */ - maxPages?: number -} - -export interface InitialPageParam { - initialPageParam: TPageParam -} - -export interface InfiniteQueryPageParamsOptions< - TQueryFnData = unknown, - TPageParam = unknown, -> extends InitialPageParam { - /** - * This function can be set to automatically get the previous cursor for infinite queries. - * The result will also be used to determine the value of `hasPreviousPage`. - */ - getPreviousPageParam?: GetPreviousPageParamFunction - /** - * This function can be set to automatically get the next cursor for infinite queries. - * The result will also be used to determine the value of `hasNextPage`. - */ - getNextPageParam: GetNextPageParamFunction -} - -export type ThrowOnError< - TQueryFnData, - TError, - TQueryData, - TQueryKey extends QueryKey, -> = - | boolean - | (( - error: TError, - query: Query, - ) => boolean) - -export interface QueryObserverOptions< - TQueryFnData = unknown, - TError = DefaultError, - TData = TQueryFnData, - TQueryData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, - TPageParam = never, -> extends WithRequired< - QueryOptions, - 'queryKey' -> { - /** - * Set this to `false` or a function that returns `false` to disable automatic refetching when the query mounts or changes query keys. - * To refetch the query, use the `refetch` method returned from the `useQuery` instance. - * Accepts a boolean or function that returns a boolean. - * Defaults to `true`. - */ - enabled?: Enabled - /** - * The time in milliseconds after data is considered stale. - * If set to `Infinity`, the data will never be considered stale. - * If set to a function, the function will be executed with the query to compute a `staleTime`. - * Defaults to `0`. - */ - staleTime?: StaleTimeFunction - /** - * If set to a number, the query will continuously refetch at this frequency in milliseconds. - * If set to a function, the function will be executed with the latest data and query to compute a frequency - * Defaults to `false`. - */ - refetchInterval?: - | number - | false - | (( - query: Query, - ) => number | false | undefined) - /** - * If set to `true`, the query will continue to refetch while their tab/window is in the background. - * Defaults to `false`. - */ - refetchIntervalInBackground?: boolean - /** - * If set to `true`, the query will refetch on window focus if the data is stale. - * If set to `false`, the query will not refetch on window focus. - * If set to `'always'`, the query will always refetch on window focus. - * If set to a function, the function will be executed with the latest data and query to compute the value. - * Defaults to `true`. - */ - refetchOnWindowFocus?: - | boolean - | 'always' - | (( - query: Query, - ) => boolean | 'always') - /** - * If set to `true`, the query will refetch on reconnect if the data is stale. - * If set to `false`, the query will not refetch on reconnect. - * If set to `'always'`, the query will always refetch on reconnect. - * If set to a function, the function will be executed with the latest data and query to compute the value. - * Defaults to the value of `networkOnline` (`true`) - */ - refetchOnReconnect?: - | boolean - | 'always' - | (( - query: Query, - ) => boolean | 'always') - /** - * If set to `true`, the query will refetch on mount if the data is stale. - * If set to `false`, will disable additional instances of a query to trigger background refetch. - * If set to `'always'`, the query will always refetch on mount. - * If set to a function, the function will be executed with the latest data and query to compute the value - * Defaults to `true`. - */ - refetchOnMount?: - | boolean - | 'always' - | (( - query: Query, - ) => boolean | 'always') - /** - * If set to `false`, the query will not be retried on mount if it contains an error. - * Defaults to `true`. - */ - retryOnMount?: boolean - /** - * 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. - * When set to `'all'`, the component will re-render whenever a query is updated. - * When set to a function, the function will be executed to compute the list of properties. - * By default, access to properties will be tracked, and the component will only re-render when one of the tracked properties change. - */ - notifyOnChangeProps?: NotifyOnChangeProps - /** - * Whether errors should be thrown instead of setting the `error` property. - * If set to `true` or `suspense` is `true`, all errors will be thrown to the error boundary. - * If set to `false` and `suspense` is `false`, errors are returned as state. - * If set to a function, it will be passed the error and the query, and it should return a boolean indicating whether to show the error in an error boundary (`true`) or return the error as state (`false`). - * Defaults to `false`. - */ - throwOnError?: ThrowOnError - /** - * This option can be used to transform or select a part of the data returned by the query function. - */ - select?: (data: TQueryData) => TData - /** - * If set to `true`, the query will suspend when `status === 'pending'` - * and throw errors when `status === 'error'`. - * Defaults to `false`. - */ - suspense?: boolean - /** - * If set, this value will be used as the placeholder data for this particular query observer while the query is still in the `loading` data and no initialData has been provided. - */ - placeholderData?: - | NonFunctionGuard - | PlaceholderDataFunction< - NonFunctionGuard, - TError, - NonFunctionGuard, - TQueryKey - > - - _optimisticResults?: 'optimistic' | 'isRestoring' - - /** - * Enable prefetching during rendering - */ - experimental_prefetchInRender?: boolean -} - -export type WithRequired = TTarget & { - [_ in TKey]: {} -} - -export type DefaultedQueryObserverOptions< - TQueryFnData = unknown, - TError = DefaultError, - TData = TQueryFnData, - TQueryData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, -> = WithRequired< - QueryObserverOptions, - 'throwOnError' | 'refetchOnReconnect' | 'queryHash' -> - -export interface InfiniteQueryObserverOptions< - TQueryFnData = unknown, - TError = DefaultError, - TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, - TPageParam = unknown, -> - extends - QueryObserverOptions< - TQueryFnData, - TError, - TData, - InfiniteData, - TQueryKey, - TPageParam - >, - InfiniteQueryPageParamsOptions {} - -export type DefaultedInfiniteQueryObserverOptions< - TQueryFnData = unknown, - TError = DefaultError, - TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, - TPageParam = unknown, -> = WithRequired< - InfiniteQueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryKey, - TPageParam - >, - 'throwOnError' | 'refetchOnReconnect' | 'queryHash' -> - -export interface FetchQueryOptions< - TQueryFnData = unknown, - TError = DefaultError, - TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, - TPageParam = never, -> extends WithRequired< - QueryOptions, - 'queryKey' -> { - initialPageParam?: never - /** - * The time in milliseconds after data is considered stale. - * If the data is fresh it will be returned from the cache. - */ - staleTime?: StaleTimeFunction -} - -export interface EnsureQueryDataOptions< - TQueryFnData = unknown, - TError = DefaultError, - TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, - TPageParam = never, -> extends FetchQueryOptions< - TQueryFnData, - TError, - TData, - TQueryKey, - TPageParam -> { - revalidateIfStale?: boolean -} - -export type EnsureInfiniteQueryDataOptions< - TQueryFnData = unknown, - TError = DefaultError, - TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, - TPageParam = unknown, -> = FetchInfiniteQueryOptions< - TQueryFnData, - TError, - TData, - TQueryKey, - TPageParam -> & { - revalidateIfStale?: boolean -} - -type FetchInfiniteQueryPages = - | { pages?: never } - | { - pages: number - getNextPageParam: GetNextPageParamFunction - } - -export type FetchInfiniteQueryOptions< - TQueryFnData = unknown, - TError = DefaultError, - TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, - TPageParam = unknown, -> = Omit< - FetchQueryOptions< - TQueryFnData, - TError, - InfiniteData, - TQueryKey, - TPageParam - >, - 'initialPageParam' -> & - InitialPageParam & - FetchInfiniteQueryPages - -export interface ResultOptions { - throwOnError?: boolean -} - -export interface RefetchOptions extends ResultOptions { - /** - * If set to `true`, a currently running request will be cancelled before a new request is made - * - * If set to `false`, no refetch will be made if there is already a request running. - * - * Defaults to `true`. - */ - cancelRefetch?: boolean -} - -export interface InvalidateQueryFilters< - TQueryKey extends QueryKey = QueryKey, -> extends QueryFilters { - refetchType?: QueryTypeFilter | 'none' -} - -export interface RefetchQueryFilters< - TQueryKey extends QueryKey = QueryKey, -> extends QueryFilters {} - -export interface InvalidateOptions extends RefetchOptions {} -export interface ResetOptions extends RefetchOptions {} - -export interface FetchNextPageOptions extends ResultOptions { - /** - * If set to `true`, calling `fetchNextPage` repeatedly will invoke `queryFn` every time, - * whether the previous invocation has resolved or not. Also, the result from previous invocations will be ignored. - * - * If set to `false`, calling `fetchNextPage` repeatedly won't have any effect until the first invocation has resolved. - * - * Defaults to `true`. - */ - cancelRefetch?: boolean -} - -export interface FetchPreviousPageOptions extends ResultOptions { - /** - * If set to `true`, calling `fetchPreviousPage` repeatedly will invoke `queryFn` every time, - * whether the previous invocation has resolved or not. Also, the result from previous invocations will be ignored. - * - * If set to `false`, calling `fetchPreviousPage` repeatedly won't have any effect until the first invocation has resolved. - * - * Defaults to `true`. - */ - cancelRefetch?: boolean -} - -export type QueryStatus = 'pending' | 'error' | 'success' -export type FetchStatus = 'fetching' | 'paused' | 'idle' - -export interface QueryObserverBaseResult< - TData = unknown, - TError = DefaultError, -> { - /** - * The last successfully resolved data for the query. - */ - data: TData | undefined - /** - * The timestamp for when the query most recently returned the `status` as `"success"`. - */ - dataUpdatedAt: number - /** - * The error object for the query, if an error was thrown. - * - Defaults to `null`. - */ - error: TError | null - /** - * The timestamp for when the query most recently returned the `status` as `"error"`. - */ - errorUpdatedAt: number - /** - * The failure count for the query. - * - Incremented every time the query fails. - * - Reset to `0` when the query succeeds. - */ - failureCount: number - /** - * The failure reason for the query retry. - * - Reset to `null` when the query succeeds. - */ - failureReason: TError | null - /** - * The sum of all errors. - */ - errorUpdateCount: number - /** - * A derived boolean from the `status` variable, provided for convenience. - * - `true` if the query attempt resulted in an error. - */ - isError: boolean - /** - * Will be `true` if the query has been fetched. - */ - isFetched: boolean - /** - * Will be `true` if the query has been fetched after the component mounted. - * - This property can be used to not show any previously cached data. - */ - isFetchedAfterMount: boolean - /** - * A derived boolean from the `fetchStatus` variable, provided for convenience. - * - `true` whenever the `queryFn` is executing, which includes initial `pending` as well as background refetch. - */ - isFetching: boolean - /** - * Is `true` whenever the first fetch for a query is in-flight. - * - Is the same as `isFetching && isPending`. - */ - isLoading: boolean - /** - * Will be `pending` if there's no cached data and no query attempt was finished yet. - */ - isPending: boolean - /** - * Will be `true` if the query failed while fetching for the first time. - */ - isLoadingError: boolean - /** - * @deprecated `isInitialLoading` is being deprecated in favor of `isLoading` - * and will be removed in the next major version. - */ - isInitialLoading: boolean - /** - * A derived boolean from the `fetchStatus` variable, provided for convenience. - * - The query wanted to fetch, but has been `paused`. - */ - isPaused: boolean - /** - * Will be `true` if the data shown is the placeholder data. - */ - isPlaceholderData: boolean - /** - * Will be `true` if the query failed while refetching. - */ - isRefetchError: boolean - /** - * Is `true` whenever a background refetch is in-flight, which _does not_ include initial `pending`. - * - Is the same as `isFetching && !isPending`. - */ - isRefetching: boolean - /** - * Will be `true` if the data in the cache is invalidated or if the data is older than the given `staleTime`. - */ - isStale: boolean - /** - * A derived boolean from the `status` variable, provided for convenience. - * - `true` if the query has received a response with no errors and is ready to display its data. - */ - isSuccess: boolean - /** - * `true` if this observer is enabled, `false` otherwise. - */ - isEnabled: boolean - /** - * A function to manually refetch the query. - */ - refetch: ( - options?: RefetchOptions, - ) => Promise> - /** - * The status of the query. - * - Will be: - * - `pending` if there's no cached data and no query attempt was finished yet. - * - `error` if the query attempt resulted in an error. - * - `success` if the query has received a response with no errors and is ready to display its data. - */ - status: QueryStatus - /** - * The fetch status of the query. - * - `fetching`: Is `true` whenever the queryFn is executing, which includes initial `pending` as well as background refetch. - * - `paused`: The query wanted to fetch, but has been `paused`. - * - `idle`: The query is not fetching. - * - See [Network Mode](https://tanstack.com/query/latest/docs/framework/react/guides/network-mode) for more information. - */ - fetchStatus: FetchStatus - /** - * A stable promise that will be resolved with the data of the query. - * Requires the `experimental_prefetchInRender` feature flag to be enabled. - * @example - * - * ### Enabling the feature flag - * ```ts - * const client = new QueryClient({ - * defaultOptions: { - * queries: { - * experimental_prefetchInRender: true, - * }, - * }, - * }) - * ``` - * - * ### Usage - * ```tsx - * import { useQuery } from '@tanstack/react-query' - * import React from 'react' - * import { fetchTodos, type Todo } from './api' - * - * function TodoList({ query }: { query: UseQueryResult }) { - * const data = React.use(query.promise) - * - * return ( - *
    - * {data.map(todo => ( - *
  • {todo.title}
  • - * ))} - *
- * ) - * } - * - * export function App() { - * const query = useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) - * - * return ( - * <> - *

Todos

- * Loading...}> - * - * - * - * ) - * } - * ``` - */ - promise: Promise -} - -export interface QueryObserverPendingResult< - TData = unknown, - TError = DefaultError, -> extends QueryObserverBaseResult { - data: undefined - error: null - isError: false - isPending: true - isLoadingError: false - isRefetchError: false - isSuccess: false - isPlaceholderData: false - status: 'pending' -} - -export interface QueryObserverLoadingResult< - TData = unknown, - TError = DefaultError, -> extends QueryObserverBaseResult { - data: undefined - error: null - isError: false - isPending: true - isLoading: true - isLoadingError: false - isRefetchError: false - isSuccess: false - isPlaceholderData: false - status: 'pending' -} - -export interface QueryObserverLoadingErrorResult< - TData = unknown, - TError = DefaultError, -> extends QueryObserverBaseResult { - data: undefined - error: TError - isError: true - isPending: false - isLoading: false - isLoadingError: true - isRefetchError: false - isSuccess: false - isPlaceholderData: false - status: 'error' -} - -export interface QueryObserverRefetchErrorResult< - TData = unknown, - TError = DefaultError, -> extends QueryObserverBaseResult { - data: TData - error: TError - isError: true - isPending: false - isLoading: false - isLoadingError: false - isRefetchError: true - isSuccess: false - isPlaceholderData: false - status: 'error' -} - -export interface QueryObserverSuccessResult< - TData = unknown, - TError = DefaultError, -> extends QueryObserverBaseResult { - data: TData - error: null - isError: false - isPending: false - isLoading: false - isLoadingError: false - isRefetchError: false - isSuccess: true - isPlaceholderData: false - status: 'success' -} - -export interface QueryObserverPlaceholderResult< - TData = unknown, - TError = DefaultError, -> extends QueryObserverBaseResult { - data: TData - isError: false - error: null - isPending: false - isLoading: false - isLoadingError: false - isRefetchError: false - isSuccess: true - isPlaceholderData: true - status: 'success' -} - -export type DefinedQueryObserverResult< - TData = unknown, - TError = DefaultError, -> = - | QueryObserverRefetchErrorResult - | QueryObserverSuccessResult - -export type QueryObserverResult = - | DefinedQueryObserverResult - | QueryObserverLoadingErrorResult - | QueryObserverLoadingResult - | QueryObserverPendingResult - | QueryObserverPlaceholderResult - -export interface InfiniteQueryObserverBaseResult< - TData = unknown, - TError = DefaultError, -> extends QueryObserverBaseResult { - /** - * This function allows you to fetch the next "page" of results. - */ - fetchNextPage: ( - options?: FetchNextPageOptions, - ) => Promise> - /** - * This function allows you to fetch the previous "page" of results. - */ - fetchPreviousPage: ( - options?: FetchPreviousPageOptions, - ) => Promise> - /** - * Will be `true` if there is a next page to be fetched (known via the `getNextPageParam` option). - */ - hasNextPage: boolean - /** - * Will be `true` if there is a previous page to be fetched (known via the `getPreviousPageParam` option). - */ - hasPreviousPage: boolean - /** - * Will be `true` if the query failed while fetching the next page. - */ - isFetchNextPageError: boolean - /** - * Will be `true` while fetching the next page with `fetchNextPage`. - */ - isFetchingNextPage: boolean - /** - * Will be `true` if the query failed while fetching the previous page. - */ - isFetchPreviousPageError: boolean - /** - * Will be `true` while fetching the previous page with `fetchPreviousPage`. - */ - isFetchingPreviousPage: boolean -} - -export interface InfiniteQueryObserverPendingResult< - TData = unknown, - TError = DefaultError, -> extends InfiniteQueryObserverBaseResult { - data: undefined - error: null - isError: false - isPending: true - isLoadingError: false - isRefetchError: false - isFetchNextPageError: false - isFetchPreviousPageError: false - isSuccess: false - isPlaceholderData: false - status: 'pending' -} - -export interface InfiniteQueryObserverLoadingResult< - TData = unknown, - TError = DefaultError, -> extends InfiniteQueryObserverBaseResult { - data: undefined - error: null - isError: false - isPending: true - isLoading: true - isLoadingError: false - isRefetchError: false - isFetchNextPageError: false - isFetchPreviousPageError: false - isSuccess: false - isPlaceholderData: false - status: 'pending' -} - -export interface InfiniteQueryObserverLoadingErrorResult< - TData = unknown, - TError = DefaultError, -> extends InfiniteQueryObserverBaseResult { - data: undefined - error: TError - isError: true - isPending: false - isLoading: false - isLoadingError: true - isRefetchError: false - isFetchNextPageError: false - isFetchPreviousPageError: false - isSuccess: false - isPlaceholderData: false - status: 'error' -} - -export interface InfiniteQueryObserverRefetchErrorResult< - TData = unknown, - TError = DefaultError, -> extends InfiniteQueryObserverBaseResult { - data: TData - error: TError - isError: true - isPending: false - isLoading: false - isLoadingError: false - isRefetchError: true - isSuccess: false - isPlaceholderData: false - status: 'error' -} - -export interface InfiniteQueryObserverSuccessResult< - TData = unknown, - TError = DefaultError, -> extends InfiniteQueryObserverBaseResult { - data: TData - error: null - isError: false - isPending: false - isLoading: false - isLoadingError: false - isRefetchError: false - isFetchNextPageError: false - isFetchPreviousPageError: false - isSuccess: true - isPlaceholderData: false - status: 'success' -} - -export interface InfiniteQueryObserverPlaceholderResult< - TData = unknown, - TError = DefaultError, -> extends InfiniteQueryObserverBaseResult { - data: TData - isError: false - error: null - isPending: false - isLoading: false - isLoadingError: false - isRefetchError: false - isSuccess: true - isPlaceholderData: true - isFetchNextPageError: false - isFetchPreviousPageError: false - status: 'success' -} - -export type DefinedInfiniteQueryObserverResult< - TData = unknown, - TError = DefaultError, -> = - | InfiniteQueryObserverRefetchErrorResult - | InfiniteQueryObserverSuccessResult - -export type InfiniteQueryObserverResult< - TData = unknown, - TError = DefaultError, -> = - | DefinedInfiniteQueryObserverResult - | InfiniteQueryObserverLoadingErrorResult - | InfiniteQueryObserverLoadingResult - | InfiniteQueryObserverPendingResult - | InfiniteQueryObserverPlaceholderResult - -export type MutationKey = Register extends { - mutationKey: infer TMutationKey -} - ? TMutationKey extends ReadonlyArray - ? TMutationKey - : TMutationKey extends Array - ? TMutationKey - : ReadonlyArray - : ReadonlyArray - -export type MutationStatus = 'idle' | 'pending' | 'success' | 'error' - -export type MutationScope = { - id: string -} - -export type MutationMeta = Register extends { - mutationMeta: infer TMutationMeta -} - ? TMutationMeta extends Record - ? TMutationMeta - : Record - : Record - -export type MutationFunctionContext = { - client: QueryClient - meta: MutationMeta | undefined - mutationKey?: MutationKey -} - -export type MutationFunction = ( - variables: TVariables, - context: MutationFunctionContext, -) => Promise - -export interface MutationOptions< - TData = unknown, - TError = DefaultError, - TVariables = void, - TOnMutateResult = unknown, -> { - mutationFn?: MutationFunction - mutationKey?: MutationKey - onMutate?: ( - variables: TVariables, - context: MutationFunctionContext, - ) => Promise | TOnMutateResult - onSuccess?: ( - data: TData, - variables: TVariables, - onMutateResult: TOnMutateResult, - context: MutationFunctionContext, - ) => Promise | unknown - onError?: ( - error: TError, - variables: TVariables, - onMutateResult: TOnMutateResult | undefined, - context: MutationFunctionContext, - ) => Promise | unknown - onSettled?: ( - data: TData | undefined, - error: TError | null, - variables: TVariables, - onMutateResult: TOnMutateResult | undefined, - context: MutationFunctionContext, - ) => Promise | unknown - retry?: RetryValue - retryDelay?: RetryDelayValue - networkMode?: NetworkMode - gcTime?: number - _defaulted?: boolean - meta?: MutationMeta - scope?: MutationScope -} - -export interface MutationObserverOptions< - TData = unknown, - TError = DefaultError, - TVariables = void, - TOnMutateResult = unknown, -> extends MutationOptions { - throwOnError?: boolean | ((error: TError) => boolean) -} - -export interface MutateOptions< - TData = unknown, - TError = DefaultError, - TVariables = void, - TOnMutateResult = unknown, -> { - onSuccess?: ( - data: TData, - variables: TVariables, - onMutateResult: TOnMutateResult | undefined, - context: MutationFunctionContext, - ) => void - onError?: ( - error: TError, - variables: TVariables, - onMutateResult: TOnMutateResult | undefined, - context: MutationFunctionContext, - ) => void - onSettled?: ( - data: TData | undefined, - error: TError | null, - variables: TVariables, - onMutateResult: TOnMutateResult | undefined, - context: MutationFunctionContext, - ) => void -} - -export type MutateFunction< - TData = unknown, - TError = DefaultError, - TVariables = void, - TOnMutateResult = unknown, -> = ( - variables: TVariables, - options?: MutateOptions, -) => Promise - -export interface MutationObserverBaseResult< - TData = unknown, - TError = DefaultError, - TVariables = void, - TOnMutateResult = unknown, -> extends MutationState { - /** - * The last successfully resolved data for the mutation. - */ - data: TData | undefined - /** - * The variables object passed to the `mutationFn`. - */ - variables: TVariables | undefined - /** - * The error object for the mutation, if an error was encountered. - * - Defaults to `null`. - */ - error: TError | null - /** - * A boolean variable derived from `status`. - * - `true` if the last mutation attempt resulted in an error. - */ - isError: boolean - /** - * A boolean variable derived from `status`. - * - `true` if the mutation is in its initial state prior to executing. - */ - isIdle: boolean - /** - * A boolean variable derived from `status`. - * - `true` if the mutation is currently executing. - */ - isPending: boolean - /** - * A boolean variable derived from `status`. - * - `true` if the last mutation attempt was successful. - */ - isSuccess: boolean - /** - * The status of the mutation. - * - Will be: - * - `idle` initial status prior to the mutation function executing. - * - `pending` if the mutation is currently executing. - * - `error` if the last mutation attempt resulted in an error. - * - `success` if the last mutation attempt was successful. - */ - status: MutationStatus - /** - * The mutation function you can call with variables to trigger the mutation and optionally hooks on additional callback options. - * @param variables - The variables object to pass to the `mutationFn`. - * @param options.onSuccess - This function will fire when the mutation is successful and will be passed the mutation's result. - * @param options.onError - This function will fire if the mutation encounters an error and will be passed the error. - * @param options.onSettled - This function will fire when the mutation is either successfully fetched or encounters an error and be passed either the data or error. - * @remarks - * - If you make multiple requests, `onSuccess` will fire only after the latest call you've made. - * - All the callback functions (`onSuccess`, `onError`, `onSettled`) are void functions, and the returned value will be ignored. - */ - mutate: MutateFunction - /** - * A function to clean the mutation internal state (i.e., it resets the mutation to its initial state). - */ - reset: () => void -} - -export interface MutationObserverIdleResult< - TData = unknown, - TError = DefaultError, - TVariables = void, - TOnMutateResult = unknown, -> extends MutationObserverBaseResult< - TData, - TError, - TVariables, - TOnMutateResult -> { - data: undefined - variables: undefined - error: null - isError: false - isIdle: true - isPending: false - isSuccess: false - status: 'idle' -} - -export interface MutationObserverLoadingResult< - TData = unknown, - TError = DefaultError, - TVariables = void, - TOnMutateResult = unknown, -> extends MutationObserverBaseResult< - TData, - TError, - TVariables, - TOnMutateResult -> { - data: undefined - variables: TVariables - error: null - isError: false - isIdle: false - isPending: true - isSuccess: false - status: 'pending' -} - -export interface MutationObserverErrorResult< - TData = unknown, - TError = DefaultError, - TVariables = void, - TOnMutateResult = unknown, -> extends MutationObserverBaseResult< - TData, - TError, - TVariables, - TOnMutateResult -> { - data: undefined - error: TError - variables: TVariables - isError: true - isIdle: false - isPending: false - isSuccess: false - status: 'error' -} - -export interface MutationObserverSuccessResult< - TData = unknown, - TError = DefaultError, - TVariables = void, - TOnMutateResult = unknown, -> extends MutationObserverBaseResult< - TData, - TError, - TVariables, - TOnMutateResult -> { - data: TData - error: null - variables: TVariables - isError: false - isIdle: false - isPending: false - isSuccess: true - status: 'success' -} - -export type MutationObserverResult< - TData = unknown, - TError = DefaultError, - TVariables = void, - TOnMutateResult = unknown, -> = - | MutationObserverIdleResult - | MutationObserverLoadingResult - | MutationObserverErrorResult - | MutationObserverSuccessResult - -export interface QueryClientConfig { - queryCache?: QueryCache - mutationCache?: MutationCache - defaultOptions?: DefaultOptions -} - -export interface DefaultOptions { - queries?: OmitKeyof< - QueryObserverOptions, - 'suspense' | 'queryKey' - > - mutations?: MutationObserverOptions - hydrate?: HydrateOptions['defaultOptions'] - dehydrate?: DehydrateOptions -} - -export interface CancelOptions { - revert?: boolean - silent?: boolean -} - -export interface SetDataOptions { - updatedAt?: number -} - -export type NotifyEventType = - | 'added' - | 'removed' - | 'updated' - | 'observerAdded' - | 'observerRemoved' - | 'observerResultsUpdated' - | 'observerOptionsUpdated' - -export interface NotifyEvent { - type: NotifyEventType -} +/** + * This file re-exports all types from the types/ directory for backwards compatibility. + * The types have been organized into separate files by concern: + * + * - types/common.ts - Shared utility types (Register, DefaultError, NetworkMode, etc.) + * - types/query.ts - Query-related types (QueryKey, QueryOptions, QueryFunction, etc.) + * - types/observer.ts - Observer-related types (QueryObserverOptions, QueryObserverResult, etc.) + * - types/mutation.ts - Mutation-related types (MutationKey, MutationOptions, etc.) + * - types/client.ts - Client configuration types (QueryClientConfig, DefaultOptions) + * - types/index.ts - Re-exports everything + */ + +// Re-export everything from the types directory +export * from './types/index' + +// Re-export the runtime values (symbols) directly +export { dataTagSymbol, dataTagErrorSymbol, unsetMarker } from './types/query' diff --git a/packages/query-core/src/types/client.ts b/packages/query-core/src/types/client.ts new file mode 100644 index 0000000000..538f136a50 --- /dev/null +++ b/packages/query-core/src/types/client.ts @@ -0,0 +1,24 @@ +/* istanbul ignore file */ + +import type { DehydrateOptions, HydrateOptions } from '../hydration' +import type { QueryCache } from '../queryCache' +import type { MutationCache } from '../mutationCache' +import type { DefaultError, OmitKeyof } from './common' +import type { QueryObserverOptions } from './observer' +import type { MutationObserverOptions } from './mutation' + +export interface QueryClientConfig { + queryCache?: QueryCache + mutationCache?: MutationCache + defaultOptions?: DefaultOptions +} + +export interface DefaultOptions { + queries?: OmitKeyof< + QueryObserverOptions, + 'suspense' | 'queryKey' + > + mutations?: MutationObserverOptions + hydrate?: HydrateOptions['defaultOptions'] + dehydrate?: DehydrateOptions +} diff --git a/packages/query-core/src/types/common.ts b/packages/query-core/src/types/common.ts new file mode 100644 index 0000000000..afab1f3c1c --- /dev/null +++ b/packages/query-core/src/types/common.ts @@ -0,0 +1,98 @@ +/* istanbul ignore file */ + +export type NonUndefinedGuard = T extends undefined ? never : T + +export type DistributiveOmit< + TObject, + TKey extends keyof TObject, +> = TObject extends any ? Omit : never + +export type OmitKeyof< + TObject, + TKey extends TStrictly extends 'safely' + ? + | keyof TObject + | (string & Record) + | (number & Record) + | (symbol & Record) + : keyof TObject, + TStrictly extends 'strictly' | 'safely' = 'strictly', +> = Omit + +export type Override = { + [AKey in keyof TTargetA]: AKey extends keyof TTargetB + ? TTargetB[AKey] + : TTargetA[AKey] +} + +export type NoInfer = [T][T extends any ? 0 : never] + +/** + * Register interface for module augmentation. + * Extend this interface to customize global types like defaultError and queryMeta. + * + * @example + * ```ts + * declare module '@tanstack/query-core' { + * interface Register { + * defaultError: AxiosError + * queryMeta: { auth?: boolean } + * } + * } + * ``` + */ +export interface Register { + // defaultError: Error + // queryMeta: Record + // mutationMeta: Record + // queryKey: ReadonlyArray + // mutationKey: ReadonlyArray +} + +/** + * The default error type used when no error type is specified. + * Can be customized via module augmentation of the Register interface. + */ +export type DefaultError = Register extends { + defaultError: infer TError +} + ? TError + : Error + +export type WithRequired = TTarget & { + [_ in TKey]: {} +} + +/** + * Controls when queries and mutations should execute based on network connectivity. + * - 'online': Only fetches when online (default) + * - 'always': Always fetches regardless of network status + * - 'offlineFirst': Fetches from cache first, then network when online + */ +export type NetworkMode = 'online' | 'always' | 'offlineFirst' + +export interface CancelOptions { + revert?: boolean + silent?: boolean +} + +export interface SetDataOptions { + updatedAt?: number +} + +export type NotifyEventType = + | 'added' + | 'removed' + | 'updated' + | 'observerAdded' + | 'observerRemoved' + | 'observerResultsUpdated' + | 'observerOptionsUpdated' + +export interface NotifyEvent { + type: NotifyEventType +} + +export interface ResultOptions { + throwOnError?: boolean +} diff --git a/packages/query-core/src/types/index.ts b/packages/query-core/src/types/index.ts new file mode 100644 index 0000000000..967171cb02 --- /dev/null +++ b/packages/query-core/src/types/index.ts @@ -0,0 +1,116 @@ +/* istanbul ignore file */ + +// Re-export all types for backwards compatibility + +// Common/utility types +export type { + NonUndefinedGuard, + DistributiveOmit, + OmitKeyof, + Override, + NoInfer, + Register, + DefaultError, + WithRequired, + NetworkMode, + CancelOptions, + SetDataOptions, + NotifyEventType, + NotifyEvent, + ResultOptions, +} from './common' + +// Query types +export { dataTagSymbol, dataTagErrorSymbol, unsetMarker } from './query' + +export type { + dataTagSymbol as dataTagSymbolType, + dataTagErrorSymbol as dataTagErrorSymbolType, + UnsetMarker, + QueryKey, + AnyDataTag, + DataTag, + InferDataFromTag, + InferErrorFromTag, + QueryFunction, + StaleTime, + StaleTimeFunction, + Enabled, + QueryPersister, + QueryFunctionContext, + InitialDataFunction, + PlaceholderDataFunction, + QueriesPlaceholderDataFunction, + QueryKeyHashFunction, + GetPreviousPageParamFunction, + GetNextPageParamFunction, + InfiniteData, + QueryMeta, + QueryOptions, + InitialPageParam, + InfiniteQueryPageParamsOptions, + ThrowOnError, + FetchQueryOptions, + EnsureQueryDataOptions, + EnsureInfiniteQueryDataOptions, + FetchInfiniteQueryOptions, + RefetchOptions, + InvalidateQueryFilters, + RefetchQueryFilters, + InvalidateOptions, + ResetOptions, + FetchNextPageOptions, + FetchPreviousPageOptions, + QueryStatus, + FetchStatus, +} from './query' + +// Observer types +export type { + NotifyOnChangeProps, + QueryObserverOptions, + DefaultedQueryObserverOptions, + InfiniteQueryObserverOptions, + DefaultedInfiniteQueryObserverOptions, + QueryObserverBaseResult, + QueryObserverPendingResult, + QueryObserverLoadingResult, + QueryObserverLoadingErrorResult, + QueryObserverRefetchErrorResult, + QueryObserverSuccessResult, + QueryObserverPlaceholderResult, + DefinedQueryObserverResult, + QueryObserverResult, + InfiniteQueryObserverBaseResult, + InfiniteQueryObserverPendingResult, + InfiniteQueryObserverLoadingResult, + InfiniteQueryObserverLoadingErrorResult, + InfiniteQueryObserverRefetchErrorResult, + InfiniteQueryObserverSuccessResult, + InfiniteQueryObserverPlaceholderResult, + DefinedInfiniteQueryObserverResult, + InfiniteQueryObserverResult, +} from './observer' + +// Mutation types +export type { + MutationKey, + MutationStatus, + MutationScope, + MutationMeta, + MutationFunctionContext, + MutationFunction, + MutationOptions, + MutationObserverOptions, + MutateOptions, + MutateFunction, + MutationObserverBaseResult, + MutationObserverIdleResult, + MutationObserverLoadingResult, + MutationObserverErrorResult, + MutationObserverSuccessResult, + MutationObserverResult, +} from './mutation' + +// Client configuration types +export type { QueryClientConfig, DefaultOptions } from './client' diff --git a/packages/query-core/src/types/mutation.ts b/packages/query-core/src/types/mutation.ts new file mode 100644 index 0000000000..89f5b47011 --- /dev/null +++ b/packages/query-core/src/types/mutation.ts @@ -0,0 +1,320 @@ +/* istanbul ignore file */ + +import type { QueryClient } from '../queryClient' +import type { MutationState } from '../mutation' +import type { RetryDelayValue, RetryValue } from '../retryer' +import type { DefaultError, NetworkMode, Register } from './common' + +/** + * A mutation key is a serializable array that can be used to identify and + * deduplicate mutations. Unlike query keys, mutation keys are optional. + * + * @example + * ```ts + * const key: MutationKey = ['addTodo'] + * ``` + */ +export type MutationKey = Register extends { + mutationKey: infer TMutationKey +} + ? TMutationKey extends ReadonlyArray + ? TMutationKey + : TMutationKey extends Array + ? TMutationKey + : ReadonlyArray + : ReadonlyArray + +export type MutationStatus = 'idle' | 'pending' | 'success' | 'error' + +export type MutationScope = { + id: string +} + +export type MutationMeta = Register extends { + mutationMeta: infer TMutationMeta +} + ? TMutationMeta extends Record + ? TMutationMeta + : Record + : Record + +export type MutationFunctionContext = { + client: QueryClient + meta: MutationMeta | undefined + mutationKey?: MutationKey +} + +/** + * The function that performs the mutation operation. + * Receives the variables and context, and returns a promise with the result. + * + * @example + * ```ts + * const mutationFn: MutationFunction = async (newTodo) => { + * const response = await fetch('/api/todos', { + * method: 'POST', + * body: JSON.stringify(newTodo), + * }) + * return response.json() + * } + * ``` + */ +export type MutationFunction = ( + variables: TVariables, + context: MutationFunctionContext, +) => Promise + +/** + * Configuration options for a mutation. These options control the mutation + * function, callbacks, retrying, and other mutation behaviors. + * + * @typeParam TData - The data type returned by the mutation function + * @typeParam TError - The error type for the mutation + * @typeParam TVariables - The variables type passed to the mutation function + * @typeParam TOnMutateResult - The return type of the onMutate callback + */ +export interface MutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> { + mutationFn?: MutationFunction + mutationKey?: MutationKey + onMutate?: ( + variables: TVariables, + context: MutationFunctionContext, + ) => Promise | TOnMutateResult + onSuccess?: ( + data: TData, + variables: TVariables, + onMutateResult: TOnMutateResult, + context: MutationFunctionContext, + ) => Promise | unknown + onError?: ( + error: TError, + variables: TVariables, + onMutateResult: TOnMutateResult | undefined, + context: MutationFunctionContext, + ) => Promise | unknown + onSettled?: ( + data: TData | undefined, + error: TError | null, + variables: TVariables, + onMutateResult: TOnMutateResult | undefined, + context: MutationFunctionContext, + ) => Promise | unknown + retry?: RetryValue + retryDelay?: RetryDelayValue + networkMode?: NetworkMode + gcTime?: number + _defaulted?: boolean + meta?: MutationMeta + scope?: MutationScope +} + +export interface MutationObserverOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> extends MutationOptions { + throwOnError?: boolean | ((error: TError) => boolean) +} + +export interface MutateOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> { + onSuccess?: ( + data: TData, + variables: TVariables, + onMutateResult: TOnMutateResult | undefined, + context: MutationFunctionContext, + ) => void + onError?: ( + error: TError, + variables: TVariables, + onMutateResult: TOnMutateResult | undefined, + context: MutationFunctionContext, + ) => void + onSettled?: ( + data: TData | undefined, + error: TError | null, + variables: TVariables, + onMutateResult: TOnMutateResult | undefined, + context: MutationFunctionContext, + ) => void +} + +export type MutateFunction< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> = ( + variables: TVariables, + options?: MutateOptions, +) => Promise + +export interface MutationObserverBaseResult< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> extends MutationState { + /** + * The last successfully resolved data for the mutation. + */ + data: TData | undefined + /** + * The variables object passed to the `mutationFn`. + */ + variables: TVariables | undefined + /** + * The error object for the mutation, if an error was encountered. + * - Defaults to `null`. + */ + error: TError | null + /** + * A boolean variable derived from `status`. + * - `true` if the last mutation attempt resulted in an error. + */ + isError: boolean + /** + * A boolean variable derived from `status`. + * - `true` if the mutation is in its initial state prior to executing. + */ + isIdle: boolean + /** + * A boolean variable derived from `status`. + * - `true` if the mutation is currently executing. + */ + isPending: boolean + /** + * A boolean variable derived from `status`. + * - `true` if the last mutation attempt was successful. + */ + isSuccess: boolean + /** + * The status of the mutation. + * - Will be: + * - `idle` initial status prior to the mutation function executing. + * - `pending` if the mutation is currently executing. + * - `error` if the last mutation attempt resulted in an error. + * - `success` if the last mutation attempt was successful. + */ + status: MutationStatus + /** + * The mutation function you can call with variables to trigger the mutation and optionally hooks on additional callback options. + * @param variables - The variables object to pass to the `mutationFn`. + * @param options.onSuccess - This function will fire when the mutation is successful and will be passed the mutation's result. + * @param options.onError - This function will fire if the mutation encounters an error and will be passed the error. + * @param options.onSettled - This function will fire when the mutation is either successfully fetched or encounters an error and be passed either the data or error. + * @remarks + * - If you make multiple requests, `onSuccess` will fire only after the latest call you've made. + * - All the callback functions (`onSuccess`, `onError`, `onSettled`) are void functions, and the returned value will be ignored. + */ + mutate: MutateFunction + /** + * A function to clean the mutation internal state (i.e., it resets the mutation to its initial state). + */ + reset: () => void +} + +export interface MutationObserverIdleResult< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> extends MutationObserverBaseResult< + TData, + TError, + TVariables, + TOnMutateResult +> { + data: undefined + variables: undefined + error: null + isError: false + isIdle: true + isPending: false + isSuccess: false + status: 'idle' +} + +export interface MutationObserverLoadingResult< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> extends MutationObserverBaseResult< + TData, + TError, + TVariables, + TOnMutateResult +> { + data: undefined + variables: TVariables + error: null + isError: false + isIdle: false + isPending: true + isSuccess: false + status: 'pending' +} + +export interface MutationObserverErrorResult< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> extends MutationObserverBaseResult< + TData, + TError, + TVariables, + TOnMutateResult +> { + data: undefined + error: TError + variables: TVariables + isError: true + isIdle: false + isPending: false + isSuccess: false + status: 'error' +} + +export interface MutationObserverSuccessResult< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> extends MutationObserverBaseResult< + TData, + TError, + TVariables, + TOnMutateResult +> { + data: TData + error: null + variables: TVariables + isError: false + isIdle: false + isPending: false + isSuccess: true + status: 'success' +} + +export type MutationObserverResult< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> = + | MutationObserverIdleResult + | MutationObserverLoadingResult + | MutationObserverErrorResult + | MutationObserverSuccessResult diff --git a/packages/query-core/src/types/observer.ts b/packages/query-core/src/types/observer.ts new file mode 100644 index 0000000000..30a9324373 --- /dev/null +++ b/packages/query-core/src/types/observer.ts @@ -0,0 +1,654 @@ +/* istanbul ignore file */ + +import type { Query } from '../query' +import type { DefaultError, WithRequired } from './common' +import type { + Enabled, + FetchNextPageOptions, + FetchPreviousPageOptions, + FetchStatus, + InfiniteData, + InfiniteQueryPageParamsOptions, + PlaceholderDataFunction, + QueryKey, + QueryOptions, + QueryStatus, + RefetchOptions, + StaleTimeFunction, + ThrowOnError, +} from './query' + +type NonFunctionGuard = T extends Function ? never : T + +export type NotifyOnChangeProps = + | Array + | 'all' + | undefined + | (() => Array | 'all' | undefined) + +export interface QueryObserverOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, +> extends WithRequired< + QueryOptions, + 'queryKey' +> { + /** + * Set this to `false` or a function that returns `false` to disable automatic refetching when the query mounts or changes query keys. + * To refetch the query, use the `refetch` method returned from the `useQuery` instance. + * Accepts a boolean or function that returns a boolean. + * Defaults to `true`. + */ + enabled?: Enabled + /** + * The time in milliseconds after data is considered stale. + * If set to `Infinity`, the data will never be considered stale. + * If set to a function, the function will be executed with the query to compute a `staleTime`. + * Defaults to `0`. + */ + staleTime?: StaleTimeFunction + /** + * If set to a number, the query will continuously refetch at this frequency in milliseconds. + * If set to a function, the function will be executed with the latest data and query to compute a frequency + * Defaults to `false`. + */ + refetchInterval?: + | number + | false + | (( + query: Query, + ) => number | false | undefined) + /** + * If set to `true`, the query will continue to refetch while their tab/window is in the background. + * Defaults to `false`. + */ + refetchIntervalInBackground?: boolean + /** + * If set to `true`, the query will refetch on window focus if the data is stale. + * If set to `false`, the query will not refetch on window focus. + * If set to `'always'`, the query will always refetch on window focus. + * If set to a function, the function will be executed with the latest data and query to compute the value. + * Defaults to `true`. + */ + refetchOnWindowFocus?: + | boolean + | 'always' + | (( + query: Query, + ) => boolean | 'always') + /** + * If set to `true`, the query will refetch on reconnect if the data is stale. + * If set to `false`, the query will not refetch on reconnect. + * If set to `'always'`, the query will always refetch on reconnect. + * If set to a function, the function will be executed with the latest data and query to compute the value. + * Defaults to the value of `networkOnline` (`true`) + */ + refetchOnReconnect?: + | boolean + | 'always' + | (( + query: Query, + ) => boolean | 'always') + /** + * If set to `true`, the query will refetch on mount if the data is stale. + * If set to `false`, will disable additional instances of a query to trigger background refetch. + * If set to `'always'`, the query will always refetch on mount. + * If set to a function, the function will be executed with the latest data and query to compute the value + * Defaults to `true`. + */ + refetchOnMount?: + | boolean + | 'always' + | (( + query: Query, + ) => boolean | 'always') + /** + * If set to `false`, the query will not be retried on mount if it contains an error. + * Defaults to `true`. + */ + retryOnMount?: boolean + /** + * 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. + * When set to `'all'`, the component will re-render whenever a query is updated. + * When set to a function, the function will be executed to compute the list of properties. + * By default, access to properties will be tracked, and the component will only re-render when one of the tracked properties change. + */ + notifyOnChangeProps?: NotifyOnChangeProps + /** + * Whether errors should be thrown instead of setting the `error` property. + * If set to `true` or `suspense` is `true`, all errors will be thrown to the error boundary. + * If set to `false` and `suspense` is `false`, errors are returned as state. + * If set to a function, it will be passed the error and the query, and it should return a boolean indicating whether to show the error in an error boundary (`true`) or return the error as state (`false`). + * Defaults to `false`. + */ + throwOnError?: ThrowOnError + /** + * This option can be used to transform or select a part of the data returned by the query function. + */ + select?: (data: TQueryData) => TData + /** + * If set to `true`, the query will suspend when `status === 'pending'` + * and throw errors when `status === 'error'`. + * Defaults to `false`. + */ + suspense?: boolean + /** + * If set, this value will be used as the placeholder data for this particular query observer while the query is still in the `loading` data and no initialData has been provided. + */ + placeholderData?: + | NonFunctionGuard + | PlaceholderDataFunction< + NonFunctionGuard, + TError, + NonFunctionGuard, + TQueryKey + > + + _optimisticResults?: 'optimistic' | 'isRestoring' + + /** + * Enable prefetching during rendering + */ + experimental_prefetchInRender?: boolean +} + +export type DefaultedQueryObserverOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = WithRequired< + QueryObserverOptions, + 'throwOnError' | 'refetchOnReconnect' | 'queryHash' +> + +export interface InfiniteQueryObserverOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> + extends + QueryObserverOptions< + TQueryFnData, + TError, + TData, + InfiniteData, + TQueryKey, + TPageParam + >, + InfiniteQueryPageParamsOptions {} + +export type DefaultedInfiniteQueryObserverOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = WithRequired< + InfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + 'throwOnError' | 'refetchOnReconnect' | 'queryHash' +> + +export interface QueryObserverBaseResult< + TData = unknown, + TError = DefaultError, +> { + /** + * The last successfully resolved data for the query. + */ + data: TData | undefined + /** + * The timestamp for when the query most recently returned the `status` as `"success"`. + */ + dataUpdatedAt: number + /** + * The error object for the query, if an error was thrown. + * - Defaults to `null`. + */ + error: TError | null + /** + * The timestamp for when the query most recently returned the `status` as `"error"`. + */ + errorUpdatedAt: number + /** + * The failure count for the query. + * - Incremented every time the query fails. + * - Reset to `0` when the query succeeds. + */ + failureCount: number + /** + * The failure reason for the query retry. + * - Reset to `null` when the query succeeds. + */ + failureReason: TError | null + /** + * The sum of all errors. + */ + errorUpdateCount: number + /** + * A derived boolean from the `status` variable, provided for convenience. + * - `true` if the query attempt resulted in an error. + */ + isError: boolean + /** + * Will be `true` if the query has been fetched. + */ + isFetched: boolean + /** + * Will be `true` if the query has been fetched after the component mounted. + * - This property can be used to not show any previously cached data. + */ + isFetchedAfterMount: boolean + /** + * A derived boolean from the `fetchStatus` variable, provided for convenience. + * - `true` whenever the `queryFn` is executing, which includes initial `pending` as well as background refetch. + */ + isFetching: boolean + /** + * Is `true` whenever the first fetch for a query is in-flight. + * - Is the same as `isFetching && isPending`. + */ + isLoading: boolean + /** + * Will be `pending` if there's no cached data and no query attempt was finished yet. + */ + isPending: boolean + /** + * Will be `true` if the query failed while fetching for the first time. + */ + isLoadingError: boolean + /** + * @deprecated `isInitialLoading` is being deprecated in favor of `isLoading` + * and will be removed in the next major version. + */ + isInitialLoading: boolean + /** + * A derived boolean from the `fetchStatus` variable, provided for convenience. + * - The query wanted to fetch, but has been `paused`. + */ + isPaused: boolean + /** + * Will be `true` if the data shown is the placeholder data. + */ + isPlaceholderData: boolean + /** + * Will be `true` if the query failed while refetching. + */ + isRefetchError: boolean + /** + * Is `true` whenever a background refetch is in-flight, which _does not_ include initial `pending`. + * - Is the same as `isFetching && !isPending`. + */ + isRefetching: boolean + /** + * Will be `true` if the data in the cache is invalidated or if the data is older than the given `staleTime`. + */ + isStale: boolean + /** + * A derived boolean from the `status` variable, provided for convenience. + * - `true` if the query has received a response with no errors and is ready to display its data. + */ + isSuccess: boolean + /** + * `true` if this observer is enabled, `false` otherwise. + */ + isEnabled: boolean + /** + * A function to manually refetch the query. + */ + refetch: ( + options?: RefetchOptions, + ) => Promise> + /** + * The status of the query. + * - Will be: + * - `pending` if there's no cached data and no query attempt was finished yet. + * - `error` if the query attempt resulted in an error. + * - `success` if the query has received a response with no errors and is ready to display its data. + */ + status: QueryStatus + /** + * The fetch status of the query. + * - `fetching`: Is `true` whenever the queryFn is executing, which includes initial `pending` as well as background refetch. + * - `paused`: The query wanted to fetch, but has been `paused`. + * - `idle`: The query is not fetching. + * - See [Network Mode](https://tanstack.com/query/latest/docs/framework/react/guides/network-mode) for more information. + */ + fetchStatus: FetchStatus + /** + * A stable promise that will be resolved with the data of the query. + * Requires the `experimental_prefetchInRender` feature flag to be enabled. + * @example + * + * ### Enabling the feature flag + * ```ts + * const client = new QueryClient({ + * defaultOptions: { + * queries: { + * experimental_prefetchInRender: true, + * }, + * }, + * }) + * ``` + * + * ### Usage + * ```tsx + * import { useQuery } from '@tanstack/react-query' + * import React from 'react' + * import { fetchTodos, type Todo } from './api' + * + * function TodoList({ query }: { query: UseQueryResult }) { + * const data = React.use(query.promise) + * + * return ( + *
    + * {data.map(todo => ( + *
  • {todo.title}
  • + * ))} + *
+ * ) + * } + * + * export function App() { + * const query = useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) + * + * return ( + * <> + *

Todos

+ * Loading...}> + * + * + * + * ) + * } + * ``` + */ + promise: Promise +} + +export interface QueryObserverPendingResult< + TData = unknown, + TError = DefaultError, +> extends QueryObserverBaseResult { + data: undefined + error: null + isError: false + isPending: true + isLoadingError: false + isRefetchError: false + isSuccess: false + isPlaceholderData: false + status: 'pending' +} + +export interface QueryObserverLoadingResult< + TData = unknown, + TError = DefaultError, +> extends QueryObserverBaseResult { + data: undefined + error: null + isError: false + isPending: true + isLoading: true + isLoadingError: false + isRefetchError: false + isSuccess: false + isPlaceholderData: false + status: 'pending' +} + +export interface QueryObserverLoadingErrorResult< + TData = unknown, + TError = DefaultError, +> extends QueryObserverBaseResult { + data: undefined + error: TError + isError: true + isPending: false + isLoading: false + isLoadingError: true + isRefetchError: false + isSuccess: false + isPlaceholderData: false + status: 'error' +} + +export interface QueryObserverRefetchErrorResult< + TData = unknown, + TError = DefaultError, +> extends QueryObserverBaseResult { + data: TData + error: TError + isError: true + isPending: false + isLoading: false + isLoadingError: false + isRefetchError: true + isSuccess: false + isPlaceholderData: false + status: 'error' +} + +export interface QueryObserverSuccessResult< + TData = unknown, + TError = DefaultError, +> extends QueryObserverBaseResult { + data: TData + error: null + isError: false + isPending: false + isLoading: false + isLoadingError: false + isRefetchError: false + isSuccess: true + isPlaceholderData: false + status: 'success' +} + +export interface QueryObserverPlaceholderResult< + TData = unknown, + TError = DefaultError, +> extends QueryObserverBaseResult { + data: TData + isError: false + error: null + isPending: false + isLoading: false + isLoadingError: false + isRefetchError: false + isSuccess: true + isPlaceholderData: true + status: 'success' +} + +export type DefinedQueryObserverResult< + TData = unknown, + TError = DefaultError, +> = + | QueryObserverRefetchErrorResult + | QueryObserverSuccessResult + +export type QueryObserverResult = + | DefinedQueryObserverResult + | QueryObserverLoadingErrorResult + | QueryObserverLoadingResult + | QueryObserverPendingResult + | QueryObserverPlaceholderResult + +export interface InfiniteQueryObserverBaseResult< + TData = unknown, + TError = DefaultError, +> extends QueryObserverBaseResult { + /** + * This function allows you to fetch the next "page" of results. + */ + fetchNextPage: ( + options?: FetchNextPageOptions, + ) => Promise> + /** + * This function allows you to fetch the previous "page" of results. + */ + fetchPreviousPage: ( + options?: FetchPreviousPageOptions, + ) => Promise> + /** + * Will be `true` if there is a next page to be fetched (known via the `getNextPageParam` option). + */ + hasNextPage: boolean + /** + * Will be `true` if there is a previous page to be fetched (known via the `getPreviousPageParam` option). + */ + hasPreviousPage: boolean + /** + * Will be `true` if the query failed while fetching the next page. + */ + isFetchNextPageError: boolean + /** + * Will be `true` while fetching the next page with `fetchNextPage`. + */ + isFetchingNextPage: boolean + /** + * Will be `true` if the query failed while fetching the previous page. + */ + isFetchPreviousPageError: boolean + /** + * Will be `true` while fetching the previous page with `fetchPreviousPage`. + */ + isFetchingPreviousPage: boolean +} + +export interface InfiniteQueryObserverPendingResult< + TData = unknown, + TError = DefaultError, +> extends InfiniteQueryObserverBaseResult { + data: undefined + error: null + isError: false + isPending: true + isLoadingError: false + isRefetchError: false + isFetchNextPageError: false + isFetchPreviousPageError: false + isSuccess: false + isPlaceholderData: false + status: 'pending' +} + +export interface InfiniteQueryObserverLoadingResult< + TData = unknown, + TError = DefaultError, +> extends InfiniteQueryObserverBaseResult { + data: undefined + error: null + isError: false + isPending: true + isLoading: true + isLoadingError: false + isRefetchError: false + isFetchNextPageError: false + isFetchPreviousPageError: false + isSuccess: false + isPlaceholderData: false + status: 'pending' +} + +export interface InfiniteQueryObserverLoadingErrorResult< + TData = unknown, + TError = DefaultError, +> extends InfiniteQueryObserverBaseResult { + data: undefined + error: TError + isError: true + isPending: false + isLoading: false + isLoadingError: true + isRefetchError: false + isFetchNextPageError: false + isFetchPreviousPageError: false + isSuccess: false + isPlaceholderData: false + status: 'error' +} + +export interface InfiniteQueryObserverRefetchErrorResult< + TData = unknown, + TError = DefaultError, +> extends InfiniteQueryObserverBaseResult { + data: TData + error: TError + isError: true + isPending: false + isLoading: false + isLoadingError: false + isRefetchError: true + isSuccess: false + isPlaceholderData: false + status: 'error' +} + +export interface InfiniteQueryObserverSuccessResult< + TData = unknown, + TError = DefaultError, +> extends InfiniteQueryObserverBaseResult { + data: TData + error: null + isError: false + isPending: false + isLoading: false + isLoadingError: false + isRefetchError: false + isFetchNextPageError: false + isFetchPreviousPageError: false + isSuccess: true + isPlaceholderData: false + status: 'success' +} + +export interface InfiniteQueryObserverPlaceholderResult< + TData = unknown, + TError = DefaultError, +> extends InfiniteQueryObserverBaseResult { + data: TData + isError: false + error: null + isPending: false + isLoading: false + isLoadingError: false + isRefetchError: false + isSuccess: true + isPlaceholderData: true + isFetchNextPageError: false + isFetchPreviousPageError: false + status: 'success' +} + +export type DefinedInfiniteQueryObserverResult< + TData = unknown, + TError = DefaultError, +> = + | InfiniteQueryObserverRefetchErrorResult + | InfiniteQueryObserverSuccessResult + +export type InfiniteQueryObserverResult< + TData = unknown, + TError = DefaultError, +> = + | DefinedInfiniteQueryObserverResult + | InfiniteQueryObserverLoadingErrorResult + | InfiniteQueryObserverLoadingResult + | InfiniteQueryObserverPendingResult + | InfiniteQueryObserverPlaceholderResult diff --git a/packages/query-core/src/types/query.ts b/packages/query-core/src/types/query.ts new file mode 100644 index 0000000000..ab34a52bb8 --- /dev/null +++ b/packages/query-core/src/types/query.ts @@ -0,0 +1,434 @@ +/* istanbul ignore file */ + +import type { QueryClient } from '../queryClient' +import type { FetchDirection, Query, QueryBehavior } from '../query' +import type { RetryDelayValue, RetryValue } from '../retryer' +import type { QueryFilters, QueryTypeFilter, SkipToken } from '../utils' +import type { + DefaultError, + NetworkMode, + NoInfer, + Register, + ResultOptions, + WithRequired, +} from './common' + +/** + * A query key is a serializable array that uniquely identifies a query. + * Query keys are used for caching and can be customized via the Register interface. + * + * @example + * ```ts + * // Simple key + * const key1: QueryKey = ['todos'] + * + * // Key with parameters + * const key2: QueryKey = ['todos', { status: 'done' }] + * + * // Nested key + * const key3: QueryKey = ['todos', 'list', { page: 1 }] + * ``` + */ +export type QueryKey = Register extends { + queryKey: infer TQueryKey +} + ? TQueryKey extends ReadonlyArray + ? TQueryKey + : TQueryKey extends Array + ? TQueryKey + : ReadonlyArray + : ReadonlyArray + +export const dataTagSymbol = Symbol('dataTagSymbol') +export type dataTagSymbol = typeof dataTagSymbol +export const dataTagErrorSymbol = Symbol('dataTagErrorSymbol') +export type dataTagErrorSymbol = typeof dataTagErrorSymbol +export const unsetMarker = Symbol('unsetMarker') +export type UnsetMarker = typeof unsetMarker +export type AnyDataTag = { + [dataTagSymbol]: any + [dataTagErrorSymbol]: any +} +export type DataTag< + TType, + TValue, + TError = UnsetMarker, +> = TType extends AnyDataTag + ? TType + : TType & { + [dataTagSymbol]: TValue + [dataTagErrorSymbol]: TError + } + +export type InferDataFromTag = + TTaggedQueryKey extends DataTag + ? TaggedValue + : TQueryFnData + +export type InferErrorFromTag = + TTaggedQueryKey extends DataTag + ? TaggedError extends UnsetMarker + ? TError + : TaggedError + : TError + +/** + * The function that fetches data for a query. + * Receives a context object with the query key, abort signal, and other metadata. + * + * @example + * ```ts + * const queryFn: QueryFunction = async ({ queryKey, signal }) => { + * const response = await fetch('/api/todos', { signal }) + * return response.json() + * } + * ``` + */ +export type QueryFunction< + T = unknown, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, +> = (context: QueryFunctionContext) => T | Promise + +export type StaleTime = number | 'static' + +export type StaleTimeFunction< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = + | StaleTime + | ((query: Query) => StaleTime) + +export type Enabled< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = + | boolean + | ((query: Query) => boolean) + +export type QueryPersister< + T = unknown, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, +> = [TPageParam] extends [never] + ? ( + queryFn: QueryFunction, + context: QueryFunctionContext, + query: Query, + ) => T | Promise + : ( + queryFn: QueryFunction, + context: QueryFunctionContext, + query: Query, + ) => T | Promise + +export type QueryFunctionContext< + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, +> = [TPageParam] extends [never] + ? { + client: QueryClient + queryKey: TQueryKey + signal: AbortSignal + meta: QueryMeta | undefined + pageParam?: unknown + /** + * @deprecated + * if you want access to the direction, you can add it to the pageParam + */ + direction?: unknown + } + : { + client: QueryClient + queryKey: TQueryKey + signal: AbortSignal + pageParam: TPageParam + /** + * @deprecated + * if you want access to the direction, you can add it to the pageParam + */ + direction: FetchDirection + meta: QueryMeta | undefined + } + +export type InitialDataFunction = () => T | undefined + +export type PlaceholderDataFunction< + TQueryFnData = unknown, + TError = DefaultError, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = ( + previousData: TQueryData | undefined, + previousQuery: Query | undefined, +) => TQueryData | undefined + +export type QueriesPlaceholderDataFunction = ( + previousData: undefined, + previousQuery: undefined, +) => TQueryData | undefined + +export type QueryKeyHashFunction = ( + queryKey: TQueryKey, +) => string + +export type GetPreviousPageParamFunction = ( + firstPage: TQueryFnData, + allPages: Array, + firstPageParam: TPageParam, + allPageParams: Array, +) => TPageParam | undefined | null + +export type GetNextPageParamFunction = ( + lastPage: TQueryFnData, + allPages: Array, + lastPageParam: TPageParam, + allPageParams: Array, +) => TPageParam | undefined | null + +/** + * The data structure returned by infinite queries. + * Contains all fetched pages and their corresponding page parameters. + */ +export interface InfiniteData { + /** Array of fetched pages, in order */ + pages: Array + /** Array of page parameters used to fetch each page */ + pageParams: Array +} + +export type QueryMeta = Register extends { + queryMeta: infer TQueryMeta +} + ? TQueryMeta extends Record + ? TQueryMeta + : Record + : Record + +/** + * Configuration options for a query. These options control caching, fetching, + * retrying, and other query behaviors. + * + * @typeParam TQueryFnData - The data type returned by the query function + * @typeParam TError - The error type for the query + * @typeParam TData - The transformed data type (after select) + * @typeParam TQueryKey - The query key type + * @typeParam TPageParam - The page parameter type for infinite queries + */ +export interface QueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, +> { + /** + * If `false`, failed queries will not retry by default. + * If `true`, failed queries will retry infinitely., failureCount: num + * If set to an integer number, e.g. 3, failed queries will retry until the failed query count meets that number. + * If set to a function `(failureCount, error) => boolean` failed queries will retry until the function returns false. + */ + retry?: RetryValue + retryDelay?: RetryDelayValue + networkMode?: NetworkMode + /** + * The time in milliseconds that unused/inactive cache data remains in memory. + * When a query's cache becomes unused or inactive, that cache data will be garbage collected after this duration. + * When different garbage collection times are specified, the longest one will be used. + * Setting it to `Infinity` will disable garbage collection. + */ + gcTime?: number + queryFn?: QueryFunction | SkipToken + persister?: QueryPersister< + NoInfer, + NoInfer, + NoInfer + > + queryHash?: string + queryKey?: TQueryKey + queryKeyHashFn?: QueryKeyHashFunction + initialData?: TData | InitialDataFunction + initialDataUpdatedAt?: number | (() => number | undefined) + behavior?: QueryBehavior + /** + * Set this to `false` to disable structural sharing between query results. + * Set this to a function which accepts the old and new data and returns resolved data of the same type to implement custom structural sharing logic. + * Defaults to `true`. + */ + structuralSharing?: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) + _defaulted?: boolean + /** + * Additional payload to be stored on each query. + * Use this property to pass information that can be used in other places. + */ + meta?: QueryMeta + /** + * Maximum number of pages to store in the data of an infinite query. + */ + maxPages?: number +} + +export interface InitialPageParam { + initialPageParam: TPageParam +} + +export interface InfiniteQueryPageParamsOptions< + TQueryFnData = unknown, + TPageParam = unknown, +> extends InitialPageParam { + /** + * This function can be set to automatically get the previous cursor for infinite queries. + * The result will also be used to determine the value of `hasPreviousPage`. + */ + getPreviousPageParam?: GetPreviousPageParamFunction + /** + * This function can be set to automatically get the next cursor for infinite queries. + * The result will also be used to determine the value of `hasNextPage`. + */ + getNextPageParam: GetNextPageParamFunction +} + +export type ThrowOnError< + TQueryFnData, + TError, + TQueryData, + TQueryKey extends QueryKey, +> = + | boolean + | (( + error: TError, + query: Query, + ) => boolean) + +export interface FetchQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, +> extends WithRequired< + QueryOptions, + 'queryKey' +> { + initialPageParam?: never + /** + * The time in milliseconds after data is considered stale. + * If the data is fresh it will be returned from the cache. + */ + staleTime?: StaleTimeFunction +} + +export interface EnsureQueryDataOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, +> extends FetchQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam +> { + revalidateIfStale?: boolean +} + +export type EnsureInfiniteQueryDataOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = FetchInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam +> & { + revalidateIfStale?: boolean +} + +type FetchInfiniteQueryPages = + | { pages?: never } + | { + pages: number + getNextPageParam: GetNextPageParamFunction + } + +export type FetchInfiniteQueryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = Omit< + FetchQueryOptions< + TQueryFnData, + TError, + InfiniteData, + TQueryKey, + TPageParam + >, + 'initialPageParam' +> & + InitialPageParam & + FetchInfiniteQueryPages + +export interface RefetchOptions extends ResultOptions { + /** + * If set to `true`, a currently running request will be cancelled before a new request is made + * + * If set to `false`, no refetch will be made if there is already a request running. + * + * Defaults to `true`. + */ + cancelRefetch?: boolean +} + +export interface InvalidateQueryFilters< + TQueryKey extends QueryKey = QueryKey, +> extends QueryFilters { + refetchType?: QueryTypeFilter | 'none' +} + +export interface RefetchQueryFilters< + TQueryKey extends QueryKey = QueryKey, +> extends QueryFilters {} + +export interface InvalidateOptions extends RefetchOptions {} +export interface ResetOptions extends RefetchOptions {} + +export interface FetchNextPageOptions extends ResultOptions { + /** + * If set to `true`, calling `fetchNextPage` repeatedly will invoke `queryFn` every time, + * whether the previous invocation has resolved or not. Also, the result from previous invocations will be ignored. + * + * If set to `false`, calling `fetchNextPage` repeatedly won't have any effect until the first invocation has resolved. + * + * Defaults to `true`. + */ + cancelRefetch?: boolean +} + +export interface FetchPreviousPageOptions extends ResultOptions { + /** + * If set to `true`, calling `fetchPreviousPage` repeatedly will invoke `queryFn` every time, + * whether the previous invocation has resolved or not. Also, the result from previous invocations will be ignored. + * + * If set to `false`, calling `fetchPreviousPage` repeatedly won't have any effect until the first invocation has resolved. + * + * Defaults to `true`. + */ + cancelRefetch?: boolean +} + +export type QueryStatus = 'pending' | 'error' | 'success' +export type FetchStatus = 'fetching' | 'paused' | 'idle' diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 27d44bc98b..6fd221858f 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -320,11 +320,16 @@ export function shallowEqualObjects>( a: T, b: T | undefined, ): boolean { - if (!b || Object.keys(a).length !== Object.keys(b).length) { + if (!b) { return false } - for (const key in a) { + const aKeys = Object.keys(a) + if (aKeys.length !== Object.keys(b).length) { + return false + } + + for (const key of aKeys) { if (a[key] !== b[key]) { return false } diff --git a/packages/query-devtools/src/__tests__/devtools.test.tsx b/packages/query-devtools/src/__tests__/devtools.test.tsx index 0e44d74db6..f7e8595e80 100644 --- a/packages/query-devtools/src/__tests__/devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/devtools.test.tsx @@ -1,7 +1,185 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { TanstackQueryDevtools, TanstackQueryDevtoolsPanel } from '..' +import type { QueryClient } from '@tanstack/query-core' -describe('ReactQueryDevtools', () => { - it('should be able to open and close devtools', () => { - expect(1).toBe(1) +// Create a mock QueryClient +const createMockQueryClient = (): QueryClient => + ({ + getQueryCache: vi.fn(() => ({ + getAll: vi.fn(() => []), + subscribe: vi.fn(), + find: vi.fn(), + findAll: vi.fn(), + })), + getMutationCache: vi.fn(() => ({ + getAll: vi.fn(() => []), + subscribe: vi.fn(), + })), + getDefaultOptions: vi.fn(() => ({})), + setDefaultOptions: vi.fn(), + getQueryDefaults: vi.fn(), + setQueryDefaults: vi.fn(), + getMutationDefaults: vi.fn(), + setMutationDefaults: vi.fn(), + defaultQueryOptions: vi.fn((options) => options), + defaultMutationOptions: vi.fn((options) => options), + }) as unknown as QueryClient + +// Mock onlineManager +const mockOnlineManager = { + isOnline: () => true, + subscribe: vi.fn(), + setOnline: vi.fn(), + setEventListener: vi.fn(), +} + +describe('TanstackQueryDevtools', () => { + it('should export TanstackQueryDevtools class', () => { + expect(TanstackQueryDevtools).toBeDefined() + expect(typeof TanstackQueryDevtools).toBe('function') + }) + + it('should create an instance with config', () => { + const client = createMockQueryClient() + const devtools = new TanstackQueryDevtools({ + client, + queryFlavor: 'React Query', + version: '5', + onlineManager: mockOnlineManager as any, + }) + + expect(devtools).toBeInstanceOf(TanstackQueryDevtools) + }) + + it('should throw error when mounting twice', () => { + const client = createMockQueryClient() + const devtools = new TanstackQueryDevtools({ + client, + queryFlavor: 'React Query', + version: '5', + onlineManager: mockOnlineManager as any, + }) + + const container = document.createElement('div') + devtools.mount(container) + + expect(() => devtools.mount(container)).toThrow( + 'Devtools is already mounted', + ) + + devtools.unmount() + }) + + it('should throw error when unmounting without mounting', () => { + const client = createMockQueryClient() + const devtools = new TanstackQueryDevtools({ + client, + queryFlavor: 'React Query', + version: '5', + onlineManager: mockOnlineManager as any, + }) + + expect(() => devtools.unmount()).toThrow('Devtools is not mounted') + }) + + it('should allow setting button position', () => { + const client = createMockQueryClient() + const devtools = new TanstackQueryDevtools({ + client, + queryFlavor: 'React Query', + version: '5', + onlineManager: mockOnlineManager as any, + buttonPosition: 'bottom-left', + }) + + // Should not throw + expect(() => devtools.setButtonPosition('top-right')).not.toThrow() + }) + + it('should allow setting position', () => { + const client = createMockQueryClient() + const devtools = new TanstackQueryDevtools({ + client, + queryFlavor: 'React Query', + version: '5', + onlineManager: mockOnlineManager as any, + }) + + // Should not throw + expect(() => devtools.setPosition('left')).not.toThrow() + }) + + it('should allow setting initial open state', () => { + const client = createMockQueryClient() + const devtools = new TanstackQueryDevtools({ + client, + queryFlavor: 'React Query', + version: '5', + onlineManager: mockOnlineManager as any, + }) + + // Should not throw + expect(() => devtools.setInitialIsOpen(true)).not.toThrow() + }) + + it('should allow setting error types', () => { + const client = createMockQueryClient() + const devtools = new TanstackQueryDevtools({ + client, + queryFlavor: 'React Query', + version: '5', + onlineManager: mockOnlineManager as any, + }) + + // Should not throw + expect(() => devtools.setErrorTypes([])).not.toThrow() + }) + + it('should allow setting client', () => { + const client = createMockQueryClient() + const devtools = new TanstackQueryDevtools({ + client, + queryFlavor: 'React Query', + version: '5', + onlineManager: mockOnlineManager as any, + }) + + const newClient = createMockQueryClient() + // Should not throw + expect(() => devtools.setClient(newClient)).not.toThrow() + }) + + it('should allow setting theme', () => { + const client = createMockQueryClient() + const devtools = new TanstackQueryDevtools({ + client, + queryFlavor: 'React Query', + version: '5', + onlineManager: mockOnlineManager as any, + }) + + // Should not throw + expect(() => devtools.setTheme('dark')).not.toThrow() + expect(() => devtools.setTheme('light')).not.toThrow() + expect(() => devtools.setTheme('system')).not.toThrow() + }) +}) + +describe('TanstackQueryDevtoolsPanel', () => { + it('should export TanstackQueryDevtoolsPanel class', () => { + expect(TanstackQueryDevtoolsPanel).toBeDefined() + expect(typeof TanstackQueryDevtoolsPanel).toBe('function') + }) + + it('should create an instance with config', () => { + const client = createMockQueryClient() + const devtoolsPanel = new TanstackQueryDevtoolsPanel({ + client, + queryFlavor: 'React Query', + version: '5', + onlineManager: mockOnlineManager as any, + }) + + expect(devtoolsPanel).toBeInstanceOf(TanstackQueryDevtoolsPanel) }) }) diff --git a/packages/query-persist-client-core/src/__tests__/persist.test.ts b/packages/query-persist-client-core/src/__tests__/persist.test.ts index 14f7d0720b..7028babf3c 100644 --- a/packages/query-persist-client-core/src/__tests__/persist.test.ts +++ b/packages/query-persist-client-core/src/__tests__/persist.test.ts @@ -86,7 +86,11 @@ describe('persistQueryClientRestore', () => { queryClient, persister, }), - ).rejects.toBe(restoreError) + ).rejects.toMatchObject({ + name: 'PersistQueryClientError', + message: + 'Failed to restore persisted query client. The cache has been discarded.', + }) expect(consoleMock).toHaveBeenCalledTimes(1) expect(consoleWarn).toHaveBeenCalledTimes(1) diff --git a/packages/query-persist-client-core/src/createPersister.ts b/packages/query-persist-client-core/src/createPersister.ts index 55fccc5558..a2fbb1f646 100644 --- a/packages/query-persist-client-core/src/createPersister.ts +++ b/packages/query-persist-client-core/src/createPersister.ts @@ -77,6 +77,57 @@ export interface StoragePersisterOptions { export const PERSISTER_KEY_PREFIX = 'tanstack-query' +// Storage key validation constants +const MAX_STORAGE_KEY_LENGTH = 256 +const ALLOWED_KEY_CHARS = /^[a-zA-Z0-9_\-:.[\]]+$/ + +/** + * Validates and sanitizes a storage key + * @throws Error if key is invalid + */ +function validateStorageKey(key: string): void { + if (typeof key !== 'string' || key.length === 0) { + throw new Error('Storage key must be a non-empty string') + } + if (key.length > MAX_STORAGE_KEY_LENGTH) { + throw new Error( + `Storage key exceeds maximum length of ${MAX_STORAGE_KEY_LENGTH} characters`, + ) + } + if (!ALLOWED_KEY_CHARS.test(key)) { + throw new Error( + 'Storage key contains invalid characters. Only alphanumeric, underscore, hyphen, colon, dot, and brackets are allowed.', + ) + } +} + +/** + * Validates deserialized data to prevent prototype pollution + */ +function validateDeserializedData(data: unknown): void { + if (data === null || typeof data !== 'object') { + return + } + + const obj = data as Record + + // Check for prototype pollution vectors (only own properties, not inherited) + if ( + Object.hasOwn(obj, '__proto__') || + Object.hasOwn(obj, 'constructor') || + Object.hasOwn(obj, 'prototype') + ) { + throw new Error('Deserialized data contains forbidden keys') + } + + // Recursively validate nested objects + for (const value of Object.values(obj)) { + if (value !== null && typeof value === 'object') { + validateDeserializedData(value) + } + } +} + /** * Warning: experimental feature. * This utility function enables fine-grained query persistence. @@ -106,6 +157,8 @@ export function experimental_createQueryPersister({ refetchOnRestore = true, filters, }: StoragePersisterOptions) { + // Validate prefix parameter at initialization + validateStorageKey(prefix) function isExpiredOrBusted(persistedQuery: PersistedQuery) { if (persistedQuery.state.dataUpdatedAt) { const queryAge = Date.now() - persistedQuery.state.dataUpdatedAt @@ -128,10 +181,13 @@ export function experimental_createQueryPersister({ ) { if (storage != null) { const storageKey = `${prefix}-${queryHash}` + // Note: storageKey is not validated here because queryHash is internally generated + // and may contain JSON characters. The prefix is validated at initialization. try { const storedData = await storage.getItem(storageKey) if (storedData) { const persistedQuery = await deserialize(storedData) + validateDeserializedData(persistedQuery) if (isExpiredOrBusted(persistedQuery)) { await storage.removeItem(storageKey) @@ -182,6 +238,7 @@ export function experimental_createQueryPersister({ async function persistQuery(query: Query) { if (storage != null) { const storageKey = `${prefix}-${query.queryHash}` + // Note: storageKey is not validated here because queryHash is internally generated storage.setItem( storageKey, await serialize({ @@ -245,6 +302,7 @@ export function experimental_createQueryPersister({ for (const [key, value] of entries) { if (key.startsWith(prefix)) { const persistedQuery = await deserialize(value) + validateDeserializedData(persistedQuery) if (isExpiredOrBusted(persistedQuery)) { await storage.removeItem(key) @@ -260,15 +318,16 @@ export function experimental_createQueryPersister({ async function restoreQueries( queryClient: QueryClient, - filters: Pick = {}, + queryFilters: Pick = {}, ): Promise { - const { exact, queryKey } = filters + const { exact, queryKey } = queryFilters if (storage?.entries) { const entries = await storage.entries() for (const [key, value] of entries) { if (key.startsWith(prefix)) { const persistedQuery = await deserialize(value) + validateDeserializedData(persistedQuery) if (isExpiredOrBusted(persistedQuery)) { await storage.removeItem(key) diff --git a/packages/query-persist-client-core/src/persist.ts b/packages/query-persist-client-core/src/persist.ts index 3d81b76e48..3340044ac2 100644 --- a/packages/query-persist-client-core/src/persist.ts +++ b/packages/query-persist-client-core/src/persist.ts @@ -101,7 +101,12 @@ export async function persistQueryClientRestore({ await persister.removeClient() - throw err + // Throw a sanitized error to avoid exposing internal details + const sanitizedError = new Error( + 'Failed to restore persisted query client. The cache has been discarded.', + ) + sanitizedError.name = 'PersistQueryClientError' + throw sanitizedError } } diff --git a/packages/react-query-next-experimental/package.json b/packages/react-query-next-experimental/package.json index 80206e1079..e9fbff95f7 100644 --- a/packages/react-query-next-experimental/package.json +++ b/packages/react-query-next-experimental/package.json @@ -18,6 +18,8 @@ "clean": "premove ./build ./coverage ./dist-ts", "compile": "tsc --build", "test:eslint": "eslint --concurrency=auto ./src", + "test:lib": "vitest", + "test:lib:dev": "pnpm run test:lib --watch", "test:types": "npm-run-all --serial test:types:*", "test:types:ts50": "node ../../node_modules/typescript50/lib/tsc.js --build tsconfig.legacy.json", "test:types:ts51": "node ../../node_modules/typescript51/lib/tsc.js --build tsconfig.legacy.json", diff --git a/packages/react-query-next-experimental/src/HydrationStreamProvider.tsx b/packages/react-query-next-experimental/src/HydrationStreamProvider.tsx index c97f0c26d6..29ec55bfb5 100644 --- a/packages/react-query-next-experimental/src/HydrationStreamProvider.tsx +++ b/packages/react-query-next-experimental/src/HydrationStreamProvider.tsx @@ -133,18 +133,21 @@ export function createHydrationStreamProvider() { // eslint-disable-next-line react-hooks/immutability stream.length = 0 - const html: Array = [ + // Build the script content using text content instead of dangerouslySetInnerHTML + // This is safer as textContent is automatically escaped by the browser + const scriptContent = [ `window[${idJSON}] = window[${idJSON}] || [];`, `window[${idJSON}].push(${htmlEscapeJsonString(serializedCacheArgs)});`, - ] + ].join('') + return ( ) }) // diff --git a/packages/react-query-next-experimental/src/__tests__/HydrationStreamProvider.test.tsx b/packages/react-query-next-experimental/src/__tests__/HydrationStreamProvider.test.tsx new file mode 100644 index 0000000000..a25843c2d4 --- /dev/null +++ b/packages/react-query-next-experimental/src/__tests__/HydrationStreamProvider.test.tsx @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' +import { createHydrationStreamProvider } from '../HydrationStreamProvider' +import { ReactQueryStreamedHydration } from '../ReactQueryStreamedHydration' + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useServerInsertedHTML: vi.fn((callback) => { + // In test environment, just call the callback to test it doesn't throw + callback() + }), +})) + +// Mock react-query +vi.mock('@tanstack/react-query', () => ({ + isServer: false, + useQueryClient: vi.fn(() => ({ + getQueryCache: () => ({ + subscribe: vi.fn(), + }), + })), + dehydrate: vi.fn(() => ({ queries: [], mutations: [] })), + hydrate: vi.fn(), + defaultShouldDehydrateQuery: vi.fn(() => true), +})) + +describe('HydrationStreamProvider', () => { + it('should export createHydrationStreamProvider function', () => { + expect(createHydrationStreamProvider).toBeDefined() + expect(typeof createHydrationStreamProvider).toBe('function') + }) + + it('should create a provider with context', () => { + const result = createHydrationStreamProvider<{ test: string }>() + + expect(result).toHaveProperty('Provider') + expect(result).toHaveProperty('context') + expect(typeof result.Provider).toBe('function') + }) +}) + +describe('ReactQueryStreamedHydration', () => { + it('should export ReactQueryStreamedHydration component', () => { + expect(ReactQueryStreamedHydration).toBeDefined() + expect(typeof ReactQueryStreamedHydration).toBe('function') + }) +}) diff --git a/packages/react-query-next-experimental/vite.config.ts b/packages/react-query-next-experimental/vite.config.ts index 816f7feb0f..475641fdb2 100644 --- a/packages/react-query-next-experimental/vite.config.ts +++ b/packages/react-query-next-experimental/vite.config.ts @@ -1,6 +1,8 @@ -import { defineConfig } from 'vite' +import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' +import packageJson from './package.json' + export default defineConfig({ plugins: [react()], // fix from https://github.com/vitest-dev/vitest/issues/6992#issuecomment-2509408660 @@ -14,4 +16,13 @@ export default defineConfig({ }, }, }, + test: { + name: packageJson.name, + dir: './src', + watch: false, + environment: 'jsdom', + coverage: { enabled: true, provider: 'istanbul', include: ['src/**/*'] }, + typecheck: { enabled: true }, + restoreMocks: true, + }, }) diff --git a/packages/react-query/src/__tests__/useQuery.promise.test.tsx b/packages/react-query/src/__tests__/useQuery.promise.test.tsx index 5e1d892df0..54d6daec78 100644 --- a/packages/react-query/src/__tests__/useQuery.promise.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.promise.test.tsx @@ -1365,7 +1365,7 @@ describe('useQuery().promise', { timeout: 10_000 }, () => { expect( queryClient.getQueryCache().find({ queryKey: [key, 'defaultInput'] })! - .observers.length, + .observers.size, ).toBe(2) rendered.getByText('setInput').click() @@ -1382,12 +1382,12 @@ describe('useQuery().promise', { timeout: 10_000 }, () => { expect( queryClient.getQueryCache().find({ queryKey: [key, 'defaultInput'] })! - .observers.length, + .observers.size, ).toBe(0) expect( queryClient.getQueryCache().find({ queryKey: [key, 'someInput'] })! - .observers.length, + .observers.size, ).toBe(2) }) diff --git a/packages/react-query/src/__tests__/useQuery.test.tsx b/packages/react-query/src/__tests__/useQuery.test.tsx index 3c7f7562a8..a4bac69d6b 100644 --- a/packages/react-query/src/__tests__/useQuery.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.test.tsx @@ -5942,13 +5942,13 @@ describe('useQuery', () => { rendered.getByText('data: data') expect( - queryClient.getQueryCache().find({ queryKey: key })!.observers.length, + queryClient.getQueryCache().find({ queryKey: key })!.observers.size, ).toBe(1) fireEvent.click(rendered.getByRole('button', { name: 'toggle' })) expect( - queryClient.getQueryCache().find({ queryKey: key })!.observers.length, + queryClient.getQueryCache().find({ queryKey: key })!.observers.size, ).toBe(0) expect(queryFn).toHaveBeenCalledTimes(1) @@ -5959,7 +5959,7 @@ describe('useQuery', () => { await vi.advanceTimersByTimeAsync(0) expect(queryFn).toHaveBeenCalledTimes(2) expect( - queryClient.getQueryCache().find({ queryKey: key })!.observers.length, + queryClient.getQueryCache().find({ queryKey: key })!.observers.size, ).toBe(1) }) @@ -5986,7 +5986,7 @@ describe('useQuery', () => { rendered.getByText('data:') expect( - queryClient.getQueryCache().find({ queryKey: key })!.observers.length, + queryClient.getQueryCache().find({ queryKey: key })!.observers.size, ).toBe(0) expect(queryFn).toHaveBeenCalledTimes(0) diff --git a/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx b/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx index 8a0e6c5b7c..0c2009d664 100644 --- a/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx @@ -374,8 +374,10 @@ describe('useSuspenseQuery', () => { expect(rendered.getByText('data: 1')).toBeInTheDocument() expect( - typeof queryClient.getQueryCache().find({ queryKey: key })?.observers[0] - ?.options.staleTime, + typeof [ + ...(queryClient.getQueryCache().find({ queryKey: key })?.observers ?? + []), + ][0]?.options.staleTime, ).toBe('function') }) diff --git a/packages/svelte-query-devtools/package.json b/packages/svelte-query-devtools/package.json index 374deb0d39..08f7836732 100644 --- a/packages/svelte-query-devtools/package.json +++ b/packages/svelte-query-devtools/package.json @@ -24,6 +24,8 @@ "compile": "tsc --build", "test:types": "svelte-check --tsconfig ./tsconfig.json", "test:eslint": "eslint --concurrency=auto ./src", + "test:lib": "vitest", + "test:lib:dev": "pnpm run test:lib --watch", "test:build": "publint --strict && attw --pack", "build": "svelte-package --input ./src --output ./dist" }, diff --git a/packages/svelte-query-devtools/src/__tests__/devtools.test.ts b/packages/svelte-query-devtools/src/__tests__/devtools.test.ts new file mode 100644 index 0000000000..d97f14fe59 --- /dev/null +++ b/packages/svelte-query-devtools/src/__tests__/devtools.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest' +import { SvelteQueryDevtools } from '..' + +// Mock svelte-query to avoid requiring full Svelte context +vi.mock('@tanstack/svelte-query', () => ({ + useQueryClient: vi.fn(() => ({})), + onlineManager: { + isOnline: () => true, + subscribe: vi.fn(), + }, +})) + +// Mock esm-env to control environment +vi.mock('esm-env', () => ({ + BROWSER: false, + DEV: false, +})) + +// Mock query-devtools +vi.mock('@tanstack/query-devtools', () => ({ + TanstackQueryDevtools: vi.fn().mockImplementation(() => ({ + mount: vi.fn(), + unmount: vi.fn(), + setButtonPosition: vi.fn(), + setPosition: vi.fn(), + setInitialIsOpen: vi.fn(), + setErrorTypes: vi.fn(), + })), +})) + +describe('SvelteQueryDevtools', () => { + it('should export SvelteQueryDevtools component', () => { + expect(SvelteQueryDevtools).toBeDefined() + }) + + it('should be a valid Svelte component', () => { + // Svelte components are objects with specific shape + expect(typeof SvelteQueryDevtools).toBe('function') + }) +}) diff --git a/packages/svelte-query-devtools/vite.config.ts b/packages/svelte-query-devtools/vite.config.ts index 01ebc554bd..a6d35202e8 100644 --- a/packages/svelte-query-devtools/vite.config.ts +++ b/packages/svelte-query-devtools/vite.config.ts @@ -1,5 +1,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte' -import { defineConfig } from 'vite' +import { defineConfig } from 'vitest/config' + +import packageJson from './package.json' export default defineConfig({ plugins: [svelte()], @@ -14,4 +16,13 @@ export default defineConfig({ }, }, }, + test: { + name: packageJson.name, + dir: './src', + watch: false, + environment: 'jsdom', + coverage: { enabled: true, provider: 'istanbul', include: ['src/**/*'] }, + typecheck: { enabled: true }, + restoreMocks: true, + }, }) diff --git a/packages/vue-query-devtools/package.json b/packages/vue-query-devtools/package.json index 01a5cefdbe..4f7339bbf6 100644 --- a/packages/vue-query-devtools/package.json +++ b/packages/vue-query-devtools/package.json @@ -19,6 +19,8 @@ "compile": "vue-tsc --build", "test:eslint": "eslint --concurrency=auto ./src", "test:types": "vue-tsc --build", + "test:lib": "vitest", + "test:lib:dev": "pnpm run test:lib --watch", "test:build": "publint --strict && attw --pack", "build": "pnpm run compile && vite build" }, diff --git a/packages/vue-query-devtools/src/__tests__/devtools.test.ts b/packages/vue-query-devtools/src/__tests__/devtools.test.ts new file mode 100644 index 0000000000..5ccc66955f --- /dev/null +++ b/packages/vue-query-devtools/src/__tests__/devtools.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest' +import { VueQueryDevtools, VueQueryDevtoolsPanel } from '..' + +// Mock vue-query to avoid requiring full Vue context +vi.mock('@tanstack/vue-query', () => ({ + useQueryClient: vi.fn(), + onlineManager: { + isOnline: () => true, + subscribe: vi.fn(), + }, +})) + +// Mock query-devtools to avoid rendering complexities +vi.mock('@tanstack/query-devtools', () => ({ + TanstackQueryDevtools: vi.fn().mockImplementation(() => ({ + mount: vi.fn(), + unmount: vi.fn(), + setButtonPosition: vi.fn(), + setPosition: vi.fn(), + setInitialIsOpen: vi.fn(), + setErrorTypes: vi.fn(), + setTheme: vi.fn(), + })), +})) + +describe('VueQueryDevtools', () => { + it('should export VueQueryDevtools component', () => { + expect(VueQueryDevtools).toBeDefined() + }) + + it('should export VueQueryDevtoolsPanel component', () => { + expect(VueQueryDevtoolsPanel).toBeDefined() + }) + + it('should be functions (no-op in non-development mode)', () => { + // In test environment, NODE_ENV is 'test', so the devtools are no-op functions + expect(typeof VueQueryDevtools).toBe('function') + expect(typeof VueQueryDevtoolsPanel).toBe('function') + }) +}) diff --git a/packages/vue-query-devtools/vite.config.ts b/packages/vue-query-devtools/vite.config.ts index 7f66d0e500..5ed04ed714 100644 --- a/packages/vue-query-devtools/vite.config.ts +++ b/packages/vue-query-devtools/vite.config.ts @@ -1,7 +1,9 @@ -import { defineConfig, mergeConfig } from 'vite' +import { defineConfig, mergeConfig } from 'vitest/config' import vue from '@vitejs/plugin-vue' import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + const config = defineConfig({ plugins: [vue()], // fix from https://github.com/vitest-dev/vitest/issues/6992#issuecomment-2509408660 @@ -15,6 +17,15 @@ const config = defineConfig({ }, }, }, + test: { + name: packageJson.name, + dir: './src', + watch: false, + environment: 'jsdom', + coverage: { enabled: true, provider: 'istanbul', include: ['src/**/*'] }, + typecheck: { enabled: true }, + restoreMocks: true, + }, }) export default mergeConfig( From 1e7e9b83cfa589b75c5e0fd1750d42f46c2f657e Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Tue, 3 Feb 2026 20:57:17 -0500 Subject: [PATCH 2/3] fix: use hasOwnProperty.call for ES2020 compatibility Co-Authored-By: Claude Opus 4.5 --- ANALYSIS_REPORT.md | 487 ++++++++++++++++++ .../src/createPersister.ts | 6 +- 2 files changed, 490 insertions(+), 3 deletions(-) create mode 100644 ANALYSIS_REPORT.md diff --git a/ANALYSIS_REPORT.md b/ANALYSIS_REPORT.md new file mode 100644 index 0000000000..5d171cc21d --- /dev/null +++ b/ANALYSIS_REPORT.md @@ -0,0 +1,487 @@ +# TanStack Query - Comprehensive Analysis Report + +**Date:** 2026-02-03 +**Reviewers:** Architecture, Testing, Performance, Security, Design/Documentation + +--- + +## Executive Summary + +Five specialized agents reviewed the TanStack Query codebase from different perspectives. This report consolidates findings across architecture, testing, performance, security, and documentation. The project is well-engineered overall but has accumulated technical debt that warrants attention. + +--- + +## Table of Contents + +1. [Architecture Review](#1-architecture-review) +2. [Testing Review](#2-testing-review) +3. [Performance Review](#3-performance-review) +4. [Security Review](#4-security-review) +5. [Design & Documentation Review](#5-design--documentation-review) +6. [Priority Matrix](#6-priority-matrix) +7. [Recommended Actions](#7-recommended-actions) + +--- + +## 1. Architecture Review + +### Strengths + +- Clean monorepo separation with 23 packages organized by framework +- Well-defined core module (`query-core`) providing abstraction layer +- Consistent structure across framework adapters + +### Issues Found + +#### 1.1 Monolithic Types File + +**Location:** `packages/query-core/src/types.ts` +**Problem:** 1,391 lines in a single file, violating single-responsibility principle +**Recommendation:** Split by concern (query types, mutation types, observer types, etc.) + +#### 1.2 Code Duplication Across Framework Adapters + +**Affected Files:** + +- `packages/react-query/src/useBaseQuery.ts` +- `packages/vue-query/src/useBaseQuery.ts` +- `packages/solid-query/src/useBaseQuery.ts` +- `packages/svelte-query/src/createBaseQuery.svelte.ts` +- `packages/angular-query-experimental/src/create-base-query.ts` + +**Problem:** Identical patterns repeat across all frameworks: + +- Option defaulting logic +- Observer creation and subscription +- Result computation +- Cleanup/disposal logic + +**Recommendation:** Extract common abstraction like `createBaseQueryLogic()` to reduce duplication by ~40% + +#### 1.3 Tight Coupling + +**Problem:** Circular coupling between QueryClient ↔ QueryCache ↔ Query ↔ QueryObserver + +**Evidence:** + +- `queryObserver.ts` imports from query, queryClient, queryCache +- `query.ts` imports queryCache, queryClient +- `queryCache.ts` imports queryClient (line 14) + +#### 1.4 Missing Abstractions + +- No Plugin Architecture +- No Query Strategy Pattern +- No Observer pooling/reuse +- No middleware pattern for logging, metrics, analytics + +--- + +## 2. Testing Review + +### Coverage Summary + +| Package | Tests | Source Files | Coverage | +| ----------------------------- | ----- | ------------ | -------- | +| react-query | 23 | 23 | 100% | +| query-core | 17 | 23 | 74% | +| angular-query-experimental | 16 | 29 | 55% | +| vue-query | 12 | 21 | 57% | +| svelte-query | 10 | 18 | 56% | +| solid-query | 10 | 15 | 67% | +| query-devtools | 2 | 15 | **13%** | +| vue-query-devtools | 0 | 3 | **0%** | +| svelte-query-devtools | 0 | 1 | **0%** | +| react-query-next-experimental | 0 | 4 | **0%** | + +### Critical Gaps + +#### 2.1 Zero Test Coverage Packages + +- `vue-query-devtools` - 0 tests for 3 source files +- `svelte-query-devtools` - 0 tests for 1 source file +- `react-query-next-experimental` - 0 tests for 4 source files +- `query-codemods` - 0 tests + +#### 2.2 Placeholder Test + +**Location:** `packages/query-devtools/__tests__/devtools.test.tsx` + +```typescript +describe('ReactQueryDevtools', () => { + it('should be able to open and close devtools', () => { + expect(1).toBe(1) // PLACEHOLDER TEST + }) +}) +``` + +#### 2.3 Missing Edge Cases + +- Network timeouts and retry exhaustion +- Cascading error states in dependent queries +- Race conditions in query deduplication +- Storage quota exceeded scenarios +- Corrupted cache restoration +- Streaming hydration failures + +#### 2.4 Missing Test Types + +- No E2E tests +- No MSW (Mock Service Worker) for HTTP testing +- Limited browser API mocks (IndexedDB, localStorage) + +### Files Needing Tests (Priority) + +**Priority 1 (Critical):** + +- `packages/query-devtools/src/Devtools.tsx` (115KB component with NO tests) +- `packages/react-query-next-experimental/src/HydrationStreamProvider.tsx` +- `packages/vue-query-devtools/src/devtools.vue` +- `packages/svelte-query-devtools/src/index.ts` + +**Priority 2 (High):** + +- `packages/query-persist-client-core/src/persist.ts` +- `packages/query-async-storage-persister/src/` +- `packages/query-sync-storage-persister/src/` + +--- + +## 3. Performance Review + +### Issues Found + +#### 3.1 Deep Recursion in `replaceEqualDeep` + +**Location:** `packages/query-core/src/utils.ts:268-314` +**Problem:** + +- Stack overflow risk with depth > 500 +- Creates new arrays/objects for every level regardless of actual changes +- No early termination when data is identical + +**Complexity:** O(n) with high constant factor due to object creation + +#### 3.2 JSON.stringify in Hash Function + +**Location:** `packages/query-core/src/utils.ts:215-238` + +```typescript +return JSON.stringify(queryKey, (_, val) => + isPlainObject(val) + ? Object.keys(val).sort().reduce(...) // O(n log n) per object! + : val, +) +``` + +**Problem:** Called on every cache lookup, no memoization +**Complexity:** O(n log n) per lookup + +#### 3.3 N+1 Query Patterns + +**Location:** `packages/query-core/src/queryClient.ts` + +| Method | Lines | Issue | +| ------------------ | ------- | ---------------------------------- | +| `isFetching()` | 112-114 | findAll + .length | +| `getQueriesData()` | 166-174 | findAll + map | +| `removeQueries()` | 252-256 | findAll + forEach + remove | +| `resetQueries()` | 265-276 | findAll + forEach + refetchQueries | +| `refetchQueries()` | 325-336 | findAll + filter + map | + +**Complexity:** O(n\*m) where n = queries in cache + +#### 3.4 Observer Lookup Using Array.includes + +**Location:** `packages/query-core/src/query.ts:343-348` + +```typescript +addObserver(observer: QueryObserver): void { + if (!this.observers.includes(observer)) { // O(n) linear search! + this.observers.push(observer) + } +} +``` + +**Recommendation:** Use `Set` for O(1) lookups + +#### 3.5 Query Defaults Linear Search + +**Location:** `packages/query-core/src/queryClient.ts:493-509` + +```typescript +getQueryDefaults(queryKey: QueryKey) { + const defaults = [...this.#queryDefaults.values()] // O(m) spread + defaults.forEach((queryDefault) => { + if (partialMatchKey(queryKey, queryDefault.queryKey)) { // O(k) each + ... + } + }) +} +``` + +**Problem:** Called on every `defaultQueryOptions()` invocation +**Complexity:** O(m\*k) per query build + +### Performance Summary Table + +| Issue | Location | Complexity | Severity | +| ------------------------ | ---------------------- | ---------- | -------- | +| Deep recursion & memory | utils.ts:268-314 | O(n) | Critical | +| JSON hash serialization | utils.ts:215-238 | O(n log n) | High | +| N+1 query patterns | queryClient.ts:112-336 | O(n\*m) | High | +| Query defaults lookup | queryClient.ts:493-546 | O(m\*k) | High | +| Observer array search | query.ts:343-348 | O(n) | Medium | +| Object.keys called twice | utils.ts:323 | O(n) | Medium | + +--- + +## 4. Security Review + +### Vulnerabilities Found + +#### 4.1 Unsafe Deserialization (HIGH) + +**Location:** + +- `packages/query-sync-storage-persister/src/index.ts:49` +- `packages/query-async-storage-persister/src/index.ts:47` +- `packages/query-persist-client-core/src/createPersister.ts:102` + +**Problem:** Default deserialization uses `JSON.parse` without validation + +```typescript +deserialize = JSON.parse +``` + +**Risk:** Attackers who can modify storage can inject malicious data leading to: + +- Prototype pollution attacks +- Denial of service through deeply nested structures +- Data integrity violations + +**OWASP:** A08:2021 - Software and Data Integrity Failures + +#### 4.2 Potential XSS via dangerouslySetInnerHTML (HIGH) + +**Location:** `packages/react-query-next-experimental/src/HydrationStreamProvider.tsx:144-146` + +```typescript +dangerouslySetInnerHTML={{ + __html: html.join(''), +}} +``` + +**Problem:** While `htmlEscapeJsonString()` is used, this pattern is inherently risky for embedding JSON in HTML script tags. + +**OWASP:** A03:2021 - Injection + +#### 4.3 Sensitive Error Information Disclosure (MEDIUM) + +**Location:** `packages/query-persist-client-core/src/persist.ts:94-104` + +```typescript +catch (err) { + if (process.env.NODE_ENV !== 'production') { + console.error(err) // Full error objects logged + } + throw err // Re-thrown without redaction +} +``` + +**OWASP:** A01:2021 - Broken Access Control + +#### 4.4 Weak Input Validation (MEDIUM) + +**Location:** `packages/query-persist-client-core/src/createPersister.ts:130, 184, 246` + +**Problems:** + +- No length checks on query keys, storage keys, or hash values +- No validation of `prefix` parameter +- Potential key name collisions +- No validation that `queryHash` contains only safe characters + +#### 4.5 Unencrypted Sensitive Data in Storage (MEDIUM) + +**Affected:** All persistence packages + +**Problem:** Query cache data stored in localStorage/asyncStorage without encryption, exposed to: + +- Browser extensions +- Malicious JavaScript in same origin +- Physical device access + +### Security Summary Table + +| Vulnerability | Severity | Location | OWASP | +| ------------------------------- | -------- | ------------------------------- | -------- | +| Unsafe Deserialization | HIGH | createPersister.ts:102 | A08:2021 | +| XSS via dangerouslySetInnerHTML | HIGH | HydrationStreamProvider.tsx:144 | A03:2021 | +| Error Information Disclosure | MEDIUM | persist.ts:96 | A01:2021 | +| Weak Input Validation | MEDIUM | createPersister.ts:130 | A03:2021 | +| Unencrypted Sensitive Data | MEDIUM | All persistence packages | A02:2021 | +| Prototype Pollution | MEDIUM | utils.ts:267 | A08:2021 | + +--- + +## 5. Design & Documentation Review + +### Strengths + +- Extensive documentation with 322 markdown files across 5 frameworks +- Good CONTRIBUTING.md with setup instructions +- Framework-specific examples (25+) +- Clean README with proper structure + +### Issues Found + +#### 5.1 Severely Lacking Code Comments + +**Finding:** Only **13 JSDoc annotations** in entire query-core package (321+ source files) + +**Examples of undocumented code:** + +- `packages/query-core/src/queryObserver.ts` - Complex state management with minimal comments +- `packages/query-core/src/subscribable.ts` - Observer pattern implementation lacks documentation +- Private methods like `#executeFetch()` have no comments explaining retry logic + +#### 5.2 Missing Architecture Documentation + +**Problem:** No guide explaining: + +- QueryClient → QueryCache → Query → QueryObserver flow +- State machine transitions +- Observer pattern usage +- Where to start reading the code + +#### 5.3 Overly Complex Type Parameters + +**Location:** `packages/query-core/src/types.ts` + +```typescript +export class QueryObserver< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> +``` + +**Problem:** 5 type parameters with 3 different "Data" types - purpose unclear without docs + +#### 5.4 Minimal Example Documentation + +**Location:** `examples/react/basic/README.md` + +``` +# Basic + +To run this example: +- `npm install` +- `npm run dev` +``` + +**Problem:** Only 7 lines, no explanation of what the example demonstrates + +### Documentation Summary Table + +| Area | Status | Severity | +| ------------------ | -------------------- | -------- | +| README | Good | Low | +| API Docs | Extensive but bare | Medium | +| Code Comments | Critical gap | **High** | +| JSDoc Annotations | Minimal (13 total) | **High** | +| Architecture Guide | Missing | **High** | +| Examples | Minimal | Medium | +| Type Naming | Complex/undocumented | Medium | + +--- + +## 6. Priority Matrix + +### Critical (Fix Immediately) + +| Issue | Category | Location | +| -------------------------------- | -------- | ------------------------------- | +| XSS via dangerouslySetInnerHTML | Security | HydrationStreamProvider.tsx:144 | +| Zero test coverage in 4 packages | Testing | devtools, next-experimental | +| Unsafe deserialization | Security | createPersister.ts:102 | + +### High Priority + +| Issue | Category | Location | +| ---------------------------- | ------------- | ---------------------- | +| N+1 cache iterations | Performance | queryClient.ts:112-336 | +| Observer array → Set | Performance | query.ts:343-348 | +| Add JSDoc to public APIs | Documentation | All query-core exports | +| Placeholder devtools test | Testing | devtools.test.tsx | +| Input validation for storage | Security | createPersister.ts | + +### Medium Priority + +| Issue | Category | Location | +| ------------------------------- | ------------- | ----------------------- | +| Split types.ts | Architecture | query-core/src/types.ts | +| Extract shared base query logic | Architecture | Framework adapters | +| Hash function memoization | Performance | utils.ts:215-238 | +| Architecture documentation | Documentation | N/A (create new) | +| Error information redaction | Security | persist.ts | + +### Low Priority + +| Issue | Category | Location | +| ------------------------ | ------------- | ---------- | +| Observer pooling | Performance | query.ts | +| Plugin architecture | Architecture | query-core | +| Example README expansion | Documentation | examples/ | + +--- + +## 7. Recommended Actions + +### Immediate Actions + +1. **Security:** Remove or secure `dangerouslySetInnerHTML` - use safe serialization methods +2. **Security:** Add input validation for storage keys (length limits, character restrictions) +3. **Testing:** Add tests to zero-coverage packages (vue-query-devtools, svelte-query-devtools, react-query-next-experimental) + +### Short-term Actions + +1. **Performance:** Replace observer arrays with Sets for O(1) lookups +2. **Performance:** Reduce N+1 patterns in queryClient methods +3. **Documentation:** Add JSDoc comments to all public APIs in query-core +4. **Security:** Implement consistent error redaction + +### Medium-term Actions + +1. **Architecture:** Split monolithic types.ts file by concern +2. **Architecture:** Extract common useBaseQuery logic to shared utility +3. **Documentation:** Create architecture documentation explaining core concepts +4. **Performance:** Memoize hash function results + +### Long-term Actions + +1. **Architecture:** Design plugin/middleware system +2. **Testing:** Implement E2E tests with MSW for HTTP mocking +3. **Security:** Add optional encryption for sensitive persistence data +4. **Performance:** Implement observer pooling for high-volume scenarios + +--- + +## Appendix: File References + +### Most Critical Files to Address + +1. `packages/react-query-next-experimental/src/HydrationStreamProvider.tsx` - Security +2. `packages/query-persist-client-core/src/createPersister.ts` - Security +3. `packages/query-core/src/queryClient.ts` - Performance +4. `packages/query-core/src/query.ts` - Performance +5. `packages/query-core/src/types.ts` - Architecture +6. `packages/query-devtools/src/Devtools.tsx` - Testing +7. `packages/query-core/src/utils.ts` - Performance, Security + +--- + +_Report generated by automated analysis agents_ diff --git a/packages/query-persist-client-core/src/createPersister.ts b/packages/query-persist-client-core/src/createPersister.ts index a2fbb1f646..ab16acc00c 100644 --- a/packages/query-persist-client-core/src/createPersister.ts +++ b/packages/query-persist-client-core/src/createPersister.ts @@ -113,9 +113,9 @@ function validateDeserializedData(data: unknown): void { // Check for prototype pollution vectors (only own properties, not inherited) if ( - Object.hasOwn(obj, '__proto__') || - Object.hasOwn(obj, 'constructor') || - Object.hasOwn(obj, 'prototype') + Object.prototype.hasOwnProperty.call(obj, '__proto__') || + Object.prototype.hasOwnProperty.call(obj, 'constructor') || + Object.prototype.hasOwnProperty.call(obj, 'prototype') ) { throw new Error('Deserialized data contains forbidden keys') } From 3e3ea7c01cca821f778d3d75e29acf2b52680dd8 Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Tue, 3 Feb 2026 20:57:41 -0500 Subject: [PATCH 3/3] fix: add biome-ignore for empty Register interface The Register interface is intentionally empty for module augmentation. Co-Authored-By: Claude Opus 4.5 --- packages/query-core/src/types/common.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/query-core/src/types/common.ts b/packages/query-core/src/types/common.ts index afab1f3c1c..6332d715ce 100644 --- a/packages/query-core/src/types/common.ts +++ b/packages/query-core/src/types/common.ts @@ -41,6 +41,7 @@ export type NoInfer = [T][T extends any ? 0 : never] * } * ``` */ +// biome-ignore lint/suspicious/noEmptyInterface: Intentionally empty for module augmentation export interface Register { // defaultError: Error // queryMeta: Record