UNPKG

@tanstack/query-core

Version:

The framework agnostic core that powers TanStack Query

770 lines (667 loc) 21.3 kB
import { isServer, isValidTimeout, noop, replaceData, shallowEqualObjects, timeUntilStale, } from './utils' import { notifyManager } from './notifyManager' import { focusManager } from './focusManager' import { Subscribable } from './subscribable' import { canFetch } from './retryer' import type { QueryClient } from './queryClient' import type { FetchOptions, Query, QueryState } from './query' import type { DefaultError, DefaultedQueryObserverOptions, PlaceholderDataFunction, QueryKey, QueryObserverBaseResult, QueryObserverOptions, QueryObserverResult, QueryOptions, RefetchOptions, } from './types' type QueryObserverListener<TData, TError> = ( result: QueryObserverResult<TData, TError>, ) => void export interface NotifyOptions { listeners?: boolean } export interface ObserverFetchOptions extends FetchOptions { throwOnError?: boolean } export class QueryObserver< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > extends Subscribable<QueryObserverListener<TData, TError>> { options: QueryObserverOptions< TQueryFnData, TError, TData, TQueryData, TQueryKey > #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?: ReturnType<typeof setTimeout> #refetchIntervalId?: ReturnType<typeof setInterval> #currentRefetchInterval?: number | false #trackedProps: Set<keyof QueryObserverResult> = new Set() constructor( client: QueryClient, options: QueryObserverOptions< TQueryFnData, TError, TData, TQueryData, TQueryKey >, ) { super() this.#client = client this.options = options this.#selectError = null this.bindMethods() this.setOptions(options) } protected bindMethods(): void { this.refetch = this.refetch.bind(this) } protected onSubscribe(): void { if (this.listeners.size === 1) { this.#currentQuery.addObserver(this) if (shouldFetchOnMount(this.#currentQuery, this.options)) { this.#executeFetch() } this.#updateTimers() } } protected onUnsubscribe(): void { if (!this.hasListeners()) { this.destroy() } } shouldFetchOnReconnect(): boolean { return shouldFetchOn( this.#currentQuery, this.options, this.options.refetchOnReconnect, ) } shouldFetchOnWindowFocus(): boolean { return shouldFetchOn( this.#currentQuery, this.options, this.options.refetchOnWindowFocus, ) } destroy(): void { this.listeners = new Set() this.#clearStaleTimeout() this.#clearRefetchInterval() this.#currentQuery.removeObserver(this) } setOptions( options?: QueryObserverOptions< TQueryFnData, TError, TData, TQueryData, TQueryKey >, notifyOptions?: NotifyOptions, ): void { const prevOptions = this.options const prevQuery = this.#currentQuery this.options = this.#client.defaultQueryOptions(options) if (!shallowEqualObjects(prevOptions, this.options)) { this.#client.getQueryCache().notify({ type: 'observerOptionsUpdated', query: this.#currentQuery, observer: this, }) } if ( typeof this.options.enabled !== 'undefined' && typeof this.options.enabled !== 'boolean' ) { throw new Error('Expected enabled to be a boolean') } // Keep previous query key if the user does not supply one if (!this.options.queryKey) { this.options.queryKey = prevOptions.queryKey } this.#updateQuery() const mounted = this.hasListeners() // Fetch if there are subscribers if ( mounted && shouldFetchOptionally( this.#currentQuery, prevQuery, this.options, prevOptions, ) ) { this.#executeFetch() } // Update result this.updateResult(notifyOptions) // Update stale interval if needed if ( mounted && (this.#currentQuery !== prevQuery || this.options.enabled !== prevOptions.enabled || this.options.staleTime !== prevOptions.staleTime) ) { this.#updateStaleTimeout() } const nextRefetchInterval = this.#computeRefetchInterval() // Update refetch interval if needed if ( mounted && (this.#currentQuery !== prevQuery || this.options.enabled !== prevOptions.enabled || nextRefetchInterval !== this.#currentRefetchInterval) ) { this.#updateRefetchInterval(nextRefetchInterval) } } 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 (shouldAssignObserverCurrentProperties(this, 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 everytime // 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 } getCurrentResult(): QueryObserverResult<TData, TError> { return this.#currentResult } trackResult( result: QueryObserverResult<TData, TError>, ): QueryObserverResult<TData, TError> { const trackedResult = {} as QueryObserverResult<TData, TError> Object.keys(result).forEach((key) => { Object.defineProperty(trackedResult, key, { configurable: false, enumerable: true, get: () => { this.#trackedProps.add(key as keyof QueryObserverResult) return result[key as keyof QueryObserverResult] }, }) }) return trackedResult } getCurrentQuery(): Query<TQueryFnData, TError, TQueryData, TQueryKey> { return this.#currentQuery } refetch({ ...options }: RefetchOptions = {}): Promise< QueryObserverResult<TData, TError> > { return this.fetch({ ...options, }) } 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) query.isFetchingOptimistic = true return query.fetch().then(() => this.createResult(query, defaultedOptions)) } protected fetch( fetchOptions: ObserverFetchOptions, ): Promise<QueryObserverResult<TData, TError>> { return this.#executeFetch({ ...fetchOptions, cancelRefetch: fetchOptions.cancelRefetch ?? true, }).then(() => { this.updateResult() return this.#currentResult }) } #executeFetch( fetchOptions?: ObserverFetchOptions, ): 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 as QueryOptions<TQueryFnData, TError, TQueryData, TQueryKey>, fetchOptions, ) if (!fetchOptions?.throwOnError) { promise = promise.catch(noop) } return promise } #updateStaleTimeout(): void { this.#clearStaleTimeout() if ( isServer || this.#currentResult.isStale || !isValidTimeout(this.options.staleTime) ) { return } const time = timeUntilStale( this.#currentResult.dataUpdatedAt, this.options.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 = setTimeout(() => { if (!this.#currentResult.isStale) { this.updateResult() } }, timeout) } #computeRefetchInterval() { return ( (typeof this.options.refetchInterval === 'function' ? this.options.refetchInterval(this.#currentQuery) : this.options.refetchInterval) ?? false ) } #updateRefetchInterval(nextInterval: number | false): void { this.#clearRefetchInterval() this.#currentRefetchInterval = nextInterval if ( isServer || this.options.enabled === false || !isValidTimeout(this.#currentRefetchInterval) || this.#currentRefetchInterval === 0 ) { return } this.#refetchIntervalId = setInterval(() => { if ( this.options.refetchIntervalInBackground || focusManager.isFocused() ) { this.#executeFetch() } }, this.#currentRefetchInterval) } #updateTimers(): void { this.#updateStaleTimeout() this.#updateRefetchInterval(this.#computeRefetchInterval()) } #clearStaleTimeout(): void { if (this.#staleTimeoutId) { clearTimeout(this.#staleTimeoutId) this.#staleTimeoutId = undefined } } #clearRefetchInterval(): void { if (this.#refetchIntervalId) { clearInterval(this.#refetchIntervalId) this.#refetchIntervalId = undefined } } 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 { error, errorUpdatedAt, fetchStatus, status } = 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) { fetchStatus = canFetch(query.options.networkMode) ? 'fetching' : 'paused' if (!state.dataUpdatedAt) { status = 'pending' } } if (options._optimisticResults === 'isRestoring') { fetchStatus = 'idle' } } // Select data if needed if (options.select && typeof state.data !== 'undefined') { // Memoize select result if ( prevResult && state.data === prevResultState?.data && options.select === this.#selectFn ) { data = this.#selectResult } else { try { this.#selectFn = options.select data = options.select(state.data) data = replaceData(prevResult?.data, data, options) this.#selectResult = data this.#selectError = null } catch (selectError) { this.#selectError = selectError as TError } } } // Use query data else { data = state.data as unknown as TData } // Show placeholder data if needed if ( typeof options.placeholderData !== 'undefined' && typeof data === 'undefined' && status === 'pending' ) { let placeholderData // Memoize placeholder data if ( prevResult?.isPlaceholderData && options.placeholderData === prevResultOptions?.placeholderData ) { placeholderData = prevResult.data } else { placeholderData = typeof options.placeholderData === 'function' ? ( options.placeholderData as unknown as PlaceholderDataFunction<TQueryData> )( this.#lastQueryWithDefinedData?.state.data, this.#lastQueryWithDefinedData as any, ) : options.placeholderData if (options.select && typeof placeholderData !== 'undefined') { try { placeholderData = options.select(placeholderData) this.#selectError = null } catch (selectError) { this.#selectError = selectError as TError } } } if (typeof placeholderData !== 'undefined') { status = 'success' data = replaceData( prevResult?.data, placeholderData as unknown, options, ) as TData isPlaceholderData = true } } if (this.#selectError) { error = this.#selectError as any data = this.#selectResult errorUpdatedAt = Date.now() status = 'error' } const isFetching = fetchStatus === 'fetching' const isPending = status === 'pending' const isError = status === 'error' const isLoading = isPending && isFetching const result: QueryObserverBaseResult<TData, TError> = { status, fetchStatus, isPending, isSuccess: status === 'success', isError, isInitialLoading: isLoading, isLoading, data, dataUpdatedAt: state.dataUpdatedAt, error, errorUpdatedAt, failureCount: state.fetchFailureCount, failureReason: state.fetchFailureReason, errorUpdateCount: state.errorUpdateCount, isFetched: state.dataUpdateCount > 0 || state.errorUpdateCount > 0, isFetchedAfterMount: state.dataUpdateCount > queryInitialState.dataUpdateCount || state.errorUpdateCount > queryInitialState.errorUpdateCount, isFetching, isRefetching: isFetching && !isPending, isLoadingError: isError && state.dataUpdatedAt === 0, isPaused: fetchStatus === 'paused', isPlaceholderData, isRefetchError: isError && state.dataUpdatedAt !== 0, isStale: isStale(query, options), refetch: this.refetch, } return result as QueryObserverResult<TData, TError> } updateResult(notifyOptions?: NotifyOptions): 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 // Only notify and update result if something has changed if (shallowEqualObjects(nextResult, prevResult)) { return } if (this.#currentResultState.data !== undefined) { this.#lastQueryWithDefinedData = this.#currentQuery } this.#currentResult = nextResult // Determine which callbacks to trigger const defaultNotifyOptions: NotifyOptions = {} 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) }) } if (notifyOptions?.listeners !== false && shouldNotifyListeners()) { defaultNotifyOptions.listeners = true } this.#notify({ ...defaultNotifyOptions, ...notifyOptions }) } #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) } } onQueryUpdate(): void { this.updateResult() if (this.hasListeners()) { this.#updateTimers() } } #notify(notifyOptions: NotifyOptions): void { notifyManager.batch(() => { // First, trigger the listeners if (notifyOptions.listeners) { this.listeners.forEach((listener) => { listener(this.#currentResult) }) } // Then the cache listeners this.#client.getQueryCache().notify({ query: this.#currentQuery, type: 'observerResultsUpdated', }) }) } } function shouldLoadOnMount( query: Query<any, any, any, any>, options: QueryObserverOptions<any, any, any, any>, ): boolean { return ( options.enabled !== false && !query.state.dataUpdatedAt && !(query.state.status === 'error' && options.retryOnMount === false) ) } function shouldFetchOnMount( query: Query<any, any, any, any>, options: QueryObserverOptions<any, any, any, any, any>, ): boolean { return ( shouldLoadOnMount(query, options) || (query.state.dataUpdatedAt > 0 && shouldFetchOn(query, options, options.refetchOnMount)) ) } 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 (options.enabled !== false) { const value = typeof field === 'function' ? field(query) : field return value === 'always' || (value !== false && isStale(query, options)) } return false } 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 ( options.enabled !== false && (query !== prevQuery || prevOptions.enabled === false) && (!options.suspense || query.state.status !== 'error') && isStale(query, options) ) } function isStale( query: Query<any, any, any, any>, options: QueryObserverOptions<any, any, any, any, any>, ): boolean { return query.isStaleByTime(options.staleTime) } // this function would decide if we will update the observer's 'current' // properties after an optimistic reading via getOptimisticResult function shouldAssignObserverCurrentProperties< TQueryFnData = unknown, TError = unknown, TData = TQueryFnData, TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( observer: QueryObserver<TQueryFnData, TError, TData, TQueryData, TQueryKey>, optimisticResult: QueryObserverResult<TData, TError>, ) { // if the newly created result isn't what the observer is holding as current, // then we'll need to update the properties as well if (!shallowEqualObjects(observer.getCurrentResult(), optimisticResult)) { return true } // basically, just keep previous properties if nothing changed return false }