UNPKG

@tanstack/query-core

Version:

The framework agnostic core that powers TanStack Query

975 lines (870 loc) • 30.7 kB
import { focusManager } from './focusManager' import { isServer as isServerEnvironment } from './environmentManager' import { notifyManager } from './notifyManager' import { fetchState } from './query' import { Subscribable } from './subscribable' import { isValidTimeout, noop, replaceData, resolveQueryValue, shallowEqualObjects, timeUntilStale, } from './utils' import { timeoutManager } from './timeoutManager' import type { ManagedTimerId } from './timeoutManager' import type { FetchOptions, Query, QueryState } from './query' import type { QueryClient } from './queryClient' import type { DefaultError, DefaultedQueryObserverOptions, PlaceholderDataFunction, QueryKey, QueryObserverBaseResult, QueryObserverOptions, QueryObserverResult, RefetchOptions, } from './types' type QueryObserverListener<TData, TError> = ( result: QueryObserverResult<TData, TError>, ) => void interface ObserverFetchOptions extends FetchOptions { throwOnError?: boolean } /** * A `QueryObserver` watches a single query in the `QueryCache` and computes a * `QueryObserverResult` from its state, recomputing and notifying subscribers * whenever the underlying query (or the observer's options) changes. It is * the primitive that framework adapters (e.g. `useQuery`) build their hooks * on top of, but it can also be used directly to observe and switch between * queries outside of any framework. * @example * ```ts * const observer = new QueryObserver(queryClient, { * queryKey: ['posts'], * queryFn: fetchPosts, * }) * * const unsubscribe = observer.subscribe((result) => { * console.log(result.data) * }) * ``` */ export class QueryObserver< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > extends Subscribable<QueryObserverListener<TData, TError>> { #client: QueryClient #currentQuery: Query<TQueryFnData, TError, TQueryData, TQueryKey> = undefined! #currentQueryInitialState: QueryState<TQueryData, TError> = undefined! #currentResult: QueryObserverResult<TData, TError> = undefined! #currentResultState?: QueryState<TQueryData, TError> #currentResultOptions?: QueryObserverOptions< TQueryFnData, TError, TData, TQueryData, TQueryKey > #selectError: TError | null #selectFn?: (data: TQueryData) => TData #selectResult?: TData // This property keeps track of the last query with defined data. // It will be used to pass the previous data and query to the placeholder function between renders. #lastQueryWithDefinedData?: Query<TQueryFnData, TError, TQueryData, TQueryKey> #staleTimeoutId?: ManagedTimerId #refetchIntervalId?: ManagedTimerId #currentRefetchInterval?: number | false #trackedProps = new Set<keyof QueryObserverResult>() constructor( client: QueryClient, public options: QueryObserverOptions< TQueryFnData, TError, TData, TQueryData, TQueryKey >, ) { super() this.#client = client this.#selectError = null this.bindMethods() this.setOptions(options) } /** * Binds the methods of the result (`refetch`) to the observer, so they keep working when destructured * from it. Subclasses override it to bind their own result methods as well. */ protected bindMethods(): void { this.refetch = this.refetch.bind(this) } protected override onSubscribe(): void { if (this.listeners.size === 1) { this.#currentQuery.addObserver(this) if (shouldFetchOnMount(this.#currentQuery, this.options)) { this.#executeFetch() } else { this.updateResult() } this.#updateTimers() } } protected override onUnsubscribe(): void { if (!this.hasListeners()) { this.destroy() } } /** * Returns whether the observed query is currently stale and configured * (via the `refetchOnReconnect` option) to refetch when the network * reconnects. * @returns `true` if the observer should refetch the query on reconnect. */ shouldFetchOnReconnect(): boolean { return shouldFetchOn( this.#currentQuery, this.options, this.options.refetchOnReconnect, ) } /** * Returns whether the observed query is currently stale and configured * (via the `refetchOnWindowFocus` option) to refetch when the window * regains focus. * @returns `true` if the observer should refetch the query on window focus. */ shouldFetchOnWindowFocus(): boolean { return shouldFetchOn( this.#currentQuery, this.options, this.options.refetchOnWindowFocus, ) } /** * Stops observing the current query: clears all listeners, cancels the * stale and refetch-interval timers, and removes this observer from the * query it was observing. */ destroy(): void { this.listeners = new Set() this.#clearStaleTimeout() this.#clearRefetchInterval() this.#currentQuery.removeObserver(this) } /** * Updates the observer's options. This will re-resolve the query being * observed (switching to a different query if the `queryKey` changed), * trigger a fetch if the new options require one and the observer has * subscribers, recompute the current result, and reschedule the stale and * refetch-interval timers as needed. * @param options - The new observer options. They are defaulted with {@link * QueryClient#defaultQueryOptions} before being applied. * @throws {Error} If `enabled` is neither a boolean nor a function. * @example * ```ts * observer.setOptions({ queryKey: ['posts', 1], queryFn: () => fetchPost(1) }) * // later: switch to a different query, reusing the same observer * observer.setOptions({ queryKey: ['posts', 2], queryFn: () => fetchPost(2) }) * ``` */ setOptions( options: QueryObserverOptions< TQueryFnData, TError, TData, TQueryData, TQueryKey >, ): void { const prevOptions = this.options const prevQuery = this.#currentQuery this.options = this.#client.defaultQueryOptions(options) if ( this.options.enabled !== undefined && typeof this.options.enabled !== 'boolean' && typeof this.options.enabled !== 'function' && typeof resolveQueryValue(this.options.enabled, this.#currentQuery) !== 'boolean' ) { throw new Error( 'Expected enabled to be a boolean or a callback that returns a boolean', ) } this.#updateQuery() this.#currentQuery.setOptions(this.options) if ( prevOptions._defaulted && !shallowEqualObjects(this.options, prevOptions) ) { this.#client.getQueryCache().notify({ type: 'observerOptionsUpdated', query: this.#currentQuery, observer: this, }) } const mounted = this.hasListeners() // Fetch if there are subscribers if ( mounted && shouldFetchOptionally( this.#currentQuery, prevQuery, this.options, prevOptions, ) ) { this.#executeFetch() } // Update result this.updateResult() // Update stale interval if needed if ( mounted && (this.#currentQuery !== prevQuery || resolveQueryValue(this.options.enabled, this.#currentQuery) !== resolveQueryValue(prevOptions.enabled, this.#currentQuery) || resolveQueryValue(this.options.staleTime, this.#currentQuery) !== resolveQueryValue(prevOptions.staleTime, this.#currentQuery)) ) { this.#updateStaleTimeout() } const nextRefetchInterval = this.#computeRefetchInterval() // Update refetch interval if needed if ( mounted && (this.#currentQuery !== prevQuery || resolveQueryValue(this.options.enabled, this.#currentQuery) !== resolveQueryValue(prevOptions.enabled, this.#currentQuery) || nextRefetchInterval !== this.#currentRefetchInterval) ) { this.#updateRefetchInterval(nextRefetchInterval) } } /** * Computes the result the observer would produce for the given (already-defaulted) options * right now, building the underlying `Query` if it doesn't exist yet, without waiting for a * subscription callback. Called by framework adapters on every render (e.g. `useQuery`) so the * returned value is available synchronously, ahead of `setOptions` triggering an actual fetch. * @param options - The defaulted observer options to compute the result for. * @returns The result for the given options. */ getOptimisticResult( options: DefaultedQueryObserverOptions< TQueryFnData, TError, TData, TQueryData, TQueryKey >, ): QueryObserverResult<TData, TError> { const query = this.#client.getQueryCache().build(this.#client, options) const result = this.createResult(query, options) if (!shallowEqualObjects(this.getCurrentResult(), result)) { // this assigns the optimistic result to the current Observer // because if the query function changes, useQuery will be performing // an effect where it would fetch again. // When the fetch finishes, we perform a deep data cloning in order // to reuse objects references. This deep data clone is performed against // the `observer.currentResult.data` property // When QueryKey changes, we refresh the query and get new `optimistic` // result, while we leave the `observer.currentResult`, so when new data // arrives, it finds the old `observer.currentResult` which is related // to the old QueryKey. Which means that currentResult and selectData are // out of sync already. // To solve this, we move the cursor of the currentResult every time // an observer reads an optimistic value. // When keeping the previous data, the result doesn't change until new // data arrives. this.#currentResult = result this.#currentResultOptions = this.options this.#currentResultState = this.#currentQuery.state } return result } /** * Returns the most recently computed `QueryObserverResult` for the * observed query. This is a point-in-time read; to be notified of updates * as they happen, subscribe to the observer instead (its inherited * `subscribe` method). * @returns The observer's latest result. * @example * ```ts * const result = observer.getCurrentResult() * console.log(result.status, result.data) * ``` */ getCurrentResult(): QueryObserverResult<TData, TError> { return this.#currentResult } /** * Wraps a `QueryObserverResult` in a `Proxy` that records which properties are read, via * {@link QueryObserver#trackProp} (and an optional `onPropTracked` callback). Used by framework * adapters when `notifyOnChangeProps` is not set, to implement its default "only re-render on * properties you actually read" behavior. * @param result - The result to wrap. * @param onPropTracked - Called with the name of each property that is read. * @returns A proxy of `result` that tracks property reads. */ trackResult( result: QueryObserverResult<TData, TError>, onPropTracked?: (key: keyof QueryObserverResult) => void, ): QueryObserverResult<TData, TError> { return new Proxy(result, { get: (target, key) => { this.trackProp(key as keyof QueryObserverResult) onPropTracked?.(key as keyof QueryObserverResult) return Reflect.get(target, key) }, }) } /** * Records that the given `QueryObserverResult` property was read, so a subsequent update only * notifies this observer if a tracked property actually changed. Normally called indirectly via * {@link QueryObserver#trackResult}'s proxy; exposed directly for adapters that track property * access themselves (e.g. through their own reactivity system) instead of via the proxy. * @param key - The name of the property that was read. */ trackProp(key: keyof QueryObserverResult) { this.#trackedProps.add(key) } /** * Returns the `Query` instance this observer is currently observing. * @returns The observed query. */ getCurrentQuery(): Query<TQueryFnData, TError, TQueryData, TQueryKey> { return this.#currentQuery } /** * Refetches the observed query and returns a promise that resolves with * the resulting `QueryObserverResult`. * @param options - Set `cancelRefetch` to `false` to keep a running fetch instead of cancelling * it, and `throwOnError` to `true` to reject when the refetch fails. * @returns A promise that resolves with the result after the refetch. * @example * ```ts * const result = await observer.refetch({ cancelRefetch: false }) * console.log(result.data) * ``` */ refetch({ ...options }: RefetchOptions = {}): Promise< QueryObserverResult<TData, TError> > { return this.fetch({ ...options, }) } /** * Fetches a query defined by the given options without affecting this * observer's own tracked query or result, and returns a promise that * resolves with the `QueryObserverResult` for that fetch. This is useful * for prefetching data that another observer (e.g. a query about to be * navigated to) will need, ahead of time. * @param options - The observer options of the query to fetch. * @returns A promise that resolves with the result for the fetched query. * @example * ```ts * const result = await observer.fetchOptimistic({ * queryKey: ['posts', 2], * queryFn: () => fetchPost(2), * }) * console.log(result.data) * ``` */ fetchOptimistic( options: QueryObserverOptions< TQueryFnData, TError, TData, TQueryData, TQueryKey >, ): Promise<QueryObserverResult<TData, TError>> { const defaultedOptions = this.#client.defaultQueryOptions(options) const query = this.#client .getQueryCache() .build(this.#client, defaultedOptions) let unsubscribe = () => {} let resolveEarly: | ((result: QueryObserverResult<TData, TError>) => void) | undefined const cachePromise = new Promise<QueryObserverResult<TData, TError>>( (resolve) => { resolveEarly = resolve unsubscribe = this.#client.getQueryCache().subscribe((event) => { if ( event.type === 'updated' && event.query.queryHash === query.queryHash && query.state.data !== undefined ) { unsubscribe() resolve(this.createResult(query, defaultedOptions)) } }) }, ) return Promise.race([ query .fetch(defaultedOptions) .then(() => { const result = this.createResult(query, defaultedOptions) // Settle the subscriber promise so both branches always settle. // This value is ignored by Promise.race since the fetch branch already won. resolveEarly?.(result) return result }) .finally(() => { unsubscribe() }), cachePromise, ]) } /** * Fetches the observed query and updates the result once the fetch settles. Used by `refetch` and, * in `InfiniteQueryObserver`, to fetch more pages. * @param fetchOptions - Options for this fetch. `cancelRefetch` defaults to `true`. * @returns A promise that resolves with the updated result. */ protected fetch( fetchOptions: ObserverFetchOptions, ): Promise<QueryObserverResult<TData, TError>> { return this.#executeFetch({ ...fetchOptions, cancelRefetch: fetchOptions.cancelRefetch ?? true, }).then(() => { this.updateResult() return this.#currentResult }) } #executeFetch( fetchOptions?: Omit<ObserverFetchOptions, 'initialPromise'>, ): Promise<TQueryData | undefined> { // Make sure we reference the latest query as the current one might have been removed this.#updateQuery() // Fetch let promise: Promise<TQueryData | undefined> = this.#currentQuery.fetch( this.options, fetchOptions, ) if (!fetchOptions?.throwOnError) { promise = promise.catch(noop) } return promise } #shouldScheduleTimer(timeout: unknown): timeout is number { return ( !isServerEnvironment() && resolveQueryValue(this.options.enabled, this.#currentQuery) !== false && isValidTimeout(timeout) ) } #updateStaleTimeout(): void { this.#clearStaleTimeout() const staleTime = resolveQueryValue( this.options.staleTime, this.#currentQuery, ) if (this.#currentResult.isStale || !this.#shouldScheduleTimer(staleTime)) { return } const time = timeUntilStale(this.#currentResult.dataUpdatedAt, staleTime) // The timeout is sometimes triggered 1 ms before the stale time expiration. // To mitigate this issue we always add 1 ms to the timeout. const timeout = time + 1 this.#staleTimeoutId = timeoutManager.setTimeout(() => { if (!this.#currentResult.isStale) { this.updateResult() } }, timeout) } #computeRefetchInterval() { return ( resolveQueryValue(this.options.refetchInterval, this.#currentQuery) ?? false ) } #updateRefetchInterval(nextInterval: number | false): void { this.#clearRefetchInterval() this.#currentRefetchInterval = nextInterval if ( this.#currentRefetchInterval === 0 || !this.#shouldScheduleTimer(this.#currentRefetchInterval) ) { return } this.#refetchIntervalId = timeoutManager.setInterval(() => { if ( this.options.refetchIntervalInBackground || focusManager.isFocused() ) { this.#executeFetch() } }, this.#currentRefetchInterval) } #updateTimers(): void { this.#updateStaleTimeout() this.#updateRefetchInterval(this.#computeRefetchInterval()) } #clearStaleTimeout(): void { if (this.#staleTimeoutId !== undefined) { timeoutManager.clearTimeout(this.#staleTimeoutId) this.#staleTimeoutId = undefined } } #clearRefetchInterval(): void { if (this.#refetchIntervalId !== undefined) { timeoutManager.clearInterval(this.#refetchIntervalId) this.#refetchIntervalId = undefined } } /** * Computes the result for a query and options from the query's state, applying `select`, * `placeholderData`, and the derived flags. Subclasses override it to add their own fields. * @param query - The query to compute the result for. * @param options - The observer options to compute the result with. * @returns The computed result. */ protected createResult( query: Query<TQueryFnData, TError, TQueryData, TQueryKey>, options: QueryObserverOptions< TQueryFnData, TError, TData, TQueryData, TQueryKey >, ): QueryObserverResult<TData, TError> { const prevQuery = this.#currentQuery const prevOptions = this.options const prevResult = this.#currentResult as | QueryObserverResult<TData, TError> | undefined const prevResultState = this.#currentResultState const prevResultOptions = this.#currentResultOptions const queryChange = query !== prevQuery const queryInitialState = queryChange ? query.state : this.#currentQueryInitialState const { state } = query let newState = { ...state } let isPlaceholderData = false let data: TData | undefined // Optimistically set result in fetching state if needed if (options._optimisticResults) { const mounted = this.hasListeners() const fetchOnMount = !mounted && shouldFetchOnMount(query, options) const fetchOptionally = mounted && shouldFetchOptionally(query, prevQuery, options, prevOptions) if (fetchOnMount || fetchOptionally) { newState = { ...newState, ...fetchState(state.data, query.options), } } if (options._optimisticResults === 'isRestoring') { newState.fetchStatus = 'idle' } } let { error, errorUpdatedAt, status } = newState // Per default, use query data data = newState.data as unknown as TData let skipSelect = false // use placeholderData if needed if ( options.placeholderData !== undefined && data === undefined && status === 'pending' ) { let placeholderData // Memoize placeholder data if ( prevResult?.isPlaceholderData && options.placeholderData === prevResultOptions?.placeholderData ) { placeholderData = prevResult.data // we have to skip select when reading this memoization // because prevResult.data is already "selected" skipSelect = true } else { // compute placeholderData placeholderData = typeof options.placeholderData === 'function' ? ( options.placeholderData as unknown as PlaceholderDataFunction<TQueryData> )( this.#lastQueryWithDefinedData?.state.data, this.#lastQueryWithDefinedData as any, ) : options.placeholderData } if (placeholderData !== undefined) { status = 'success' data = replaceData( prevResult?.data, placeholderData as unknown, options, ) as TData isPlaceholderData = true } } // Select data if needed // this also runs placeholderData through the select function if (options.select && data !== undefined && !skipSelect) { // Memoize select result if ( prevResult && data === prevResultState?.data && options.select === this.#selectFn ) { data = this.#selectResult } else { try { this.#selectFn = options.select data = options.select(data as any) data = replaceData(prevResult?.data, data, options) this.#selectResult = data this.#selectError = null } catch (selectError) { this.#selectError = selectError as TError } } } else if (data === undefined) { // a stored select error belongs to previously selected data; once that // data is gone (query switch or reset), it must not leak into this result this.#selectError = null } if (this.#selectError) { error = this.#selectError data = this.#selectResult errorUpdatedAt = Date.now() status = 'error' isPlaceholderData = false } const isFetching = newState.fetchStatus === 'fetching' const isPending = status === 'pending' const isError = status === 'error' const isLoading = isPending && isFetching const hasData = data !== undefined const result: QueryObserverBaseResult<TData, TError> = { status, fetchStatus: newState.fetchStatus, isPending, isSuccess: status === 'success', isError, isInitialLoading: isLoading, isLoading, data, dataUpdatedAt: newState.dataUpdatedAt, error, errorUpdatedAt, failureCount: newState.fetchFailureCount, failureReason: newState.fetchFailureReason, errorUpdateCount: newState.errorUpdateCount, isFetched: query.isFetched(), isFetchedAfterMount: newState.dataUpdateCount > queryInitialState.dataUpdateCount || newState.errorUpdateCount > queryInitialState.errorUpdateCount, isFetching, isRefetching: isFetching && !isPending, isLoadingError: isError && !hasData, isPaused: newState.fetchStatus === 'paused', isPlaceholderData, isRefetchError: isError && hasData, isStale: isStale(query, options), refetch: this.refetch, isEnabled: resolveQueryValue(options.enabled, query) !== false, } const nextResult = result as QueryObserverResult<TData, TError> return nextResult } /** * Recomputes and stores the current result from the current query/options, notifying listeners * if it changed. Framework adapters call this right after subscribing to make sure no query * update was missed in the gap between creating the observer and subscribing to it. */ updateResult(): void { const prevResult = this.#currentResult as | QueryObserverResult<TData, TError> | undefined const nextResult = this.createResult(this.#currentQuery, this.options) this.#currentResultState = this.#currentQuery.state this.#currentResultOptions = this.options if (this.#currentResultState.data !== undefined) { this.#lastQueryWithDefinedData = this.#currentQuery } // Only notify and update result if something has changed if (shallowEqualObjects(nextResult, prevResult)) { return } this.#currentResult = nextResult const shouldNotifyListeners = (): boolean => { if (!prevResult) { return true } const { notifyOnChangeProps } = this.options const notifyOnChangePropsValue = typeof notifyOnChangeProps === 'function' ? notifyOnChangeProps() : notifyOnChangeProps if ( notifyOnChangePropsValue === 'all' || (!notifyOnChangePropsValue && !this.#trackedProps.size) ) { return true } const includedProps = new Set( notifyOnChangePropsValue ?? this.#trackedProps, ) if (this.options.throwOnError) { includedProps.add('error') } return Object.keys(this.#currentResult).some((key) => { const typedKey = key as keyof QueryObserverResult const changed = this.#currentResult[typedKey] !== prevResult[typedKey] return changed && includedProps.has(typedKey) }) } const notifyListeners = shouldNotifyListeners() notifyManager.batch(() => { // First, trigger the listeners if (notifyListeners) { this.listeners.forEach((listener) => { listener(this.#currentResult) }) } // Then the cache listeners this.#client.getQueryCache().notify({ query: this.#currentQuery, type: 'observerResultsUpdated', }) }) } #updateQuery(): void { const query = this.#client.getQueryCache().build(this.#client, this.options) if (query === this.#currentQuery) { return } const prevQuery = this.#currentQuery as | Query<TQueryFnData, TError, TQueryData, TQueryKey> | undefined this.#currentQuery = query this.#currentQueryInitialState = query.state if (this.hasListeners()) { prevQuery?.removeObserver(this) query.addObserver(this) } } /** * Updates the observer's result when the observed query changes, and reschedules its timers if it * has listeners. * @internal */ onQueryUpdate(): void { this.updateResult() if (this.hasListeners()) { this.#updateTimers() } } } /** * Checks whether a query has no data yet and should do its initial load when an observer mounts. * @param query - The query to check. * @param options - The observer options. * @returns `true` if the query is enabled, has no data, and isn't in an error state with * `retryOnMount: false`. */ function shouldLoadOnMount( query: Query<any, any, any, any>, options: QueryObserverOptions<any, any, any, any>, ): boolean { return ( resolveQueryValue(options.enabled, query) !== false && query.state.data === undefined && !( query.state.status === 'error' && resolveQueryValue(options.retryOnMount, query) === false ) ) } /** * Checks whether a query should fetch when an observer mounts: either for its initial load, or * because it has data and `refetchOnMount` asks for a refetch. * @param query - The query to check. * @param options - The observer options. * @returns `true` if the query should fetch on mount. */ function shouldFetchOnMount( query: Query<any, any, any, any>, options: QueryObserverOptions<any, any, any, any, any>, ): boolean { return ( shouldLoadOnMount(query, options) || (query.state.data !== undefined && shouldFetchOn(query, options, options.refetchOnMount)) ) } /** * Checks whether a query should fetch for a `refetchOnMount`, `refetchOnWindowFocus`, or * `refetchOnReconnect` option. * @param query - The query to check. * @param options - The observer options. * @param field - The value of the option to check. * @returns `true` if the query is enabled, not static, and the option is `'always'`, or is not * `false` and the query is stale. */ function shouldFetchOn( query: Query<any, any, any, any>, options: QueryObserverOptions<any, any, any, any, any>, field: (typeof options)['refetchOnMount'] & (typeof options)['refetchOnWindowFocus'] & (typeof options)['refetchOnReconnect'], ) { if ( resolveQueryValue(options.enabled, query) !== false && resolveQueryValue(options.staleTime, query) !== 'static' ) { const value = resolveQueryValue(field, query) return value === 'always' || (value !== false && isStale(query, options)) } return false } /** * Checks whether a query should fetch after the observer's options change. * @param query - The query observed with the new options. * @param prevQuery - The query observed with the previous options. * @param options - The new observer options. * @param prevOptions - The previous observer options. * @returns `true` if the query changed or was disabled before, it isn't a suspense query in an * error state, and it is stale. */ function shouldFetchOptionally( query: Query<any, any, any, any>, prevQuery: Query<any, any, any, any>, options: QueryObserverOptions<any, any, any, any, any>, prevOptions: QueryObserverOptions<any, any, any, any, any>, ): boolean { return ( (query !== prevQuery || resolveQueryValue(prevOptions.enabled, query) === false) && (!options.suspense || query.state.status !== 'error') && isStale(query, options) ) } /** * Checks whether a query is stale for the given observer options. * @param query - The query to check. * @param options - The observer options, whose `enabled` and `staleTime` are used. * @returns `true` if the query is enabled and its data is stale for `staleTime`. */ function isStale( query: Query<any, any, any, any>, options: QueryObserverOptions<any, any, any, any, any>, ): boolean { return ( resolveQueryValue(options.enabled, query) !== false && query.isStaleByTime(resolveQueryValue(options.staleTime, query)) ) }