diff --git a/docs/src/pages/docs/api.md b/docs/src/pages/docs/api.md index 8e6353187a..b19bd2612e 100644 --- a/docs/src/pages/docs/api.md +++ b/docs/src/pages/docs/api.md @@ -7,40 +7,41 @@ title: API Reference ```js const { - status, - isIdle, - isLoading, - isSuccess, - isError, + clear, data, error, - isStale, - isFetching, failureCount, + isError, + isFetching, + isIdle, + isLoading, + isStale, + isSuccess, refetch, - clear, + status, } = useQuery(queryKey, queryFn?, { - suspense, - queryKeySerializerFn, - enabled, - retry, - retryDelay, - staleTime, cacheTime, + enabled, + initialData, + initialStale, + isDataEqual, keepPreviousData, - refetchOnWindowFocus, - refetchOnReconnect, + notifyOnStatusChange, + onError, + onSettled, + onSuccess, + queryFnParamsFilter, + queryKeySerializerFn, refetchInterval, refetchIntervalInBackground, - queryFnParamsFilter, refetchOnMount, + refetchOnReconnect, + refetchOnWindowFocus, + retry, + retryDelay, + staleTime, structuralSharing, - isDataEqual, - onError, - onSuccess, - onSettled, - initialData, - initialStale, + suspense, useErrorBoundary, }) @@ -96,6 +97,11 @@ const queryInfo = useQuery({ - `refetchOnReconnect: Boolean` - Optional - Set this to `true` or `false` to enable/disable automatic refetching on reconnect for this query. +- `notifyOnStatusChange: Boolean` + - Optional + - Whether a change to the query status should re-render a component. + - If set to `false`, the component will only re-render when the actual `data` or `error` changes. + - Defaults to `true`. - `onSuccess: Function(data) => data` - Optional - This function will fire any time the query successfully fetches new data. diff --git a/docs/src/pages/docs/comparison.md b/docs/src/pages/docs/comparison.md index 68c7985a0a..53619eee32 100644 --- a/docs/src/pages/docs/comparison.md +++ b/docs/src/pages/docs/comparison.md @@ -19,7 +19,6 @@ Feature/Capability Key: | Supported Query Keys | JSON | JSON | GraphQL Query | | Query Key Change Detection | Deep Compare (Serialization) | Referential Equality (===) | Deep Compare (Serialization) | | Query Data Memoization Level | Query + Structural Sharing | Query | Query + Entity + Structural Sharing | -| Stale While Revalidate | Server-Side + Client-Side | Server-Side | None | | Bundle Size | [![][bp-react-query]][bpl-react-query] | [![][bp-swr]][bpl-swr] | [![][bp-apollo]][bpl-apollo] | | Queries | ✅ | ✅ | ✅ | | Caching | ✅ | ✅ | ✅ | @@ -38,6 +37,8 @@ Feature/Capability Key: | Prefetching APIs | ✅ | 🔶 | ✅ | | Query Cancellation | ✅ | 🛑 | 🛑 | | Partial Query Matching2 | ✅ | 🛑 | 🛑 | +| Stale While Revalidate | ✅ | ✅ | 🛑 | +| Stale Time Configuration | ✅ | 🛑 | 🛑 | | Window Focus Refetching | ✅ | ✅ | 🛑 | | Network Status Refetching | ✅ | ✅ | ✅ | | Automatic Refetch after Mutation3 | 🔶 | 🔶 | ✅ | diff --git a/src/core/config.ts b/src/core/config.ts index 55b639c208..801a2cb264 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -60,6 +60,7 @@ export const DEFAULT_CONFIG: ReactQueryConfig = { refetchOnWindowFocus: true, refetchOnReconnect: true, refetchOnMount: true, + notifyOnStatusChange: true, structuralSharing: true, }, } diff --git a/src/core/query.ts b/src/core/query.ts index eaad23a613..2615123942 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -409,7 +409,7 @@ export class Query { const config = this.config // Check if there is a query function - if (!config.queryFn) { + if (typeof config.queryFn !== 'function') { return } diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts index 3c8c63dde1..74eaf11f46 100644 --- a/src/core/queryCache.ts +++ b/src/core/queryCache.ts @@ -313,7 +313,6 @@ export class QueryCache { if (options?.throwOnError) { throw error } - return } } diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 505308ed0c..eb6c3c699f 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -217,22 +217,16 @@ export class QueryObserver { } private createResult(): QueryResult { - const { currentResult, currentQuery, previousResult, config } = this - - const { - canFetchMore, - error, - failureCount, - isFetched, - isFetching, - isFetchingMore, - isLoading, - } = currentQuery.state - - let { data, status, updatedAt } = currentQuery.state + const { currentQuery, currentResult, previousResult, config } = this + const { state } = currentQuery + let { data, status, updatedAt } = state // Keep previous data if needed - if (config.keepPreviousData && isLoading && previousResult?.isSuccess) { + if ( + config.keepPreviousData && + state.isLoading && + previousResult?.isSuccess + ) { data = previousResult.data updatedAt = previousResult.updatedAt status = previousResult.status @@ -256,15 +250,15 @@ export class QueryObserver { return { ...getStatusProps(status), - canFetchMore, + canFetchMore: state.canFetchMore, clear: this.clear, data, - error, - failureCount, + error: state.error, + failureCount: state.failureCount, fetchMore: this.fetchMore, - isFetched, - isFetching, - isFetchingMore, + isFetched: state.isFetched, + isFetching: state.isFetching, + isFetchingMore: state.isFetchingMore, isStale, query: currentQuery, refetch: this.refetch, @@ -303,20 +297,35 @@ export class QueryObserver { _state: QueryState, action: Action ): void { - this.currentResult = this.createResult() + const { config } = this - const { data, error, isSuccess, isError } = this.currentResult + // Store current result and get new result + const prevResult = this.currentResult + this.currentResult = this.createResult() + const result = this.currentResult - if (action.type === 'Success' && isSuccess) { - this.config.onSuccess?.(data!) - this.config.onSettled?.(data!, null) + // We need to check the action because the state could have + // transitioned from success to success in case of `setQueryData`. + if (action.type === 'Success' && result.isSuccess) { + config.onSuccess?.(result.data!) + config.onSettled?.(result.data!, null) this.updateTimers() - } else if (action.type === 'Error' && isError) { - this.config.onError?.(error!) - this.config.onSettled?.(undefined, error!) + } else if (action.type === 'Error' && result.isError) { + config.onError?.(result.error!) + config.onSettled?.(undefined, result.error!) this.updateTimers() } - this.updateListener?.(this.currentResult) + // Decide if we need to notify the listener + const notify = + // Always notify on data or error change + result.data !== prevResult.data || + result.error !== prevResult.error || + // Maybe notify on other changes + config.notifyOnStatusChange + + if (notify) { + this.updateListener?.(result) + } } } diff --git a/src/core/types.ts b/src/core/types.ts index 4883d4068a..fccead863f 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -105,6 +105,12 @@ export interface QueryObserverConfig< * Defaults to `true`. */ refetchOnMount?: boolean + /** + * Whether a change to the query status should re-render a component. + * If set to `false`, the component will only re-render when the actual `data` or `error` changes. + * Defaults to `true`. + */ + notifyOnStatusChange?: boolean /** * This callback will fire any time the query successfully fetches new data. */ diff --git a/src/react/tests/useInfiniteQuery.test.tsx b/src/react/tests/useInfiniteQuery.test.tsx index b5d3552cb8..86e0d06777 100644 --- a/src/react/tests/useInfiniteQuery.test.tsx +++ b/src/react/tests/useInfiniteQuery.test.tsx @@ -60,7 +60,7 @@ describe('useInfiniteQuery', () => { await waitFor(() => rendered.getByText('Status: success')) expect(states[0]).toEqual({ - canFetchmore: undefined, + canFetchMore: undefined, clear: expect.any(Function), data: undefined, error: null, diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index 2307b4efe9..be32275c1c 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -7,6 +7,7 @@ import { queryKey, mockVisibilityState, mockConsoleError, + waitForMs, } from './utils' import { useQuery } from '..' import { queryCache, QueryResult } from '../../core' @@ -468,7 +469,7 @@ describe('useQuery', () => { await queryCache.prefetchQuery(key, () => 'prefetch') - await sleep(10) + await sleep(40) function FirstComponent() { const state = useQuery(key, () => 'one', { @@ -480,7 +481,7 @@ describe('useQuery', () => { function SecondComponent() { const state = useQuery(key, () => 'two', { - staleTime: 5, + staleTime: 20, }) states2.push(state) return null @@ -543,6 +544,76 @@ describe('useQuery', () => { }) }) + it('should re-render when a query becomes stale', async () => { + const key = queryKey() + const states: QueryResult[] = [] + + function Page() { + const state = useQuery(key, () => 'test', { + staleTime: 50, + }) + states.push(state) + return null + } + + render() + + await waitFor(() => expect(states.length).toBe(3)) + + expect(states[0]).toMatchObject({ + isStale: true, + }) + expect(states[1]).toMatchObject({ + isStale: false, + }) + expect(states[2]).toMatchObject({ + isStale: true, + }) + }) + + it('should not re-render when a query status changes and notifyOnStatusChange is false', async () => { + const key = queryKey() + const states: QueryResult[] = [] + + function Page() { + const state = useQuery( + key, + async () => { + await sleep(5) + return 'test' + }, + { + notifyOnStatusChange: false, + } + ) + + states.push(state) + + const { refetch } = state + + React.useEffect(() => { + setTimeout(refetch, 10) + }, [refetch]) + return null + } + + render() + + await waitForMs(30) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: undefined, + status: 'loading', + isFetching: true, + }) + expect(states[1]).toMatchObject({ + data: 'test', + status: 'success', + isFetching: false, + }) + }) + // See https://github.com/tannerlinsley/react-query/issues/137 it('should not override initial data in dependent queries', async () => { const key1 = queryKey() diff --git a/src/react/useMutation.ts b/src/react/useMutation.ts index 2a641f2baf..349cfbaccf 100644 --- a/src/react/useMutation.ts +++ b/src/react/useMutation.ts @@ -172,8 +172,6 @@ export function useMutation< if (mutateConfig.throwOnError ?? config.throwOnError) { throw error } - - return } }, [dispatch, getConfig, getMutationFn] diff --git a/tsconfig.json b/tsconfig.json index 5d737de6c9..287685b3f9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "target": "es5", "noEmit": true, "noImplicitAny": true, - "noImplicitReturns": true, + "noImplicitReturns": false, "noImplicitThis": true, "noUnusedLocals": true, "noUnusedParameters": true,