diff --git a/docs/src/pages/docs/api.md b/docs/src/pages/docs/api.md index 5a5eeb625d..a834280c1c 100644 --- a/docs/src/pages/docs/api.md +++ b/docs/src/pages/docs/api.md @@ -27,6 +27,7 @@ const { retryDelay, staleTime, cacheTime, + keepPreviousData, refetchOnWindowFocus, refetchInterval, refetchIntervalInBackground, @@ -112,6 +113,10 @@ const queryInfo = useQuery({ - Optional - If set, this will mark any `initialData` provided as stale and will likely cause it to be refetched on mount - If a function is passed, it will be called only when appropriate to resolve the `initialStale` value. This can be useful if your `initialStale` value is costly to calculate. +- `keepPreviousData: Boolean` + - Optional + - Defaults to `false` + - If set, any previous `data` will be kept when fetching new data because the query key changed. - `refetchOnMount: Boolean` - Optional - Defaults to `true` diff --git a/src/core/config.ts b/src/core/config.ts index d2807a6672..67fa74af9d 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,4 +1,4 @@ -import { stableStringify, identity } from './utils' +import { stableStringify } from './utils' import { ArrayQueryKey, QueryKey, @@ -30,9 +30,6 @@ export const defaultQueryKeySerializerFn: QueryKeySerializerFunction = ( } export const DEFAULT_CONFIG: ReactQueryConfig = { - shared: { - suspense: false, - }, queries: { queryKeySerializerFn: defaultQueryKeySerializerFn, enabled: true, @@ -41,14 +38,7 @@ export const DEFAULT_CONFIG: ReactQueryConfig = { staleTime: 0, cacheTime: 5 * 60 * 1000, refetchOnWindowFocus: true, - refetchInterval: false, - queryFnParamsFilter: identity, refetchOnMount: true, - useErrorBoundary: false, - }, - mutations: { - throwOnError: false, - useErrorBoundary: false, }, } diff --git a/src/core/query.ts b/src/core/query.ts index fbb31e94be..8258baf83d 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -9,7 +9,6 @@ import { Updater, replaceEqualDeep, } from './utils' -import { QueryInstance, OnStateUpdateFunction } from './queryInstance' import { ArrayQueryKey, InfiniteQueryConfig, @@ -19,7 +18,8 @@ import { QueryFunction, QueryStatus, } from './types' -import { QueryCache } from './queryCache' +import type { QueryCache } from './queryCache' +import { QueryObserver, UpdateListener } from './queryObserver' // TYPES @@ -39,7 +39,7 @@ export interface QueryState { isError: boolean isFetched: boolean isFetching: boolean - isFetchingMore?: IsFetchingMoreValue + isFetchingMore: IsFetchingMoreValue isIdle: boolean isLoading: boolean isStale: boolean @@ -111,7 +111,7 @@ export class Query { queryKey: ArrayQueryKey queryHash: string config: QueryConfig - instances: QueryInstance[] + observers: QueryObserver[] state: QueryState shouldContinueRetryOnFocus?: boolean promise?: Promise @@ -131,17 +131,14 @@ export class Query { this.queryKey = init.queryKey this.queryHash = init.queryHash this.notifyGlobalListeners = init.notifyGlobalListeners - this.instances = [] + this.observers = [] this.state = getDefaultState(init.config) if (init.config.infinite) { const infiniteConfig = init.config as InfiniteQueryConfig const infiniteData = (this.state.data as unknown) as TResult[] | undefined - if ( - typeof infiniteData !== 'undefined' && - typeof this.state.canFetchMore === 'undefined' - ) { + if (typeof infiniteData !== 'undefined') { this.fetchMoreVariable = infiniteConfig.getFetchMore( infiniteData[infiniteData.length - 1], infiniteData @@ -154,11 +151,28 @@ export class Query { this.pageVariables = [[...this.queryKey]] } } + + // If the query started with data, schedule + // a stale timeout + if (!isServer && this.state.data) { + this.scheduleStaleTimeout() + + // Simulate a query healing process + this.heal() + + // Schedule for garbage collection in case + // nothing subscribes to this query + this.scheduleGarbageCollection() + } + } + + updateConfig(config: QueryConfig): void { + this.config = config } private dispatch(action: Action): void { this.state = queryReducer(this.state, action) - this.instances.forEach(d => d.onStateUpdate(this.state, action)) + this.observers.forEach(d => d.onQueryUpdate(this.state, action)) this.notifyGlobalListeners(this) } @@ -169,11 +183,7 @@ export class Query { this.clearStaleTimeout() - if (this.state.isStale) { - return - } - - if (this.config.staleTime === Infinity) { + if (this.state.isStale || this.config.staleTime === Infinity) { return } @@ -185,10 +195,6 @@ export class Query { invalidate(): void { this.clearStaleTimeout() - if (!this.queryCache.queries[this.queryHash]) { - return - } - if (this.state.isStale) { return } @@ -197,12 +203,12 @@ export class Query { } scheduleGarbageCollection(): void { - this.clearCacheTimeout() - - if (!this.queryCache.queries[this.queryHash]) { + if (isServer) { return } + this.clearCacheTimeout() + if (this.config.cacheTime === Infinity) { return } @@ -244,9 +250,9 @@ export class Query { delete this.promise } - clearIntervals(): void { - this.instances.forEach(instance => { - instance.clearInterval() + private clearTimersObservers(): void { + this.observers.forEach(observer => { + observer.clearRefetchInterval() }) } @@ -310,19 +316,57 @@ export class Query { this.clearStaleTimeout() this.clearCacheTimeout() this.clearRetryTimeout() - this.clearIntervals() + this.clearTimersObservers() this.cancel() delete this.queryCache.queries[this.queryHash] this.notifyGlobalListeners(this) } + isEnabled(): boolean { + return this.observers.some(observer => observer.config.enabled) + } + + shouldRefetchOnWindowFocus(): boolean { + return ( + this.isEnabled() && + this.state.isStale && + this.observers.some(observer => observer.config.refetchOnWindowFocus) + ) + } + subscribe( - onStateUpdate?: OnStateUpdateFunction - ): QueryInstance { - const instance = new QueryInstance(this, onStateUpdate) - this.instances.push(instance) + listener?: UpdateListener + ): QueryObserver { + const observer = new QueryObserver({ + queryCache: this.queryCache, + queryKey: this.queryKey, + ...this.config, + }) + + observer.subscribe(listener) + + return observer + } + + subscribeObserver(observer: QueryObserver): void { + this.observers.push(observer) this.heal() - return instance + } + + unsubscribeObserver( + observer: QueryObserver, + preventGC?: boolean + ): void { + this.observers = this.observers.filter(x => x !== observer) + + if (!this.observers.length) { + this.cancel() + + if (!preventGC) { + // Schedule garbage collection + this.scheduleGarbageCollection() + } + } } // Set up the core fetcher function @@ -332,7 +376,11 @@ export class Query { ): Promise { try { // Perform the query - const promiseOrValue = fn(...this.config.queryFnParamsFilter!(args)) + const filter = this.config.queryFnParamsFilter + const params = filter ? filter(args) : args + + // Perform the query + const promiseOrValue = fn(...params) this.cancelPromises = () => (promiseOrValue as any)?.cancel?.() @@ -584,6 +632,7 @@ function getDefaultState( error: null, isFetched: false, isFetching: initialStatus === QueryStatus.Loading, + isFetchingMore: false, failureCount: 0, isStale, data: initialData, diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts index 975de3db26..1e6700ee23 100644 --- a/src/core/queryCache.ts +++ b/src/core/queryCache.ts @@ -121,6 +121,15 @@ export class QueryCache { return this.configRef.current } + getDefaultedConfig(config?: QueryConfig) { + return { + ...this.configRef.current.shared!, + ...this.configRef.current.queries!, + queryCache: this, + ...config, + } as QueryConfig + } + subscribe(listener: QueryCacheListener): () => void { this.globalListeners.push(listener) return () => { @@ -195,11 +204,8 @@ export class QueryCache { try { await Promise.all( this.getQueries(predicate, options).map(query => { - if (query.instances.length) { - if ( - refetchActive && - query.instances.some(instance => instance.config.enabled) - ) { + if (query.observers.length) { + if (refetchActive && query.isEnabled()) { return query.fetch() } } else { @@ -226,13 +232,9 @@ export class QueryCache { buildQuery( userQueryKey: QueryKey, - queryConfig: QueryConfig = {} + queryConfig?: QueryConfig ): Query { - const config = { - ...this.configRef.current.shared!, - ...this.configRef.current.queries!, - ...queryConfig, - } as QueryConfig + const config = this.getDefaultedConfig(queryConfig) const [queryHash, queryKey] = config.queryKeySerializerFn!(userQueryKey) @@ -240,7 +242,7 @@ export class QueryCache { if (this.queries[queryHash]) { query = this.queries[queryHash] as Query - query.config = config + query.updateConfig(config) } if (!query) { @@ -254,18 +256,6 @@ export class QueryCache { }, }) - // If the query started with data, schedule - // a stale timeout - if (!isServer && query.state.data) { - query.scheduleStaleTimeout() - - // Simulate a query healing process - query.heal() - // Schedule for garbage collection in case - // nothing subscribes to this query - query.scheduleGarbageCollection() - } - if (!this.config.frozen) { this.queries[queryHash] = query @@ -386,7 +376,7 @@ export class QueryCache { setQueryData( queryKey: QueryKey, updater: Updater, - config: QueryConfig = {} + config?: QueryConfig ) { let query = this.getQuery(queryKey) diff --git a/src/core/queryInstance.ts b/src/core/queryInstance.ts deleted file mode 100644 index fe24717969..0000000000 --- a/src/core/queryInstance.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { uid, isServer, isDocumentVisible, Console } from './utils' -import { Query, QueryState, Action, ActionType } from './query' -import { BaseQueryConfig } from './types' - -// TYPES - -export type OnStateUpdateFunction = ( - state: QueryState -) => void - -// CLASS - -export class QueryInstance { - id: number - config: BaseQueryConfig - - private query: Query - private refetchIntervalId?: number - private stateUpdateListener?: OnStateUpdateFunction - - constructor( - query: Query, - onStateUpdate?: OnStateUpdateFunction - ) { - this.id = uid() - this.stateUpdateListener = onStateUpdate - this.query = query - this.config = {} - } - - clearInterval(): void { - if (this.refetchIntervalId) { - clearInterval(this.refetchIntervalId) - this.refetchIntervalId = undefined - } - } - - updateConfig(config: BaseQueryConfig): void { - const oldConfig = this.config - - // Update the config - this.config = config - - if (!isServer) { - if (oldConfig?.refetchInterval === config.refetchInterval) { - return - } - - this.query.clearIntervals() - - const minInterval = Math.min( - ...this.query.instances.map(d => d.config.refetchInterval || Infinity) - ) - - if ( - !this.refetchIntervalId && - minInterval > 0 && - minInterval < Infinity - ) { - this.refetchIntervalId = setInterval(() => { - if ( - this.query.instances.some(d => d.config.enabled) && - (isDocumentVisible() || - this.query.instances.some( - d => d.config.refetchIntervalInBackground - )) - ) { - this.query.fetch() - } - }, minInterval) - } - } - } - - async run(): Promise { - try { - // Perform the refetch for this query if necessary - if ( - this.query.instances.some(d => d.config.enabled) && // Don't auto refetch if disabled - !(this.config.suspense && this.query.state.isFetched) && // Don't refetch if in suspense mode and the data is already fetched - this.query.state.isStale && // Only refetch if stale - (this.config.refetchOnMount || this.query.instances.length === 1) - ) { - await this.query.fetch() - } - } catch (error) { - Console.error(error) - } - } - - unsubscribe(preventGC?: boolean): void { - this.query.instances = this.query.instances.filter(d => d.id !== this.id) - - if (!this.query.instances.length) { - this.clearInterval() - this.query.cancel() - - if (!preventGC && !isServer) { - // Schedule garbage collection - this.query.scheduleGarbageCollection() - } - } - } - - onStateUpdate( - state: QueryState, - action: Action - ): void { - if (action.type === ActionType.Success && state.isSuccess) { - this.config.onSuccess?.(state.data!) - this.config.onSettled?.(state.data!, null) - } - - if (action.type === ActionType.Error && state.isError) { - this.config.onError?.(state.error!) - this.config.onSettled?.(undefined, state.error!) - } - - this.stateUpdateListener?.(state) - } -} diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts new file mode 100644 index 0000000000..3e22560f85 --- /dev/null +++ b/src/core/queryObserver.ts @@ -0,0 +1,235 @@ +import { getStatusProps, isServer, isDocumentVisible, Console } from './utils' +import type { QueryResult, QueryObserverConfig } from './types' +import type { Query, QueryState, Action, FetchMoreOptions } from './query' + +export type UpdateListener = ( + result: QueryResult +) => void + +export class QueryObserver { + config: QueryObserverConfig + + private currentQuery!: Query + private currentResult!: QueryResult + private previousResult?: QueryResult + private updateListener?: UpdateListener + private refetchIntervalId?: number + private started?: boolean + + constructor(config: QueryObserverConfig) { + this.config = config + + // Bind exposed methods + this.clear = this.clear.bind(this) + this.refetch = this.refetch.bind(this) + this.fetchMore = this.fetchMore.bind(this) + + // Subscribe to query + this.updateQuery() + } + + subscribe(listener?: UpdateListener): () => void { + this.started = true + this.updateListener = listener + this.optionalFetch() + this.updateRefetchInterval() + return this.unsubscribe.bind(this) + } + + unsubscribe(preventGC?: boolean): void { + this.started = false + this.updateListener = undefined + this.clearRefetchInterval() + this.currentQuery.unsubscribeObserver(this, preventGC) + } + + updateConfig(config: QueryObserverConfig): void { + const prevConfig = this.config + this.config = config + + const updated = this.updateQuery() + + // Take no further actions if the observer did not start yet + if (!this.started) { + return + } + + // If we subscribed to a new query, optionally fetch and update refetch + if (updated) { + this.optionalFetch() + this.updateRefetchInterval() + return + } + + // Optionally fetch if the query became enabled + if (config.enabled && !prevConfig.enabled) { + this.optionalFetch() + } + + // Update refetch interval if needed + if ( + config.enabled !== prevConfig.enabled || + config.refetchInterval !== prevConfig.refetchInterval || + config.refetchIntervalInBackground !== + prevConfig.refetchIntervalInBackground + ) { + this.updateRefetchInterval() + } + } + + getCurrentResult(): QueryResult { + return this.currentResult + } + + clear(): void { + return this.currentQuery.clear() + } + + async refetch(): Promise { + this.currentQuery.updateConfig(this.config) + return this.currentQuery.refetch() + } + + async fetchMore( + fetchMoreVariable?: unknown, + options?: FetchMoreOptions + ): Promise { + this.currentQuery.updateConfig(this.config) + return this.currentQuery.fetchMore(fetchMoreVariable, options) + } + + async fetch(): Promise { + this.currentQuery.updateConfig(this.config) + return this.currentQuery.fetch().catch(error => { + Console.error(error) + return undefined + }) + } + + private optionalFetch(): void { + if ( + this.config.enabled && // Don't auto refetch if disabled + !(this.config.suspense && this.currentResult.isFetched) && // Don't refetch if in suspense mode and the data is already fetched + this.currentResult.isStale && // Only refetch if stale + (this.config.refetchOnMount || this.currentQuery.observers.length === 1) + ) { + this.fetch() + } + } + + private updateRefetchInterval(): void { + if (isServer) { + return + } + + this.clearRefetchInterval() + + if ( + !this.config.enabled || + !this.config.refetchInterval || + this.config.refetchInterval < 0 || + this.config.refetchInterval === Infinity + ) { + return + } + + this.refetchIntervalId = setInterval(() => { + if (this.config.refetchIntervalInBackground || isDocumentVisible()) { + this.fetch() + } + }, this.config.refetchInterval) + } + + clearRefetchInterval(): void { + if (this.refetchIntervalId) { + clearInterval(this.refetchIntervalId) + this.refetchIntervalId = undefined + } + } + + private createResult(): QueryResult { + const { currentQuery, previousResult, config } = this + + const { + canFetchMore, + error, + failureCount, + isFetched, + isFetching, + isFetchingMore, + isLoading, + isStale, + } = currentQuery.state + + let { data, status, updatedAt } = currentQuery.state + + // Keep previous data if needed + if (config.keepPreviousData && isLoading && previousResult?.isSuccess) { + data = previousResult.data + updatedAt = previousResult.updatedAt + status = previousResult.status + } + + return { + ...getStatusProps(status), + canFetchMore, + clear: this.clear, + data, + error, + failureCount, + fetchMore: this.fetchMore, + isFetched, + isFetching, + isFetchingMore, + isStale, + query: currentQuery, + refetch: this.refetch, + updatedAt, + } + } + + private updateQuery(): boolean { + const prevQuery = this.currentQuery + + // Remove the initial data when there is an existing query + // because this data should not be used for a new query + const config = prevQuery + ? { ...this.config, initialData: undefined } + : this.config + + const newQuery = config.queryCache!.buildQuery(config.queryKey, config) + + if (newQuery === prevQuery) { + return false + } + + this.previousResult = this.currentResult + prevQuery?.unsubscribeObserver(this) + this.currentQuery = newQuery + newQuery.subscribeObserver(this) + this.currentResult = this.createResult() + + return true + } + + onQueryUpdate( + _state: QueryState, + action: Action + ): void { + this.currentResult = this.createResult() + + const { data, error, isSuccess, isError } = this.currentResult + + if (action.type === 'Success' && isSuccess) { + this.config.onSuccess?.(data!) + this.config.onSettled?.(data!, null) + this.updateRefetchInterval() + } else if (action.type === 'Error' && isError) { + this.config.onError?.(error!) + this.config.onSettled?.(undefined, error!) + this.updateRefetchInterval() + } + + this.updateListener?.(this.currentResult) + } +} diff --git a/src/core/setFocusHandler.ts b/src/core/setFocusHandler.ts index 08b1e6a7be..517e39924d 100644 --- a/src/core/setFocusHandler.ts +++ b/src/core/setFocusHandler.ts @@ -11,15 +11,7 @@ const onWindowFocus: FocusHandler = () => { queryCaches.forEach(queryCache => queryCache .invalidateQueries(query => { - if (!query.instances.length) { - return false - } - - if (!query.instances.some(instance => instance.config.enabled)) { - return false - } - - if (!query.state.isStale) { + if (!query.shouldRefetchOnWindowFocus()) { return false } @@ -28,7 +20,7 @@ const onWindowFocus: FocusHandler = () => { delete query.promise } - return Boolean(query.config.refetchOnWindowFocus) + return true }) .catch(Console.error) ) diff --git a/src/core/types.ts b/src/core/types.ts index d470cccf75..a3f1ec25d7 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,4 +1,5 @@ -import { Query, FetchMoreOptions } from './query' +import type { Query, FetchMoreOptions } from './query' +import type { QueryCache } from './queryCache' export type QueryKeyObject = | object @@ -56,32 +57,86 @@ export interface BaseQueryConfig { retryDelay?: number | ((retryAttempt: number) => number) staleTime?: number cacheTime?: number - refetchInterval?: false | number + isDataEqual?: (oldData: unknown, newData: unknown) => boolean + queryFn?: QueryFunction + queryKey?: QueryKey + queryKeySerializerFn?: QueryKeySerializerFunction + queryFnParamsFilter?: (args: ArrayQueryKey) => ArrayQueryKey + initialData?: TResult | InitialDataFunction + initialStale?: boolean | InitialStaleFunction + infinite?: true +} + +export interface QueryObserverConfig + extends BaseQueryConfig { + /** + * Set this to `false` to disable automatic refetching when the query mounts or changes query keys. + * To refetch the query, use the `refetch` method returned from the `useQuery` instance. + * Defaults to `true`. + */ + enabled?: boolean | unknown + /** + * If set to a number, the query will continuously refetch at this frequency in milliseconds. + * Defaults to `false`. + */ + refetchInterval?: number + /** + * If set to `true`, the query will continue to refetch while their tab/window is in the background. + * Defaults to `false`. + */ refetchIntervalInBackground?: boolean + /** + * Set this to `true` or `false` to enable/disable automatic refetching on window focus for this query. + * Defaults to `true`. + */ refetchOnWindowFocus?: boolean + /** + * If set to `false`, will disable additional instances of a query to trigger background refetches. + * Defaults to `true`. + */ refetchOnMount?: boolean + /** + * This callback will fire any time the query successfully fetches new data. + */ onSuccess?: (data: TResult) => void + /** + * This callback will fire if the query encounters an error and will be passed the error. + */ onError?: (err: TError) => void + /** + * This callback will fire any time the query is either successfully fetched or errors and be passed either the data or error. + */ onSettled?: (data: TResult | undefined, error: TError | null) => void - isDataEqual?: (oldData: unknown, newData: unknown) => boolean + /** + * Whether errors should be thrown instead of setting the `error` property. + * Defaults to `false`. + */ useErrorBoundary?: boolean - queryFn?: QueryFunction - queryKeySerializerFn?: QueryKeySerializerFunction - queryFnParamsFilter?: (args: ArrayQueryKey) => ArrayQueryKey + /** + * If set to `true`, the query will suspend when `status === 'loading'` + * and throw errors when `status === 'error'`. + * Defaults to `false`. + */ suspense?: boolean - initialData?: TResult | InitialDataFunction - initialStale?: boolean | InitialStaleFunction - infinite?: true + /** + * Set this to `true` to keep the previous `data` when fetching based on a new query key. + * Defaults to `false`. + */ + keepPreviousData?: boolean + /** + * By default the query cache from the context is used, but a different cache can be specified. + */ + queryCache?: QueryCache } export interface QueryConfig - extends BaseQueryConfig {} + extends QueryObserverConfig {} export interface PaginatedQueryConfig - extends BaseQueryConfig {} + extends QueryObserverConfig {} export interface InfiniteQueryConfig - extends BaseQueryConfig { + extends QueryObserverConfig { getFetchMore: (lastPage: TResult, allPages: TResult[]) => unknown } @@ -95,25 +150,31 @@ export enum QueryStatus { } export interface QueryResultBase { - status: QueryStatus + canFetchMore: boolean | undefined + clear: () => void + data: TResult | undefined error: TError | null - isLoading: boolean - isSuccess: boolean + failureCount: number + fetchMore: ( + fetchMoreVariable?: unknown, + options?: FetchMoreOptions + ) => Promise isError: boolean - isIdle: boolean + isFetched: boolean isFetching: boolean + isFetchingMore?: IsFetchingMoreValue + isIdle: boolean + isLoading: boolean isStale: boolean - failureCount: number + isSuccess: boolean query: Query - updatedAt: number refetch: () => Promise - clear: () => void + status: QueryStatus + updatedAt: number } export interface QueryResult - extends QueryResultBase { - data: TResult | undefined -} + extends QueryResultBase {} export interface PaginatedQueryResult extends QueryResultBase { @@ -122,15 +183,7 @@ export interface PaginatedQueryResult } export interface InfiniteQueryResult - extends QueryResultBase { - data: TResult[] | undefined - isFetchingMore?: IsFetchingMoreValue - canFetchMore: boolean | undefined - fetchMore: ( - fetchMoreVariable?: unknown, - options?: FetchMoreOptions - ) => Promise | undefined -} + extends QueryResultBase {} export interface MutateConfig< TResult, @@ -205,7 +258,7 @@ export interface ReactQuerySharedConfig { } export interface ReactQueryQueriesConfig - extends BaseQueryConfig {} + extends QueryObserverConfig {} export interface ReactQueryMutationsConfig< TResult, diff --git a/src/core/utils.ts b/src/core/utils.ts index 751029fcc1..d857606f9c 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -26,9 +26,6 @@ export const isServer = typeof window === 'undefined' export function noop(): void { return void 0 } -export function identity(d: T): T { - return d -} export let Console: ConsoleObject = console || { error: noop, warn: noop, @@ -121,7 +118,7 @@ export function getQueryArgs( options = args[3] } - config = config || {} + config = config ? { queryKey, ...config } : { queryKey } if (queryFn) { config = { ...config, queryFn } diff --git a/src/react/tests/suspense.test.tsx b/src/react/tests/suspense.test.tsx index 5dbff7af1d..3bc14f3ae4 100644 --- a/src/react/tests/suspense.test.tsx +++ b/src/react/tests/suspense.test.tsx @@ -57,12 +57,12 @@ describe("useQuery's in Suspense mode", () => { fireEvent.click(rendered.getByLabelText('toggle')) await waitFor(() => rendered.getByText('rendered')) - expect(queryCache.getQuery(key)?.instances.length).toBe(1) + expect(queryCache.getQuery(key)?.observers.length).toBe(1) fireEvent.click(rendered.getByLabelText('toggle')) expect(rendered.queryByText('rendered')).toBeNull() - expect(queryCache.getQuery(key)?.instances.length).toBe(0) + expect(queryCache.getQuery(key)?.observers.length).toBe(0) }) it('should call onSuccess on the first successful call', async () => { diff --git a/src/react/tests/useInfiniteQuery.test.tsx b/src/react/tests/useInfiniteQuery.test.tsx index addf1266b6..df837e6111 100644 --- a/src/react/tests/useInfiniteQuery.test.tsx +++ b/src/react/tests/useInfiniteQuery.test.tsx @@ -60,14 +60,16 @@ describe('useInfiniteQuery', () => { await waitFor(() => rendered.getByText('Status: success')) expect(states[0]).toEqual({ + canFetchmore: undefined, clear: expect.any(Function), data: undefined, error: null, failureCount: 0, fetchMore: expect.any(Function), isError: false, + isFetched: false, isFetching: true, - isFetchingMore: undefined, + isFetchingMore: false, isIdle: false, isLoading: true, isStale: true, @@ -91,8 +93,9 @@ describe('useInfiniteQuery', () => { error: null, failureCount: 0, fetchMore: expect.any(Function), - isFetchingMore: undefined, + isFetchingMore: false, isError: false, + isFetched: true, isFetching: false, isIdle: false, isLoading: false, @@ -105,6 +108,95 @@ describe('useInfiniteQuery', () => { }) }) + it('should keep the previous data when keepPreviousData is set', async () => { + const key = queryKey() + const states: InfiniteQueryResult[] = [] + + function Page() { + const [order, setOrder] = React.useState('desc') + + const state = useInfiniteQuery( + [key, order], + async (_key, order, page = 0) => { + await sleep(10) + return `${page}-${order}` + }, + { + getFetchMore: (_lastGroup, _allGroups) => 1, + keepPreviousData: true, + } + ) + + states.push(state) + + const { fetchMore } = state + + React.useEffect(() => { + setTimeout(() => { + fetchMore() + }, 20) + setTimeout(() => { + setOrder('asc') + }, 40) + }, [fetchMore]) + + return null + } + + render() + + await waitFor(() => expect(states.length).toBe(8)) + + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + isFetchingMore: false, + isSuccess: false, + }) + expect(states[1]).toMatchObject({ + data: ['0-desc'], + isFetching: false, + isFetchingMore: false, + isSuccess: true, + }) + expect(states[2]).toMatchObject({ + data: ['0-desc'], + isFetching: true, + isFetchingMore: 'next', + isSuccess: true, + }) + expect(states[3]).toMatchObject({ + data: ['0-desc'], + isFetching: true, + isFetchingMore: 'next', + isSuccess: true, + }) + expect(states[4]).toMatchObject({ + data: ['0-desc'], + isFetching: true, + isFetchingMore: false, + isSuccess: true, + }) + expect(states[5]).toMatchObject({ + data: ['0-desc', '1-desc'], + isFetching: false, + isFetchingMore: false, + isSuccess: true, + }) + expect(states[6]).toMatchObject({ + data: ['0-desc', '1-desc'], + isFetching: true, + isFetchingMore: false, + isSuccess: true, + }) + expect(states[7]).toMatchObject({ + data: ['0-asc'], + isFetching: false, + isFetchingMore: false, + isSuccess: true, + }) + }) + it('should allow you to fetch more pages', async () => { const key = queryKey() @@ -182,7 +274,7 @@ describe('useInfiniteQuery', () => { rendered.getByText('Page 0: 0') }) - await fireEvent.click(rendered.getByText('Load More')) + fireEvent.click(rendered.getByText('Load More')) await waitFor(() => rendered.getByText('Loading more...')) diff --git a/src/react/tests/usePaginatedQuery.test.tsx b/src/react/tests/usePaginatedQuery.test.tsx index 15ad1227f9..def065b90c 100644 --- a/src/react/tests/usePaginatedQuery.test.tsx +++ b/src/react/tests/usePaginatedQuery.test.tsx @@ -33,11 +33,16 @@ describe('usePaginatedQuery', () => { await waitFor(() => rendered.getByText('Status: success')) expect(states[0]).toEqual({ + canFetchMore: undefined, clear: expect.any(Function), + data: undefined, error: null, failureCount: 0, + fetchMore: expect.any(Function), isError: false, + isFetched: false, isFetching: true, + isFetchingMore: false, isIdle: false, isLoading: true, isStale: true, @@ -51,11 +56,16 @@ describe('usePaginatedQuery', () => { }) expect(states[1]).toEqual({ + canFetchMore: undefined, clear: expect.any(Function), + data: 1, error: null, failureCount: 0, + fetchMore: expect.any(Function), isError: false, + isFetched: true, isFetching: false, + isFetchingMore: false, isIdle: false, isLoading: false, isStale: true, diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index 2110bceebf..6d8e08e58f 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -98,12 +98,16 @@ describe('useQuery', () => { await waitFor(() => rendered.getByText('Status: success')) expect(states[0]).toEqual({ + canFetchMore: undefined, clear: expect.any(Function), data: undefined, error: null, failureCount: 0, + fetchMore: expect.any(Function), isError: false, + isFetched: false, isFetching: true, + isFetchingMore: false, isIdle: false, isLoading: true, isStale: true, @@ -115,12 +119,16 @@ describe('useQuery', () => { }) expect(states[1]).toEqual({ + canFetchMore: undefined, clear: expect.any(Function), data: 'test', error: null, failureCount: 0, + fetchMore: expect.any(Function), isError: false, + isFetched: true, isFetching: false, + isFetchingMore: false, isIdle: false, isLoading: false, isStale: true, @@ -163,12 +171,16 @@ describe('useQuery', () => { await waitFor(() => rendered.getByText('Status: error')) expect(states[0]).toEqual({ + canFetchMore: undefined, clear: expect.any(Function), data: undefined, error: null, failureCount: 0, + fetchMore: expect.any(Function), isError: false, + isFetched: false, isFetching: true, + isFetchingMore: false, isIdle: false, isLoading: true, isStale: true, @@ -180,12 +192,16 @@ describe('useQuery', () => { }) expect(states[1]).toEqual({ + canFetchMore: undefined, clear: expect.any(Function), data: undefined, error: null, failureCount: 1, + fetchMore: expect.any(Function), isError: false, + isFetched: false, isFetching: true, + isFetchingMore: false, isIdle: false, isLoading: true, isStale: true, @@ -197,12 +213,16 @@ describe('useQuery', () => { }) expect(states[2]).toEqual({ + canFetchMore: undefined, clear: expect.any(Function), data: undefined, error: 'rejected', failureCount: 2, + fetchMore: expect.any(Function), isError: true, + isFetched: true, isFetching: false, + isFetchingMore: false, isIdle: false, isLoading: false, isStale: true, @@ -266,6 +286,113 @@ describe('useQuery', () => { expect(newTodos).not.toBe(todos) expect(newTodo1).toBe(todo1) expect(newTodo2).not.toBe(todo2) + + return null + }) + + it('should keep the previous data when keepPreviousData is set', async () => { + const key = queryKey() + const states: QueryResult[] = [] + + function Page() { + const [count, setCount] = React.useState(0) + + const state = useQuery( + [key, count], + async () => { + await sleep(10) + return count + }, + { keepPreviousData: true } + ) + + states.push(state) + + React.useEffect(() => { + setTimeout(() => { + setCount(1) + }, 20) + }, []) + + return null + } + + render() + + await waitFor(() => expect(states.length).toBe(4)) + + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + isSuccess: false, + }) + expect(states[1]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + }) + expect(states[2]).toMatchObject({ + data: 0, + isFetching: true, + isSuccess: true, + }) + expect(states[3]).toMatchObject({ + data: 1, + isFetching: false, + isSuccess: true, + }) + }) + + it('should use the correct query function when components use different configurations', async () => { + const key = queryKey() + const states: QueryResult[] = [] + + function FirstComponent() { + const state = useQuery(key, () => 1) + const refetch = state.refetch + + states.push(state) + + React.useEffect(() => { + setTimeout(() => { + refetch() + }, 10) + }, [refetch]) + + return null + } + + function SecondComponent() { + useQuery(key, () => 2) + return null + } + + function Page() { + return ( + <> + + + + ) + } + + render() + + await waitFor(() => expect(states.length).toBe(4)) + + expect(states[0]).toMatchObject({ + data: undefined, + }) + expect(states[1]).toMatchObject({ + data: 1, + }) + expect(states[2]).toMatchObject({ + data: 1, + }) + // This state should be 1 instead of 2 + expect(states[3]).toMatchObject({ + data: 1, + }) }) // See https://github.com/tannerlinsley/react-query/issues/137 diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts index 598f03e2be..1910b29db2 100644 --- a/src/react/useBaseQuery.ts +++ b/src/react/useBaseQuery.ts @@ -1,70 +1,49 @@ import React from 'react' -import { useQueryCache } from './ReactQueryCacheProvider' import { useRerenderer } from './utils' -import { QueryInstance } from '../core/queryInstance' -import { QueryConfig, QueryKey, QueryResultBase } from '../core/types' +import { QueryObserver } from '../core/queryObserver' +import { QueryResultBase, QueryObserverConfig } from '../core/types' export function useBaseQuery( - queryKey: QueryKey, - config: QueryConfig = {} + config: QueryObserverConfig = {} ): QueryResultBase { // Make a rerender function const rerender = useRerenderer() - // Get the query cache - const queryCache = useQueryCache() - - // Build the query for use - const query = queryCache.buildQuery(queryKey, config) - const state = query.state - - // Create a query instance ref - const instanceRef = React.useRef>() - - // Subscribe to the query when the subscribe function changes - React.useEffect(() => { - const instance = query.subscribe(() => { - rerender() - }) - - instanceRef.current = instance - - // Unsubscribe when things change - return () => instance.unsubscribe() - }, [query, rerender]) - - // Always update the config - React.useEffect(() => { - instanceRef.current?.updateConfig(config) - }) + // Create query observer + const observerRef = React.useRef>() + const firstRender = !observerRef.current + const observer = observerRef.current || new QueryObserver(config) + observerRef.current = observer + + // Subscribe to the observer + React.useEffect( + () => + observer.subscribe(() => { + Promise.resolve().then(rerender) + }), + [observer, rerender] + ) + + // Update config + if (!firstRender) { + observer.updateConfig(config) + } - const enabledBool = Boolean(config.enabled) + const result = observer.getCurrentResult() - // Run the instance when the query or enabled change - React.useEffect(() => { - if (enabledBool && query) { - // Just for change detection + // Handle suspense + if (config.suspense || config.useErrorBoundary) { + if (result.isError && result.query.state.throwInErrorBoundary) { + throw result.error } - instanceRef.current?.run() - }, [enabledBool, query]) - - const clear = React.useMemo(() => query.clear.bind(query), [query]) - const refetch = React.useMemo(() => query.refetch.bind(query), [query]) - return { - clear, - error: state.error, - failureCount: state.failureCount, - isError: state.isError, - isFetching: state.isFetching, - isIdle: state.isIdle, - isLoading: state.isLoading, - isStale: state.isStale, - isSuccess: state.isSuccess, - query, - refetch, - status: state.status, - updatedAt: state.updatedAt, + if (config.enabled && config.suspense && !result.isSuccess) { + throw observer.fetch().finally(() => { + observer.unsubscribe(true) + }) + } } + + return result } diff --git a/src/react/useInfiniteQuery.ts b/src/react/useInfiniteQuery.ts index 133f121aec..3dec137c4e 100644 --- a/src/react/useInfiniteQuery.ts +++ b/src/react/useInfiniteQuery.ts @@ -1,7 +1,4 @@ -import React from 'react' - import { useBaseQuery } from './useBaseQuery' -import { handleSuspense } from './utils' import { InfiniteQueryConfig, InfiniteQueryResult, @@ -72,24 +69,7 @@ export function useInfiniteQuery( export function useInfiniteQuery( ...args: any[] ): InfiniteQueryResult { - const [queryKey, config] = useQueryArgs(args) - - config.infinite = true - - const result = useBaseQuery(queryKey, config) - const query = result.query - const state = result.query.state - - handleSuspense(config, result) - - const fetchMore = React.useMemo(() => query.fetchMore.bind(query), [query]) - - return { - ...result, - data: state.data, - canFetchMore: state.canFetchMore, - fetchMore, - isFetching: state.isFetching, - isFetchingMore: state.isFetchingMore, - } + let config = useQueryArgs(args)[1] + config = { ...config, infinite: true } + return useBaseQuery(config) } diff --git a/src/react/usePaginatedQuery.ts b/src/react/usePaginatedQuery.ts index 53e08da5d1..76048d2dc5 100644 --- a/src/react/usePaginatedQuery.ts +++ b/src/react/usePaginatedQuery.ts @@ -1,8 +1,4 @@ -import React from 'react' - import { useBaseQuery } from './useBaseQuery' -import { handleSuspense } from './utils' -import { getStatusProps } from '../core/utils' import { PaginatedQueryConfig, PaginatedQueryResult, @@ -10,7 +6,6 @@ import { QueryKeyWithoutArray, QueryKeyWithoutObject, QueryKeyWithoutObjectAndArray, - QueryStatus, TupleQueryFunction, TupleQueryKey, } from '../core/types' @@ -79,56 +74,13 @@ export function usePaginatedQuery( export function usePaginatedQuery( ...args: any[] ): PaginatedQueryResult { - const [queryKey, config] = useQueryArgs(args) - - // Keep track of the latest data result - const lastDataRef = React.useRef() - - // If latestData is there, don't use initialData - if (typeof lastDataRef.current !== 'undefined') { - delete config.initialData - } - - // Make the query as normal - const result = useBaseQuery(queryKey, config) - - // If the query is disabled, get rid of the latest data - if (!result.query.config.enabled) { - lastDataRef.current = undefined - } - - // Get the real data and status from the query - const { data: latestData, status } = result.query.state - - // If the real query succeeds, and there is data in it, - // update the latest data - React.useEffect(() => { - if (status === QueryStatus.Success && typeof latestData !== 'undefined') { - lastDataRef.current = latestData - } - }, [latestData, status]) - - // Resolved data should be either the real data we're waiting on - // or the latest placeholder data - let resolvedData = latestData - if (typeof resolvedData === 'undefined') { - resolvedData = lastDataRef.current - } - - // If we have any data at all from either, we - // need to make sure the status is success, even though - // the real query may still be loading - if (typeof resolvedData !== 'undefined') { - const overrides = getStatusProps(QueryStatus.Success) - Object.assign(result.query.state, overrides) - Object.assign(result, overrides) - } - - handleSuspense(config, result) - + let config = useQueryArgs(args)[1] + config = { ...config, keepPreviousData: true } + const result = useBaseQuery(config) return { ...result, - resolvedData, - latestData, + resolvedData: result.data, + latestData: + result.query.state.data === result.data ? result.data : undefined, } } diff --git a/src/react/useQuery.ts b/src/react/useQuery.ts index 164c2a3275..c0d9fbd33c 100644 --- a/src/react/useQuery.ts +++ b/src/react/useQuery.ts @@ -1,5 +1,4 @@ import { useBaseQuery } from './useBaseQuery' -import { handleSuspense } from './utils' import { QueryConfig, QueryKey, @@ -63,13 +62,6 @@ export function useQuery( export function useQuery( ...args: any[] ): QueryResult { - const [queryKey, config] = useQueryArgs(args) - const result = useBaseQuery(queryKey, config) - - handleSuspense(config, result) - - return { - ...result, - data: result.query.state.data, - } + const config = useQueryArgs(args)[1] + return useBaseQuery(config) } diff --git a/src/react/useQueryArgs.ts b/src/react/useQueryArgs.ts index 502bbd70b9..064c2afdb5 100644 --- a/src/react/useQueryArgs.ts +++ b/src/react/useQueryArgs.ts @@ -1,10 +1,13 @@ import { getQueryArgs } from '../core/utils' import { useConfigContext } from './ReactQueryConfigProvider' import { QueryConfig, QueryKey } from '../core/types' +import { useQueryCache } from './ReactQueryCacheProvider' export function useQueryArgs( args: any[] ): [QueryKey, QueryConfig, TOptions] { + const queryCache = useQueryCache() + const configContext = useConfigContext() const [queryKey, config, options] = getQueryArgs( @@ -12,11 +15,12 @@ export function useQueryArgs( ) // Build the final config - const configWithContext = { + const resolvedConfig = { ...configContext.shared, ...configContext.queries, + queryCache, ...config, } as QueryConfig - return [queryKey, configWithContext, options] + return [queryKey, resolvedConfig, options] } diff --git a/src/react/utils.ts b/src/react/utils.ts index ce658fb7d0..44d4f29b43 100644 --- a/src/react/utils.ts +++ b/src/react/utils.ts @@ -1,7 +1,6 @@ import React from 'react' import { uid, isServer } from '../core/utils' -import { QueryResultBase, BaseQueryConfig, QueryStatus } from '../core/types' export function useUid(): number { const ref = React.useRef(0) @@ -39,35 +38,3 @@ export function useRerenderer() { const rerender = useMountedCallback(React.useState()[1]) return React.useCallback(() => rerender({}), [rerender]) } - -export function handleSuspense( - config: BaseQueryConfig, - result: QueryResultBase -) { - const { error, query } = result - const { state } = query - - if (config.suspense || config.useErrorBoundary) { - if (state.status === QueryStatus.Error && state.throwInErrorBoundary) { - throw error - } - - if ( - config.suspense && - state.status !== QueryStatus.Success && - config.enabled - ) { - const instance = query.subscribe() - - instance.updateConfig({ - ...config, - onSettled: (data, error) => { - instance.unsubscribe(true) - config.onSettled?.(data, error) - }, - }) - - throw query.fetch() - } - } -}