From 5c3b679b66e3ca546b7660f8b56228ae4d6277d5 Mon Sep 17 00:00:00 2001 From: Niek Date: Tue, 9 Feb 2021 14:12:40 +0100 Subject: [PATCH] feat: add support for concurrent mode --- .../pages/guides/placeholder-query-data.md | 9 +- src/core/infiniteQueryObserver.ts | 13 +- src/core/mutationObserver.ts | 6 +- src/core/queriesObserver.ts | 98 ++-- src/core/query.ts | 7 +- src/core/queryCache.ts | 3 +- src/core/queryClient.ts | 12 +- src/core/queryObserver.ts | 486 ++++++++++-------- src/core/tests/queriesObserver.test.tsx | 112 +++- src/core/tests/queryObserver.test.tsx | 60 +-- src/core/types.ts | 8 +- src/core/utils.ts | 11 - src/hydration/tests/hydration.test.tsx | 2 +- .../tests/QueryResetErrorBoundary.test.tsx | 126 +++++ src/react/tests/suspense.test.tsx | 2 +- src/react/tests/useIsFetching.test.tsx | 2 +- src/react/tests/useQueries.test.tsx | 18 +- src/react/tests/useQuery.test.tsx | 229 ++++++--- src/react/useBaseQuery.ts | 93 ++-- src/react/useIsFetching.ts | 36 +- src/react/useIsMounted.ts | 17 - src/react/useMutation.ts | 72 ++- src/react/useQueries.ts | 70 +-- 23 files changed, 938 insertions(+), 554 deletions(-) delete mode 100644 src/react/useIsMounted.ts diff --git a/docs/src/pages/guides/placeholder-query-data.md b/docs/src/pages/guides/placeholder-query-data.md index 8f1c7fc4c1..32af6ed92c 100644 --- a/docs/src/pages/guides/placeholder-query-data.md +++ b/docs/src/pages/guides/placeholder-query-data.md @@ -28,15 +28,12 @@ function Todos() { ### Placeholder Data as a Function -If the process for accessing a query's placeholder data is intensive or just not something you want to perform on every render, you can pass a function as the `placeholderData` value. This function will be executed only once when the query is placeholderized, saving you precious memory and/or CPU: +If the process for accessing a query's placeholder data is intensive or just not something you want to perform on every render, you can memoize the value or pass a memoized function as the `placeholderData` value: ```js function Todos() { - const result = useQuery('todos', () => fetch('/todos'), { - placeholderData: () => { - return generateFakeTodos() - }, - }) + const placeholderData = useMemo(() => generateFakeTodos(), []) + const result = useQuery('todos', () => fetch('/todos'), { placeholderData }) } ``` diff --git a/src/core/infiniteQueryObserver.ts b/src/core/infiniteQueryObserver.ts index 959572a086..4b00be9980 100644 --- a/src/core/infiniteQueryObserver.ts +++ b/src/core/infiniteQueryObserver.ts @@ -12,6 +12,7 @@ import { hasPreviousPage, infiniteQueryBehavior, } from './infiniteQueryBehavior' +import { Query } from './query' type InfiniteQueryObserverListener = ( result: InfiniteQueryObserverResult @@ -98,9 +99,17 @@ export class InfiniteQueryObserver< }) } - protected getNewResult(): InfiniteQueryObserverResult { + protected createResult( + query: Query>, + options: InfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData + > + ): InfiniteQueryObserverResult { const { state } = this.getCurrentQuery() - const result = super.getNewResult() + const result = super.createResult(query, options) return { ...result, fetchNextPage: this.fetchNextPage, diff --git a/src/core/mutationObserver.ts b/src/core/mutationObserver.ts index da52a321c5..836de9705a 100644 --- a/src/core/mutationObserver.ts +++ b/src/core/mutationObserver.ts @@ -7,7 +7,6 @@ import type { MutationObserverResult, MutationObserverOptions, } from './types' -import { getStatusProps } from './utils' // TYPES @@ -132,7 +131,10 @@ export class MutationObserver< this.currentResult = { ...state, - ...getStatusProps(state.status), + isLoading: state.status === 'loading', + isSuccess: state.status === 'success', + isError: state.status === 'error', + isIdle: state.status === 'idle', mutate: this.mutate, reset: this.reset, } diff --git a/src/core/queriesObserver.ts b/src/core/queriesObserver.ts index 39c3a05ee6..b18b6d2756 100644 --- a/src/core/queriesObserver.ts +++ b/src/core/queriesObserver.ts @@ -2,7 +2,7 @@ import { difference, getQueryKeyHashFn, replaceAt } from './utils' import { notifyManager } from './notifyManager' import type { QueryObserverOptions, QueryObserverResult } from './types' import type { QueryClient } from './queryClient' -import { QueryObserver } from './queryObserver' +import { NotifyOptions, QueryObserver } from './queryObserver' import { Subscribable } from './subscribable' type QueriesObserverListener = (result: QueryObserverResult[]) => void @@ -20,9 +20,7 @@ export class QueriesObserver extends Subscribable { this.queries = queries || [] this.result = [] this.observers = [] - - // Subscribe to queries - this.updateObservers() + this.setQueries(this.queries) } protected onSubscribe(): void { @@ -48,21 +46,21 @@ export class QueriesObserver extends Subscribable { }) } - setQueries(queries: QueryObserverOptions[]): void { + setQueries( + queries: QueryObserverOptions[], + notifyOptions?: NotifyOptions + ): void { this.queries = queries - this.updateObservers() + this.updateObservers(notifyOptions) } getCurrentResult(): QueryObserverResult[] { return this.result } - private updateObservers(): void { - let hasIndexChange = false - - const prevObservers = this.observers - const newObservers = this.queries.map((options, i) => { - let observer: QueryObserver | undefined = prevObservers[i] + getOptimisticResult(queries: QueryObserverOptions[]): QueryObserverResult[] { + return queries.map((options, i) => { + let observer: QueryObserver | undefined = this.observers[i] const defaultedOptions = this.client.defaultQueryObserverOptions(options) const hashFn = getQueryKeyHashFn(defaultedOptions) @@ -72,42 +70,74 @@ export class QueriesObserver extends Subscribable { !observer || observer.getCurrentQuery().queryHash !== defaultedOptions.queryHash ) { - hasIndexChange = true - observer = prevObservers.find( + observer = this.observers.find( x => x.getCurrentQuery().queryHash === defaultedOptions.queryHash ) } - if (observer) { - observer.setOptions(defaultedOptions) - return observer + if (!observer) { + observer = new QueryObserver(this.client, defaultedOptions) } - return new QueryObserver(this.client, defaultedOptions) + return observer.getOptimisticResult(defaultedOptions) }) + } - if (prevObservers.length === newObservers.length && !hasIndexChange) { - return - } + private updateObservers(notifyOptions?: NotifyOptions): void { + notifyManager.batch(() => { + let hasIndexChange = false - this.observers = newObservers - this.result = newObservers.map(observer => observer.getCurrentResult()) + const prevObservers = this.observers + const newObservers = this.queries.map((options, i) => { + let observer: QueryObserver | undefined = prevObservers[i] - if (!this.listeners.length) { - return - } + const defaultedOptions = this.client.defaultQueryObserverOptions( + options + ) + const hashFn = getQueryKeyHashFn(defaultedOptions) + defaultedOptions.queryHash = hashFn(defaultedOptions.queryKey!) + + if ( + !observer || + observer.getCurrentQuery().queryHash !== defaultedOptions.queryHash + ) { + hasIndexChange = true + observer = prevObservers.find( + x => x.getCurrentQuery().queryHash === defaultedOptions.queryHash + ) + } + + if (observer) { + observer.setOptions(defaultedOptions, notifyOptions) + return observer + } + + return new QueryObserver(this.client, defaultedOptions) + }) - difference(prevObservers, newObservers).forEach(observer => { - observer.destroy() - }) + if (prevObservers.length === newObservers.length && !hasIndexChange) { + return + } - difference(newObservers, prevObservers).forEach(observer => { - observer.subscribe(result => { - this.onUpdate(observer, result) + this.observers = newObservers + this.result = newObservers.map(observer => observer.getCurrentResult()) + + if (!this.listeners.length) { + return + } + + difference(prevObservers, newObservers).forEach(observer => { + observer.destroy() }) - }) - this.notify() + difference(newObservers, prevObservers).forEach(observer => { + observer.subscribe(result => { + this.onUpdate(observer, result) + }) + }) + + this.notify() + }) } private onUpdate(observer: QueryObserver, result: QueryObserverResult): void { diff --git a/src/core/query.ts b/src/core/query.ts index 8e6aef5381..0be2ae4472 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -260,7 +260,7 @@ export class Query< } onFocus(): void { - const observer = this.observers.find(x => x.willFetchOnWindowFocus()) + const observer = this.observers.find(x => x.shouldFetchOnWindowFocus()) if (observer) { observer.refetch() @@ -271,7 +271,7 @@ export class Query< } onOnline(): void { - const observer = this.observers.find(x => x.willFetchOnReconnect()) + const observer = this.observers.find(x => x.shouldFetchOnReconnect()) if (observer) { observer.refetch() @@ -328,7 +328,7 @@ export class Query< options?: QueryOptions, fetchOptions?: FetchOptions ): Promise { - if (this.state.isFetching) + if (this.state.isFetching) { if (this.state.dataUpdatedAt && fetchOptions?.cancelRefetch) { // Silently cancel current fetch if the user wants to cancel refetches this.cancel({ silent: true }) @@ -336,6 +336,7 @@ export class Query< // Return current promise if we are already fetching return this.promise } + } // Update config if passed, otherwise the config from the last execution is used if (options) { diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts index e323e1d6d3..a0652fe6da 100644 --- a/src/core/queryCache.ts +++ b/src/core/queryCache.ts @@ -42,9 +42,8 @@ export class QueryCache extends Subscribable { options: QueryOptions, state?: QueryState ): Query { - const hashFn = getQueryKeyHashFn(options) const queryKey = options.queryKey! - const queryHash = options.queryHash ?? hashFn(queryKey) + const queryHash = options.queryHash ?? getQueryKeyHashFn(options)(queryKey) let query = this.get(queryHash) if (!query) { diff --git a/src/core/queryClient.ts b/src/core/queryClient.ts index b399f6c050..a855b80648 100644 --- a/src/core/queryClient.ts +++ b/src/core/queryClient.ts @@ -6,6 +6,7 @@ import { parseFilterArgs, parseQueryArgs, partialMatchKey, + getQueryKeyHashFn, } from './utils' import type { DefaultOptions, @@ -446,12 +447,21 @@ export class QueryClient { if (options?._defaulted) { return options } - return { + + const defaultedOptions = { ...this.defaultOptions.queries, ...this.getQueryDefaults(options?.queryKey), ...options, _defaulted: true, } as T + + if (!defaultedOptions.queryHash && defaultedOptions.queryKey) { + defaultedOptions.queryHash = getQueryKeyHashFn(defaultedOptions)( + defaultedOptions.queryKey + ) + } + + return defaultedOptions } defaultQueryObserverOptions< diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 6cbd29c922..5c698f3cee 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -1,5 +1,4 @@ import { - getStatusProps, isServer, isValidTimeout, noop, @@ -27,7 +26,7 @@ type QueryObserverListener = ( result: QueryObserverResult ) => void -interface NotifyOptions { +export interface NotifyOptions { cache?: boolean listeners?: boolean onError?: boolean @@ -48,21 +47,19 @@ export class QueryObserver< private client: QueryClient private currentQuery!: Query + private currentQueryInitialState!: QueryState private currentResult!: QueryObserverResult private currentResultState?: QueryState - private previousOptions?: QueryObserverOptions< + private currentResultOptions?: QueryObserverOptions< TQueryFnData, TError, TData, TQueryData > private previousQueryResult?: QueryObserverResult - private initialDataUpdateCount: number - private initialErrorUpdateCount: number private staleTimeoutId?: number private refetchIntervalId?: number private trackedProps!: Array - private trackedCurrentResult!: QueryObserverResult constructor( client: QueryClient, @@ -72,8 +69,6 @@ export class QueryObserver< this.client = client this.options = options - this.initialDataUpdateCount = 0 - this.initialErrorUpdateCount = 0 this.trackedProps = [] this.bindMethods() this.setOptions(options) @@ -86,15 +81,12 @@ export class QueryObserver< protected onSubscribe(): void { if (this.listeners.length === 1) { - this.updateQuery() - this.currentQuery.addObserver(this) - if (this.willFetchOnMount()) { + if (shouldFetchOnMount(this.currentQuery, this.options)) { this.executeFetch() } - this.updateResult() this.updateTimers() } } @@ -105,52 +97,12 @@ export class QueryObserver< } } - willLoadOnMount(): boolean { - return ( - this.options.enabled !== false && - !this.currentQuery.state.dataUpdatedAt && - !( - this.currentQuery.state.status === 'error' && - this.options.retryOnMount === false - ) - ) - } - - willRefetchOnMount(): boolean { - return ( - this.options.enabled !== false && - this.currentQuery.state.dataUpdatedAt > 0 && - (this.options.refetchOnMount === 'always' || - (this.options.refetchOnMount !== false && this.isStale())) - ) - } - - willFetchOnMount(): boolean { - return this.willLoadOnMount() || this.willRefetchOnMount() - } - - willFetchOnReconnect(): boolean { - return ( - this.options.enabled !== false && - (this.options.refetchOnReconnect === 'always' || - (this.options.refetchOnReconnect !== false && this.isStale())) - ) - } - - willFetchOnWindowFocus(): boolean { - return ( - this.options.enabled !== false && - (this.options.refetchOnWindowFocus === 'always' || - (this.options.refetchOnWindowFocus !== false && this.isStale())) - ) + shouldFetchOnReconnect(): boolean { + return shouldFetchOnReconnect(this.currentQuery, this.options) } - private willFetchOptionally(): boolean { - return this.options.enabled !== false && this.isStale() - } - - private isStale(): boolean { - return this.currentQuery.isStaleByTime(this.options.staleTime) + shouldFetchOnWindowFocus(): boolean { + return shouldFetchOnWindowFocus(this.currentQuery, this.options) } destroy(): void { @@ -160,9 +112,12 @@ export class QueryObserver< } setOptions( - options?: QueryObserverOptions + options?: QueryObserverOptions, + notifyOptions?: NotifyOptions ): void { - this.previousOptions = this.options + const prevOptions = this.options + const prevQuery = this.currentQuery + this.options = this.client.defaultQueryObserverOptions(options) if ( @@ -174,84 +129,89 @@ export class QueryObserver< // Keep previous query key if the user does not supply one if (!this.options.queryKey) { - this.options.queryKey = this.previousOptions.queryKey + this.options.queryKey = prevOptions.queryKey } - const didUpdateQuery = this.updateQuery() - - let optionalFetch - let updateResult - let updateStaleTimeout - let updateRefetchInterval + this.updateQuery() - // If we subscribed to a new query, optionally fetch and update result and timers - if (didUpdateQuery) { - optionalFetch = true - updateResult = true - updateStaleTimeout = true - updateRefetchInterval = true - } + const mounted = this.hasListeners() - // Optionally fetch if the query became enabled + // Fetch if there are subscribers if ( - this.options.enabled !== false && - this.previousOptions.enabled === false + mounted && + shouldFetchOptionally( + this.currentQuery, + prevQuery, + this.options, + prevOptions + ) ) { - optionalFetch = true + this.executeFetch() } - // Update result if the select function changed - if (this.options.select !== this.previousOptions.select) { - updateResult = true - } + // Update result + this.updateResult(notifyOptions) // Update stale interval if needed if ( - this.options.enabled !== this.previousOptions.enabled || - this.options.staleTime !== this.previousOptions.staleTime + mounted && + (this.currentQuery !== prevQuery || + this.options.enabled !== prevOptions.enabled || + this.options.staleTime !== prevOptions.staleTime) ) { - updateStaleTimeout = true + this.updateStaleTimeout() } // Update refetch interval if needed if ( - this.options.enabled !== this.previousOptions.enabled || - this.options.refetchInterval !== this.previousOptions.refetchInterval + mounted && + (this.currentQuery !== prevQuery || + this.options.enabled !== prevOptions.enabled || + this.options.refetchInterval !== prevOptions.refetchInterval) ) { - updateRefetchInterval = true - } - - // Fetch only if there are subscribers - if (this.hasListeners()) { - if (optionalFetch) { - this.optionalFetch() - } + this.updateRefetchInterval() } + } - if (updateResult) { - this.updateResult() - } + getOptimisticResult( + options: QueryObserverOptions + ): QueryObserverResult { + const defaultedOptions = this.client.defaultQueryObserverOptions(options) - // Update intervals only if there are subscribers - if (this.hasListeners()) { - if (updateStaleTimeout) { - this.updateStaleTimeout() - } - if (updateRefetchInterval) { - this.updateRefetchInterval() - } - } + const query = this.client + .getQueryCache() + .build( + this.client, + defaultedOptions as QueryOptions + ) - // Reset previous options after all code related to option changes has run - this.previousOptions = this.options + return this.createResult(query, defaultedOptions) } getCurrentResult(): QueryObserverResult { return this.currentResult } - getTrackedCurrentResult(): QueryObserverResult { - return this.trackedCurrentResult + trackResult( + result: QueryObserverResult + ): QueryObserverResult { + const trackedResult = {} as QueryObserverResult + + Object.keys(result).forEach(key => { + Object.defineProperty(trackedResult, key, { + configurable: false, + enumerable: true, + get: () => { + const typedKey = key as keyof QueryObserverResult + if (!this.trackedProps.includes(typedKey)) { + this.trackedProps.push(typedKey) + } + return result[typedKey] + }, + }) + }) + + return trackedResult } getNextResult( @@ -294,12 +254,6 @@ export class QueryObserver< }) } - private optionalFetch(): void { - if (this.willFetchOptionally()) { - this.executeFetch() - } - } - private executeFetch( fetchOptions?: ObserverFetchOptions ): Promise { @@ -387,50 +341,72 @@ export class QueryObserver< this.refetchIntervalId = undefined } - protected getNewResult(): QueryObserverResult { - const { state } = this.currentQuery - let { isFetching, status } = state + protected createResult( + query: Query, + options: QueryObserverOptions + ): QueryObserverResult { + const prevQuery = this.currentQuery + const prevOptions = this.options + const prevResult = this.currentResult + const prevResultState = this.currentResultState + const prevResultOptions = this.currentResultOptions + const queryChange = query !== prevQuery + const queryInitialState = queryChange + ? query.state + : this.currentQueryInitialState + const prevQueryResult = queryChange + ? this.currentResult + : this.previousQueryResult + + const { state } = query + let { dataUpdatedAt, error, errorUpdatedAt, isFetching, status } = state let isPreviousData = false let isPlaceholderData = false let data: TData | undefined - let dataUpdatedAt = state.dataUpdatedAt - let error = state.error - let errorUpdatedAt = state.errorUpdatedAt - - // Optimistically set status to loading if we will start fetching - if (!this.hasListeners() && this.willFetchOnMount()) { - isFetching = true - if (!dataUpdatedAt) { - status = 'loading' + + // 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) { + isFetching = true + if (!dataUpdatedAt) { + status = 'loading' + } } } // Keep previous data if needed if ( - this.options.keepPreviousData && + options.keepPreviousData && !state.dataUpdateCount && - this.previousQueryResult?.isSuccess && + prevQueryResult?.isSuccess && status !== 'error' ) { - data = this.previousQueryResult.data - dataUpdatedAt = this.previousQueryResult.dataUpdatedAt - status = this.previousQueryResult.status + data = prevQueryResult.data + dataUpdatedAt = prevQueryResult.dataUpdatedAt + status = prevQueryResult.status isPreviousData = true } // Select data if needed - else if (this.options.select && typeof state.data !== 'undefined') { - // Use the previous select result if the query data and select function did not change + else if (options.select && typeof state.data !== 'undefined') { + // Memoize select result if ( - this.currentResult && - state.data === this.currentResultState?.data && - this.options.select === this.previousOptions?.select + prevResult && + state.data === prevResultState?.data && + options.select === prevResultOptions?.select ) { - data = this.currentResult.data + data = prevResult.data } else { try { - data = this.options.select(state.data) - if (this.options.structuralSharing !== false) { - data = replaceEqualDeep(this.currentResult?.data, data) + data = options.select(state.data) + if (options.structuralSharing !== false) { + data = replaceEqualDeep(prevResult?.data, data) } } catch (selectError) { getLogger().error(selectError) @@ -447,14 +423,25 @@ export class QueryObserver< // Show placeholder data if needed if ( - typeof this.options.placeholderData !== 'undefined' && + typeof options.placeholderData !== 'undefined' && typeof data === 'undefined' && status === 'loading' ) { - const placeholderData = - typeof this.options.placeholderData === 'function' - ? (this.options.placeholderData as PlaceholderDataFunction)() - : this.options.placeholderData + let placeholderData + + // Memoize placeholder data + if ( + prevResult?.isPlaceholderData && + options.placeholderData === prevResultOptions?.placeholderData + ) { + placeholderData = prevResult.data + } else { + placeholderData = + typeof options.placeholderData === 'function' + ? (options.placeholderData as PlaceholderDataFunction)() + : options.placeholderData + } + if (typeof placeholderData !== 'undefined') { status = 'success' data = placeholderData @@ -463,7 +450,11 @@ export class QueryObserver< } const result: QueryObserverBaseResult = { - ...getStatusProps(status), + status, + isLoading: status === 'loading', + isSuccess: status === 'success', + isError: status === 'error', + isIdle: status === 'idle', data, dataUpdatedAt, error, @@ -471,14 +462,14 @@ export class QueryObserver< failureCount: state.fetchFailureCount, isFetched: state.dataUpdateCount > 0 || state.errorUpdateCount > 0, isFetchedAfterMount: - state.dataUpdateCount > this.initialDataUpdateCount || - state.errorUpdateCount > this.initialErrorUpdateCount, + state.dataUpdateCount > queryInitialState.dataUpdateCount || + state.errorUpdateCount > queryInitialState.errorUpdateCount, isFetching, isLoadingError: status === 'error' && state.dataUpdatedAt === 0, isPlaceholderData, isPreviousData, isRefetchError: status === 'error' && state.dataUpdatedAt !== 0, - isStale: this.isStale(), + isStale: isStale(query, options), refetch: this.refetch, remove: this.remove, } @@ -487,109 +478,69 @@ export class QueryObserver< } private shouldNotifyListeners( - prevResult: QueryObserverResult | undefined, - result: QueryObserverResult + result: QueryObserverResult, + prevResult?: QueryObserverResult ): boolean { - const { notifyOnChangeProps, notifyOnChangePropsExclusions } = this.options + if (!prevResult) { + return true + } - if (prevResult === result) { + if (result === prevResult) { return false } - if (!prevResult) { + const { notifyOnChangeProps, notifyOnChangePropsExclusions } = this.options + + if (!notifyOnChangeProps && !notifyOnChangePropsExclusions) { return true } - if (!notifyOnChangeProps && !notifyOnChangePropsExclusions) { + if (notifyOnChangeProps === 'tracked' && !this.trackedProps.length) { return true } - const keys = Object.keys(result) const includedProps = notifyOnChangeProps === 'tracked' ? this.trackedProps : notifyOnChangeProps - for (let i = 0; i < keys.length; i++) { - const key = keys[i] as keyof QueryObserverResult - const changed = prevResult[key] !== result[key] + return Object.keys(result).some(key => { + const typedKey = key as keyof QueryObserverResult + const changed = result[typedKey] !== prevResult[typedKey] const isIncluded = includedProps?.some(x => x === key) const isExcluded = notifyOnChangePropsExclusions?.some(x => x === key) - - if (changed) { - if (notifyOnChangePropsExclusions && isExcluded) { - continue - } - - if ( - !notifyOnChangeProps || - isIncluded || - (notifyOnChangeProps === 'tracked' && this.trackedProps.length === 0) - ) { - return true - } - } - } - - return false + return changed && !isExcluded && (!includedProps || isIncluded) + }) } - private updateResult(action?: Action): void { + updateResult(notifyOptions?: NotifyOptions): void { const prevResult = this.currentResult as | QueryObserverResult | undefined - const result = this.getNewResult() - - // Keep reference to the current state on which the current result is based on + this.currentResult = this.createResult(this.currentQuery, this.options) this.currentResultState = this.currentQuery.state + this.currentResultOptions = this.options - // Only update if something has changed - if (shallowEqualObjects(result, prevResult)) { + // Only notify if something has changed + if (shallowEqualObjects(this.currentResult, prevResult)) { return } - this.currentResult = result - - if (this.options.notifyOnChangeProps === 'tracked') { - const addTrackedProps = (prop: keyof QueryObserverResult) => { - if (!this.trackedProps.includes(prop)) { - this.trackedProps.push(prop) - } - } - this.trackedCurrentResult = {} as QueryObserverResult - - Object.keys(result).forEach(key => { - Object.defineProperty(this.trackedCurrentResult, key, { - configurable: false, - enumerable: true, - get() { - addTrackedProps(key as keyof QueryObserverResult) - return result[key as keyof QueryObserverResult] - }, - }) - }) - } - // Determine which callbacks to trigger - const notifyOptions: NotifyOptions = { cache: true } - - if (action?.type === 'success') { - notifyOptions.onSuccess = true - } else if (action?.type === 'error') { - notifyOptions.onError = true - } + const defaultNotifyOptions: NotifyOptions = { cache: true } - if (this.shouldNotifyListeners(prevResult, result)) { - notifyOptions.listeners = true + if ( + notifyOptions?.listeners !== false && + this.shouldNotifyListeners(this.currentResult, prevResult) + ) { + defaultNotifyOptions.listeners = true } - this.notify(notifyOptions) + this.notify({ ...defaultNotifyOptions, ...notifyOptions }) } - private updateQuery(): boolean { - const prevQuery = this.currentQuery - + private updateQuery(): void { const query = this.client .getQueryCache() .build( @@ -597,25 +548,32 @@ export class QueryObserver< this.options as QueryOptions ) - if (query === prevQuery) { - return false + if (query === this.currentQuery) { + return } - this.previousQueryResult = this.currentResult + const prevQuery = this.currentQuery this.currentQuery = query - this.initialDataUpdateCount = query.state.dataUpdateCount - this.initialErrorUpdateCount = query.state.errorUpdateCount + this.currentQueryInitialState = query.state + this.previousQueryResult = this.currentResult if (this.hasListeners()) { prevQuery?.removeObserver(this) - this.currentQuery.addObserver(this) + query.addObserver(this) } - - return true } onQueryUpdate(action: Action): void { - this.updateResult(action) + const notifyOptions: NotifyOptions = {} + + if (action.type === 'success') { + notifyOptions.onSuccess = true + } else if (action.type === 'error') { + notifyOptions.onError = true + } + + this.updateResult(notifyOptions) + if (this.hasListeners()) { this.updateTimers() } @@ -646,3 +604,77 @@ export class QueryObserver< }) } } + +function shouldLoadOnMount( + query: Query, + options: QueryObserverOptions +): boolean { + return ( + options.enabled !== false && + !query.state.dataUpdatedAt && + !(query.state.status === 'error' && options.retryOnMount === false) + ) +} + +function shouldRefetchOnMount( + query: Query, + options: QueryObserverOptions +): boolean { + return ( + options.enabled !== false && + query.state.dataUpdatedAt > 0 && + (options.refetchOnMount === 'always' || + (options.refetchOnMount !== false && isStale(query, options))) + ) +} + +function shouldFetchOnMount( + query: Query, + options: QueryObserverOptions +): boolean { + return ( + shouldLoadOnMount(query, options) || shouldRefetchOnMount(query, options) + ) +} + +function shouldFetchOnReconnect( + query: Query, + options: QueryObserverOptions +): boolean { + return ( + options.enabled !== false && + (options.refetchOnReconnect === 'always' || + (options.refetchOnReconnect !== false && isStale(query, options))) + ) +} + +function shouldFetchOnWindowFocus( + query: Query, + options: QueryObserverOptions +): boolean { + return ( + options.enabled !== false && + (options.refetchOnWindowFocus === 'always' || + (options.refetchOnWindowFocus !== false && isStale(query, options))) + ) +} + +function shouldFetchOptionally( + query: Query, + prevQuery: Query, + options: QueryObserverOptions, + prevOptions: QueryObserverOptions +): boolean { + return ( + (query !== prevQuery || + (options.enabled !== false && prevOptions.enabled === false)) && + isStale(query, options) + ) +} + +function isStale( + query: Query, + options: QueryObserverOptions +): boolean { + return query.isStaleByTime(options.staleTime) +} diff --git a/src/core/tests/queriesObserver.test.tsx b/src/core/tests/queriesObserver.test.tsx index b960ed0d3e..e424987ffb 100644 --- a/src/core/tests/queriesObserver.test.tsx +++ b/src/core/tests/queriesObserver.test.tsx @@ -49,12 +49,30 @@ describe('queriesObserver', () => { queryClient.setQueryData(key2, 3) await sleep(1) unsubscribe() - expect(results.length).toBe(4) - expect(results).toMatchObject([ - [{ data: undefined }, { data: undefined }], - [{ data: 1 }, { data: undefined }], - [{ data: 1 }, { data: 2 }], - [{ data: 1 }, { data: 3 }], + expect(results.length).toBe(6) + expect(results[0]).toMatchObject([ + { status: 'idle', data: undefined }, + { status: 'idle', data: undefined }, + ]) + expect(results[1]).toMatchObject([ + { status: 'loading', data: undefined }, + { status: 'idle', data: undefined }, + ]) + expect(results[2]).toMatchObject([ + { status: 'loading', data: undefined }, + { status: 'loading', data: undefined }, + ]) + expect(results[3]).toMatchObject([ + { status: 'success', data: 1 }, + { status: 'loading', data: undefined }, + ]) + expect(results[4]).toMatchObject([ + { status: 'success', data: 1 }, + { status: 'success', data: 2 }, + ]) + expect(results[5]).toMatchObject([ + { status: 'success', data: 1 }, + { status: 'success', data: 3 }, ]) }) @@ -81,13 +99,28 @@ describe('queriesObserver', () => { unsubscribe() expect(queryCache.find(key1, { active: true })).toBeUndefined() expect(queryCache.find(key2, { active: true })).toBeUndefined() - expect(results.length).toBe(4) - expect(results).toMatchObject([ - [{ data: undefined }, { data: undefined }], - [{ data: 1 }, { data: undefined }], - [{ data: 1 }, { data: 2 }], - [{ data: 2 }], + expect(results.length).toBe(6) + expect(results[0]).toMatchObject([ + { status: 'idle', data: undefined }, + { status: 'idle', data: undefined }, + ]) + expect(results[1]).toMatchObject([ + { status: 'loading', data: undefined }, + { status: 'idle', data: undefined }, + ]) + expect(results[2]).toMatchObject([ + { status: 'loading', data: undefined }, + { status: 'loading', data: undefined }, + ]) + expect(results[3]).toMatchObject([ + { status: 'success', data: 1 }, + { status: 'loading', data: undefined }, + ]) + expect(results[4]).toMatchObject([ + { status: 'success', data: 1 }, + { status: 'success', data: 2 }, ]) + expect(results[5]).toMatchObject([{ status: 'success', data: 2 }]) }) test('should update when a query changed position', async () => { @@ -111,12 +144,30 @@ describe('queriesObserver', () => { ]) await sleep(1) unsubscribe() - expect(results.length).toBe(4) - expect(results).toMatchObject([ - [{ data: undefined }, { data: undefined }], - [{ data: 1 }, { data: undefined }], - [{ data: 1 }, { data: 2 }], - [{ data: 2 }, { data: 1 }], + expect(results.length).toBe(6) + expect(results[0]).toMatchObject([ + { status: 'idle', data: undefined }, + { status: 'idle', data: undefined }, + ]) + expect(results[1]).toMatchObject([ + { status: 'loading', data: undefined }, + { status: 'idle', data: undefined }, + ]) + expect(results[2]).toMatchObject([ + { status: 'loading', data: undefined }, + { status: 'loading', data: undefined }, + ]) + expect(results[3]).toMatchObject([ + { status: 'success', data: 1 }, + { status: 'loading', data: undefined }, + ]) + expect(results[4]).toMatchObject([ + { status: 'success', data: 1 }, + { status: 'success', data: 2 }, + ]) + expect(results[5]).toMatchObject([ + { status: 'success', data: 2 }, + { status: 'success', data: 1 }, ]) }) @@ -141,11 +192,26 @@ describe('queriesObserver', () => { ]) await sleep(1) unsubscribe() - expect(results.length).toBe(3) - expect(results).toMatchObject([ - [{ data: undefined }, { data: undefined }], - [{ data: 1 }, { data: undefined }], - [{ data: 1 }, { data: 2 }], + expect(results.length).toBe(5) + expect(results[0]).toMatchObject([ + { status: 'idle', data: undefined }, + { status: 'idle', data: undefined }, + ]) + expect(results[1]).toMatchObject([ + { status: 'loading', data: undefined }, + { status: 'idle', data: undefined }, + ]) + expect(results[2]).toMatchObject([ + { status: 'loading', data: undefined }, + { status: 'loading', data: undefined }, + ]) + expect(results[3]).toMatchObject([ + { status: 'success', data: 1 }, + { status: 'loading', data: undefined }, + ]) + expect(results[4]).toMatchObject([ + { status: 'success', data: 1 }, + { status: 'success', data: 2 }, ]) }) diff --git a/src/core/tests/queryObserver.test.tsx b/src/core/tests/queryObserver.test.tsx index 91dcbc6732..35939d7494 100644 --- a/src/core/tests/queryObserver.test.tsx +++ b/src/core/tests/queryObserver.test.tsx @@ -43,28 +43,11 @@ describe('queryObserver', () => { observer.setOptions({ queryKey: key2, queryFn: () => 2 }) await sleep(1) unsubscribe() - expect(results.length).toBe(3) - expect(results[0]).toMatchObject({ data: 1, status: 'success' }) - expect(results[1]).toMatchObject({ data: undefined, status: 'loading' }) - expect(results[2]).toMatchObject({ data: 2, status: 'success' }) - }) - - test('should notify when the query has updated before subscribing', async () => { - const key = queryKey() - const results: QueryObserverResult[] = [] - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn: () => 1, - staleTime: Infinity, - }) - queryClient.setQueryData(key, 2) - const unsubscribe = observer.subscribe(result => { - results.push(result) - }) - await sleep(1) - unsubscribe() - expect(results.length).toBe(1) - expect(results[0]).toMatchObject({ data: 2, status: 'success' }) + expect(results.length).toBe(4) + expect(results[0]).toMatchObject({ data: undefined, status: 'loading' }) + expect(results[1]).toMatchObject({ data: 1, status: 'success' }) + expect(results[2]).toMatchObject({ data: undefined, status: 'loading' }) + expect(results[3]).toMatchObject({ data: 2, status: 'success' }) }) test('should be able to fetch with a selector', async () => { @@ -161,23 +144,28 @@ describe('queryObserver', () => { await observer.refetch() unsubscribe() expect(count).toBe(2) - expect(results.length).toBe(4) + expect(results.length).toBe(5) expect(results[0]).toMatchObject({ + status: 'loading', + isFetching: true, + data: undefined, + }) + expect(results[1]).toMatchObject({ status: 'success', isFetching: false, data: { myCount: 1 }, }) - expect(results[1]).toMatchObject({ + expect(results[2]).toMatchObject({ status: 'success', isFetching: false, data: { myCount: 99 }, }) - expect(results[2]).toMatchObject({ + expect(results[3]).toMatchObject({ status: 'success', isFetching: true, data: { myCount: 99 }, }) - expect(results[3]).toMatchObject({ + expect(results[4]).toMatchObject({ status: 'success', isFetching: false, data: { myCount: 99 }, @@ -211,18 +199,23 @@ describe('queryObserver', () => { await observer.refetch() unsubscribe() expect(count).toBe(1) - expect(results.length).toBe(3) + expect(results.length).toBe(4) expect(results[0]).toMatchObject({ + status: 'loading', + isFetching: true, + data: undefined, + }) + expect(results[1]).toMatchObject({ status: 'success', isFetching: false, data: { myCount: 1 }, }) - expect(results[1]).toMatchObject({ + expect(results[2]).toMatchObject({ status: 'success', isFetching: true, data: { myCount: 1 }, }) - expect(results[2]).toMatchObject({ + expect(results[3]).toMatchObject({ status: 'success', isFetching: false, data: { myCount: 1 }, @@ -432,8 +425,8 @@ describe('queryObserver', () => { }) expect(observer.getCurrentResult()).toMatchObject({ - status: 'success', - data: 'placeholder', + status: 'idle', + data: undefined, }) const results: QueryObserverResult[] = [] @@ -443,7 +436,10 @@ describe('queryObserver', () => { }) await sleep(10) - expect(results[0].data).toBe('data') unsubscribe() + + expect(results.length).toBe(2) + expect(results[0]).toMatchObject({ status: 'success', data: 'placeholder' }) + expect(results[1]).toMatchObject({ status: 'success', data: 'data' }) }) }) diff --git a/src/core/types.ts b/src/core/types.ts index 40ecfec392..e0457820ff 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -179,6 +179,12 @@ export interface QueryObserverOptions< * 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?: TData | PlaceholderDataFunction + /** + * If set, the observer will optimistically set the result in fetching state before the query has actually started fetching. + * This is to make sure the results are not lagging behind. + * Defaults to `true`. + */ + optimisticResults?: boolean } export interface InfiniteQueryObserverOptions< @@ -217,7 +223,7 @@ export interface ResultOptions { } export interface RefetchOptions extends ResultOptions { - cancelRefetch?: boolean; + cancelRefetch?: boolean } export interface InvalidateQueryFilters extends QueryFilters { diff --git a/src/core/utils.ts b/src/core/utils.ts index 3eb8eab3c8..56dc8ca72f 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -7,7 +7,6 @@ import type { QueryKey, QueryKeyHashFunction, QueryOptions, - QueryStatus, } from './types' // TYPES @@ -345,16 +344,6 @@ export function sleep(timeout: number): Promise { }) } -export function getStatusProps(status: T) { - return { - status, - isLoading: status === 'loading', - isSuccess: status === 'success', - isError: status === 'error', - isIdle: status === 'idle', - } -} - /** * Schedules a microtask. * This can be useful to schedule state updates after rendering. diff --git a/src/hydration/tests/hydration.test.tsx b/src/hydration/tests/hydration.test.tsx index 3527e9ecbd..d8564dad6a 100644 --- a/src/hydration/tests/hydration.test.tsx +++ b/src/hydration/tests/hydration.test.tsx @@ -128,7 +128,7 @@ describe('dehydration and rehydration', () => { await hydrationClient.prefetchQuery( ['string', { key: ['string'], key2: 0 }], fetchDataAfterHydration, - { staleTime: 10 } + { staleTime: 100 } ) expect(fetchDataAfterHydration).toHaveBeenCalledTimes(0) diff --git a/src/react/tests/QueryResetErrorBoundary.test.tsx b/src/react/tests/QueryResetErrorBoundary.test.tsx index af0c368ed5..59c2094e3e 100644 --- a/src/react/tests/QueryResetErrorBoundary.test.tsx +++ b/src/react/tests/QueryResetErrorBoundary.test.tsx @@ -73,6 +73,132 @@ describe('QueryErrorResetBoundary', () => { consoleMock.mockRestore() }) + it('should not retry fetch if the reset error boundary has not been reset', async () => { + const key = queryKey() + + let succeed = false + const consoleMock = mockConsoleError() + + function Page() { + const { data } = useQuery( + key, + async () => { + await sleep(10) + if (!succeed) { + throw new Error('Error') + } else { + return 'data' + } + }, + { + retry: false, + useErrorBoundary: true, + } + ) + return
{data}
+ } + + const rendered = renderWithClient( + queryClient, + + {() => ( + ( +
+
error boundary
+ +
+ )} + > + +
+ )} +
+ ) + + await waitFor(() => rendered.getByText('error boundary')) + await waitFor(() => rendered.getByText('retry')) + succeed = true + fireEvent.click(rendered.getByText('retry')) + await waitFor(() => rendered.getByText('error boundary')) + + consoleMock.mockRestore() + }) + + it('should not retry fetch if the reset error boundary has not been reset after a previous reset', async () => { + const key = queryKey() + + let succeed = false + let shouldReset = true + const consoleMock = mockConsoleError() + + function Page() { + const { data } = useQuery( + key, + async () => { + await sleep(10) + if (!succeed) { + throw new Error('Error') + } else { + return 'data' + } + }, + { + retry: false, + useErrorBoundary: true, + } + ) + return
{data}
+ } + + const rendered = renderWithClient( + queryClient, + + {({ reset }) => ( + { + if (shouldReset) { + reset() + } + }} + fallbackRender={({ resetErrorBoundary }) => ( +
+
error boundary
+ +
+ )} + > + +
+ )} +
+ ) + + await waitFor(() => rendered.getByText('error boundary')) + await waitFor(() => rendered.getByText('retry')) + shouldReset = true + fireEvent.click(rendered.getByText('retry')) + await waitFor(() => rendered.getByText('error boundary')) + succeed = true + shouldReset = false + fireEvent.click(rendered.getByText('retry')) + await waitFor(() => rendered.getByText('error boundary')) + + consoleMock.mockRestore() + }) + it('should throw again on error after the reset error boundary has been reset', async () => { const key = queryKey() const consoleMock = mockConsoleError() diff --git a/src/react/tests/suspense.test.tsx b/src/react/tests/suspense.test.tsx index da6638e946..a8534bc883 100644 --- a/src/react/tests/suspense.test.tsx +++ b/src/react/tests/suspense.test.tsx @@ -59,7 +59,7 @@ describe("useQuery's in Suspense mode", () => { await sleep(20) - expect(renders).toBe(5) + expect(renders).toBe(4) expect(states.length).toBe(2) expect(states[0]).toMatchObject({ data: 1, status: 'success' }) expect(states[1]).toMatchObject({ data: 2, status: 'success' }) diff --git a/src/react/tests/useIsFetching.test.tsx b/src/react/tests/useIsFetching.test.tsx index e1b523313e..085242cbfd 100644 --- a/src/react/tests/useIsFetching.test.tsx +++ b/src/react/tests/useIsFetching.test.tsx @@ -87,7 +87,7 @@ describe('useIsFetching', () => { React.useEffect(() => { setActTimeout(() => { setRenderSecond(true) - }, 10) + }, 50) }, []) return ( diff --git a/src/react/tests/useQueries.test.tsx b/src/react/tests/useQueries.test.tsx index 2a0e6f8257..c66470a853 100644 --- a/src/react/tests/useQueries.test.tsx +++ b/src/react/tests/useQueries.test.tsx @@ -14,8 +14,20 @@ describe('useQueries', () => { function Page() { const result = useQueries([ - { queryKey: key1, queryFn: () => 1 }, - { queryKey: key2, queryFn: () => 2 }, + { + queryKey: key1, + queryFn: async () => { + await sleep(5) + return 1 + }, + }, + { + queryKey: key2, + queryFn: async () => { + await sleep(10) + return 2 + }, + }, ]) results.push(result) return null @@ -23,7 +35,7 @@ describe('useQueries', () => { renderWithClient(queryClient, ) - await sleep(10) + await sleep(30) expect(results.length).toBe(3) expect(results[0]).toMatchObject([{ data: undefined }, { data: undefined }]) diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index 9dac880bb2..046026342e 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -863,48 +863,6 @@ describe('useQuery', () => { expect(states[0]).toMatchObject({ data: undefined }) }) - it('should return the referentially same object if nothing changes between fetches', async () => { - const key = queryKey() - let renderCount = 0 - const states: UseQueryResult[] = [] - - function Page() { - const state = useQuery(key, () => 'test', { - notifyOnChangeProps: 'tracked', - }) - - states.push(state) - - const { data } = state - - React.useEffect(() => { - renderCount++ - }, [state]) - - return ( -
-

{data ?? null}

-
- ) - } - - const rendered = renderWithClient(queryClient, ) - - await waitFor(() => rendered.getByText('test')) - expect(renderCount).toBe(2) - expect(states.length).toBe(2) - expect(states[0]).toMatchObject({ data: undefined }) - expect(states[1]).toMatchObject({ data: 'test' }) - - act(() => rendered.rerender()) - await waitFor(() => rendered.getByText('test')) - expect(renderCount).toBe(2) - expect(states.length).toBe(3) - expect(states[0]).toMatchObject({ data: undefined }) - expect(states[1]).toMatchObject({ data: 'test' }) - expect(states[2]).toMatchObject({ data: 'test' }) - }) - it('should always re-render if we are tracking props but not using any', async () => { const key = queryKey() let renderCount = 0 @@ -1540,7 +1498,9 @@ describe('useQuery', () => { renderWithClient(queryClient, ) - await waitFor(() => expect(states.length).toBe(7)) + await sleep(100) + + expect(states.length).toBe(6) // Disabled query expect(states[0]).toMatchObject({ @@ -1566,26 +1526,19 @@ describe('useQuery', () => { // Set state expect(states[3]).toMatchObject({ data: 0, - isFetching: false, - isSuccess: true, - isPreviousData: true, - }) - // Hook state update - expect(states[4]).toMatchObject({ - data: 0, - isFetching: false, + isFetching: true, isSuccess: true, isPreviousData: true, }) // Fetching new query - expect(states[5]).toMatchObject({ + expect(states[4]).toMatchObject({ data: 0, isFetching: true, isSuccess: true, isPreviousData: true, }) // Fetched new query - expect(states[6]).toMatchObject({ + expect(states[5]).toMatchObject({ data: 1, isFetching: false, isSuccess: true, @@ -1636,7 +1589,7 @@ describe('useQuery', () => { await sleep(100) - expect(states.length).toBe(6) + expect(states.length).toBe(5) // Disabled query expect(states[0]).toMatchObject({ @@ -1648,33 +1601,26 @@ describe('useQuery', () => { // Set state expect(states[1]).toMatchObject({ data: 10, - isFetching: false, + isFetching: true, isSuccess: true, isPreviousData: true, }) // Set state expect(states[2]).toMatchObject({ data: 10, - isFetching: false, - isSuccess: true, - isPreviousData: true, - }) - // Hook state update - expect(states[3]).toMatchObject({ - data: 10, - isFetching: false, + isFetching: true, isSuccess: true, isPreviousData: true, }) // Refetch - expect(states[4]).toMatchObject({ + expect(states[3]).toMatchObject({ data: 10, isFetching: true, isSuccess: true, isPreviousData: true, }) // Refetch done - expect(states[5]).toMatchObject({ + expect(states[4]).toMatchObject({ data: 12, isFetching: false, isSuccess: true, @@ -2004,6 +1950,130 @@ describe('useQuery', () => { expect(states[1]).toMatchObject({ status: 'success' }) }) + it('should render correct states even in case of concurrent renders with different properties', async () => { + const key = queryKey() + const states: UseQueryResult[] = [] + let concurrent = false + const originalUseEffect = React.useEffect + const dummyUseEffect = (...args: any[]) => { + originalUseEffect(() => { + return + }, args[1]) + } + + function Page() { + const [count, setCount] = React.useState(0) + + if (concurrent) { + React.useEffect = dummyUseEffect + } + + const state = useQuery( + [key, count], + async () => { + await sleep(5) + return count + }, + { staleTime: Infinity, keepPreviousData: true } + ) + + if (concurrent) { + React.useEffect = originalUseEffect + } + + states.push(state) + + React.useEffect(() => { + setActTimeout(() => { + setCount(1) + }, 20) + + // Try to simulate concurrent render which does not trigger effects + setActTimeout(() => { + concurrent = true + setCount(0) + }, 40) + + setActTimeout(() => { + concurrent = false + setCount(2) + }, 60) + }, []) + + return null + } + + renderWithClient(queryClient, ) + + await sleep(100) + + expect(states.length).toBe(9) + + // Load query 0 + expect(states[0]).toMatchObject({ + status: 'loading', + data: undefined, + isFetching: true, + isPreviousData: false, + }) + // Fetch done + expect(states[1]).toMatchObject({ + status: 'success', + data: 0, + isFetching: false, + isPreviousData: false, + }) + // Set state to query 1 + expect(states[2]).toMatchObject({ + status: 'success', + data: 0, + isFetching: true, + isPreviousData: true, + }) + // Fetch start + expect(states[3]).toMatchObject({ + status: 'success', + data: 0, + isFetching: true, + isPreviousData: true, + }) + // Fetch done + expect(states[4]).toMatchObject({ + status: 'success', + data: 1, + isFetching: false, + isPreviousData: false, + }) + // Concurrent render for query 0 + expect(states[5]).toMatchObject({ + status: 'success', + data: 0, + isFetching: false, + isPreviousData: false, + }) + // Set state to query 2 (should have query 1 has previous data) + expect(states[6]).toMatchObject({ + status: 'success', + data: 1, + isFetching: true, + isPreviousData: true, + }) + // Fetch start + expect(states[7]).toMatchObject({ + status: 'success', + data: 1, + isFetching: true, + isPreviousData: true, + }) + // Fetch done + expect(states[8]).toMatchObject({ + status: 'success', + data: 2, + isFetching: false, + isPreviousData: false, + }) + }) + it('should batch re-renders', async () => { const key = queryKey() @@ -2569,13 +2639,11 @@ describe('useQuery', () => { await sleep(100) - expect(states.length).toBe(3) + expect(states.length).toBe(2) // Initial expect(states[0]).toMatchObject({ data: { count: 0 } }) // Set state expect(states[1]).toMatchObject({ data: { count: 1 } }) - // Hook state update - expect(states[2]).toMatchObject({ data: { count: 1 } }) }) it('should retry specified number of times', async () => { @@ -3506,4 +3574,29 @@ describe('useQuery', () => { isStale: true, }) }) + + it('should only call the query hash function once each render', async () => { + const key = queryKey() + + let hashes = 0 + let renders = 0 + + function queryKeyHashFn(x: any) { + hashes++ + return JSON.stringify(x) + } + + function Page() { + renders++ + useQuery(key, () => 'test', { queryKeyHashFn }) + return null + } + + renderWithClient(queryClient, ) + + await sleep(10) + + expect(renders).toBe(2) + expect(hashes).toBe(2) + }) }) diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts index b004a3687c..220cefbfd1 100644 --- a/src/react/useBaseQuery.ts +++ b/src/react/useBaseQuery.ts @@ -1,22 +1,25 @@ import React from 'react' -import { QueryObserverResult } from '../core/types' import { notifyManager } from '../core/notifyManager' import { QueryObserver } from '../core/queryObserver' import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary' import { useQueryClient } from './QueryClientProvider' import { UseBaseQueryOptions } from './types' -import { useIsMounted } from './useIsMounted' export function useBaseQuery( options: UseBaseQueryOptions, Observer: typeof QueryObserver ) { - const isMounted = useIsMounted() + const mountedRef = React.useRef(false) + const [, forceUpdate] = React.useState(0) + const queryClient = useQueryClient() const errorResetBoundary = useQueryErrorResetBoundary() const defaultedOptions = queryClient.defaultQueryObserverOptions(options) + // Make sure results are optimistically set in fetching state before subscribing or updating options + defaultedOptions.optimisticResults = true + // Include callbacks in batch renders if (defaultedOptions.onError) { defaultedOptions.onError = notifyManager.batchCalls( @@ -42,53 +45,79 @@ export function useBaseQuery( if (typeof defaultedOptions.staleTime !== 'number') { defaultedOptions.staleTime = 1000 } + } + if (defaultedOptions.suspense || defaultedOptions.useErrorBoundary) { // Prevent retrying failed query if the error boundary has not been reset yet if (!errorResetBoundary.isReset()) { defaultedOptions.retryOnMount = false } } - // Create query observer - const observerRef = React.useRef>() - const observer = - observerRef.current || new Observer(queryClient, defaultedOptions) - observerRef.current = observer + const obsRef = React.useRef>() - // Update options - if (observer.hasListeners()) { - observer.setOptions(defaultedOptions) + if (!obsRef.current) { + obsRef.current = new Observer(queryClient, defaultedOptions) } - const currentResult = observer.getCurrentResult() - const [, setCurrentResult] = React.useState(currentResult) + let result = obsRef.current.getOptimisticResult(defaultedOptions) - // Subscribe to the observer React.useEffect(() => { - errorResetBoundary.clearReset() - return observer.subscribe( - notifyManager.batchCalls((result: QueryObserverResult) => { - if (isMounted()) { - setCurrentResult(result) + mountedRef.current = true + + const unsubscribe = obsRef.current!.subscribe( + notifyManager.batchCalls(() => { + errorResetBoundary.clearReset() + if (mountedRef.current) { + forceUpdate(x => x + 1) } }) ) - }, [observer, errorResetBoundary, isMounted]) - // Handle suspense - if (observer.options.suspense || observer.options.useErrorBoundary) { - if (observer.options.suspense && currentResult.isLoading) { - errorResetBoundary.clearReset() - const unsubscribe = observer.subscribe() - throw observer.refetch().finally(unsubscribe) - } + // Update result to make sure we did not miss any query updates + // between creating the observer and subscribing to it. + obsRef.current!.updateResult() - if (currentResult.isError) { - throw currentResult.error + return () => { + mountedRef.current = false + unsubscribe() } + }, [errorResetBoundary]) + + React.useEffect(() => { + // Do not notify on updates because of changes in the options because + // these changes should already be reflected in the optimistic result. + obsRef.current!.setOptions(defaultedOptions, { listeners: false }) + }, [defaultedOptions]) + + // Handle suspense + if (obsRef.current.options.suspense && result.isLoading) { + throw queryClient + .fetchQuery(defaultedOptions) + .then(data => { + defaultedOptions.onSuccess?.(data) + defaultedOptions.onSettled?.(data, null) + }) + .catch(error => { + errorResetBoundary.clearReset() + defaultedOptions.onError?.(error) + defaultedOptions.onSettled?.(undefined, error) + }) + } + + // Handle error boundary + if ( + (obsRef.current.options.suspense || + obsRef.current.options.useErrorBoundary) && + result.isError + ) { + throw result.error + } + + // Handle result property usage tracking + if (obsRef.current.options.notifyOnChangeProps === 'tracked') { + result = obsRef.current.trackResult(result) } - return observer.options.notifyOnChangeProps === 'tracked' - ? observer.getTrackedCurrentResult() - : currentResult + return result } diff --git a/src/react/useIsFetching.ts b/src/react/useIsFetching.ts index c7c919f147..a1319c53dd 100644 --- a/src/react/useIsFetching.ts +++ b/src/react/useIsFetching.ts @@ -4,7 +4,6 @@ import { notifyManager } from '../core/notifyManager' import { QueryKey } from '../core/types' import { parseFilterArgs, QueryFilters } from '../core/utils' import { useQueryClient } from './QueryClientProvider' -import { useIsMounted } from './useIsMounted' export function useIsFetching(filters?: QueryFilters): number export function useIsFetching( @@ -15,8 +14,10 @@ export function useIsFetching( arg1?: QueryKey | QueryFilters, arg2?: QueryFilters ): number { - const isMounted = useIsMounted() + const mountedRef = React.useRef(false) + const queryClient = useQueryClient() + const [filters] = parseFilterArgs(arg1, arg2) const [isFetching, setIsFetching] = React.useState( queryClient.isFetching(filters) @@ -27,20 +28,25 @@ export function useIsFetching( const isFetchingRef = React.useRef(isFetching) isFetchingRef.current = isFetching - React.useEffect( - () => - queryClient.getQueryCache().subscribe( - notifyManager.batchCalls(() => { - if (isMounted()) { - const newIsFetching = queryClient.isFetching(filtersRef.current) - if (isFetchingRef.current !== newIsFetching) { - setIsFetching(newIsFetching) - } + React.useEffect(() => { + mountedRef.current = true + + const unsubscribe = queryClient.getQueryCache().subscribe( + notifyManager.batchCalls(() => { + if (mountedRef.current) { + const newIsFetching = queryClient.isFetching(filtersRef.current) + if (isFetchingRef.current !== newIsFetching) { + setIsFetching(newIsFetching) } - }) - ), - [queryClient, isMounted] - ) + } + }) + ) + + return () => { + mountedRef.current = false + unsubscribe() + } + }, [queryClient]) return isFetching } diff --git a/src/react/useIsMounted.ts b/src/react/useIsMounted.ts deleted file mode 100644 index f1f620f722..0000000000 --- a/src/react/useIsMounted.ts +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react' - -import { isServer } from '../core/utils' - -export function useIsMounted() { - const mountedRef = React.useRef(false) - const isMounted = React.useCallback(() => mountedRef.current, []) - - React[isServer ? 'useEffect' : 'useLayoutEffect'](() => { - mountedRef.current = true - return () => { - mountedRef.current = false - } - }, []) - - return isMounted -} diff --git a/src/react/useMutation.ts b/src/react/useMutation.ts index d5fb6374ef..a841b27d13 100644 --- a/src/react/useMutation.ts +++ b/src/react/useMutation.ts @@ -9,12 +9,7 @@ import { UseMutationOptions, UseMutationResult, } from './types' -import { - MutationFunction, - MutationKey, - MutationObserverResult, -} from '../core/types' -import { useIsMounted } from './useIsMounted' +import { MutationFunction, MutationKey } from '../core/types' // HOOK @@ -69,54 +64,45 @@ export function useMutation< | UseMutationOptions, arg3?: UseMutationOptions ): UseMutationResult { - const isMounted = useIsMounted() + const mountedRef = React.useRef(false) + const [, forceUpdate] = React.useState(0) + const options = parseMutationArgs(arg1, arg2, arg3) const queryClient = useQueryClient() - // Create mutation observer - const observerRef = React.useRef< - MutationObserver - >() - const observer = - observerRef.current || new MutationObserver(queryClient, options) - observerRef.current = observer + const obsRef = React.useRef>() - // Update options - if (observer.hasListeners()) { - observer.setOptions(options) + if (!obsRef.current) { + obsRef.current = new MutationObserver(queryClient, options) + } else { + obsRef.current.setOptions(options) } - const [currentResult, setCurrentResult] = React.useState(() => - observer.getCurrentResult() - ) + const currentResult = obsRef.current.getCurrentResult() + + React.useEffect(() => { + mountedRef.current = true - // Subscribe to the observer - React.useEffect( - () => - observer.subscribe( - notifyManager.batchCalls( - ( - result: MutationObserverResult - ) => { - if (isMounted()) { - setCurrentResult(result) - } - } - ) - ), - [observer, isMounted] - ) + const unsubscribe = obsRef.current!.subscribe( + notifyManager.batchCalls(() => { + if (mountedRef.current) { + forceUpdate(x => x + 1) + } + }) + ) + return () => { + mountedRef.current = false + unsubscribe() + } + }, []) const mutate = React.useCallback< UseMutateFunction - >( - (variables, mutateOptions) => { - observer.mutate(variables, mutateOptions).catch(noop) - }, - [observer] - ) + >((variables, mutateOptions) => { + obsRef.current!.mutate(variables, mutateOptions).catch(noop) + }, []) - if (currentResult.error && observer.options.useErrorBoundary) { + if (currentResult.error && obsRef.current.options.useErrorBoundary) { throw currentResult.error } diff --git a/src/react/useQueries.ts b/src/react/useQueries.ts index 1a85857bdc..ce548b2d7d 100644 --- a/src/react/useQueries.ts +++ b/src/react/useQueries.ts @@ -1,43 +1,55 @@ import React from 'react' -import { QueryObserverResult } from '../core/types' import { notifyManager } from '../core/notifyManager' import { QueriesObserver } from '../core/queriesObserver' import { useQueryClient } from './QueryClientProvider' import { UseQueryOptions, UseQueryResult } from './types' -import { useIsMounted } from './useIsMounted' export function useQueries(queries: UseQueryOptions[]): UseQueryResult[] { - const isMounted = useIsMounted() + const mountedRef = React.useRef(false) + const [, forceUpdate] = React.useState(0) + const queryClient = useQueryClient() - // Create queries observer - const observerRef = React.useRef() - const observer = - observerRef.current || new QueriesObserver(queryClient, queries) - observerRef.current = observer + const defaultedQueries = queries.map(options => { + const defaultedOptions = queryClient.defaultQueryObserverOptions(options) + + // Make sure the results are already in fetching state before subscribing or updating options + defaultedOptions.optimisticResults = true + + return defaultedOptions + }) - // Update queries - if (observer.hasListeners()) { - observer.setQueries(queries) + const obsRef = React.useRef() + + if (!obsRef.current) { + obsRef.current = new QueriesObserver(queryClient, defaultedQueries) } - const [currentResult, setCurrentResult] = React.useState(() => - observer.getCurrentResult() - ) - - // Subscribe to the observer - React.useEffect( - () => - observer.subscribe( - notifyManager.batchCalls((result: QueryObserverResult[]) => { - if (isMounted()) { - setCurrentResult(result) - } - }) - ), - [observer, isMounted] - ) - - return currentResult + const result = obsRef.current.getOptimisticResult(defaultedQueries) + + React.useEffect(() => { + mountedRef.current = true + + const unsubscribe = obsRef.current!.subscribe( + notifyManager.batchCalls(() => { + if (mountedRef.current) { + forceUpdate(x => x + 1) + } + }) + ) + + return () => { + mountedRef.current = false + unsubscribe() + } + }, []) + + React.useEffect(() => { + // Do not notify on updates because of changes in the options because + // these changes should already be reflected in the optimistic result. + obsRef.current!.setQueries(defaultedQueries, { listeners: false }) + }, [defaultedQueries]) + + return result }