From c7e3f5e6e46f29f96353ab638088852e0ceff4d1 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Tue, 5 Jan 2021 17:11:36 +0100 Subject: [PATCH 01/15] feat: useTrackedQuery add useTrackedQuery hook, which tracks notifyOnChangeProps automatically via a Proxy --- src/core/queryObserver.ts | 6 +++++ src/core/trackedQueryObserver.ts | 33 +++++++++++++++++++++++++ src/react/index.ts | 1 + src/react/types.ts | 9 +++++++ src/react/useBaseQuery.ts | 2 +- src/react/useTrackedQuery.ts | 42 ++++++++++++++++++++++++++++++++ 6 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 src/core/trackedQueryObserver.ts create mode 100644 src/react/useTrackedQuery.ts diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index cbe44247b2..8cfb02c2fc 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -148,6 +148,12 @@ export class QueryObserver< this.currentQuery.removeObserver(this) } + createQueryResult( + result: QueryObserverResult + ): QueryObserverResult { + return result + } + setOptions( options?: QueryObserverOptions ): void { diff --git a/src/core/trackedQueryObserver.ts b/src/core/trackedQueryObserver.ts new file mode 100644 index 0000000000..c200a801ce --- /dev/null +++ b/src/core/trackedQueryObserver.ts @@ -0,0 +1,33 @@ +import { QueryObserver } from './queryObserver' +import { InfiniteQueryObserverResult } from './types' +import type { QueryObserverResult } from './types' + +export class TrackedQueryObserver< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryData = TQueryFnData +> extends QueryObserver { + createQueryResult( + result: QueryObserverResult + ): QueryObserverResult { + const addNotifyOnChangeProps = ( + prop: keyof InfiniteQueryObserverResult + ) => { + if (!this.options.notifyOnChangeProps) { + this.options.notifyOnChangeProps = [] + } + + if (!this.options.notifyOnChangeProps.includes(prop)) { + this.options.notifyOnChangeProps.push(prop) + } + } + + return new Proxy(result, { + get(target, prop, receiver) { + addNotifyOnChangeProps(prop as keyof InfiniteQueryObserverResult) + return Reflect.get(target, prop, receiver) + }, + }) + } +} diff --git a/src/react/index.ts b/src/react/index.ts index e9245345e6..ac17d9d7e0 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -10,6 +10,7 @@ export { export { useIsFetching } from './useIsFetching' export { useMutation } from './useMutation' export { useQuery } from './useQuery' +export { useTrackedQuery } from './useTrackedQuery' export { useQueries } from './useQueries' export { useInfiniteQuery } from './useInfiniteQuery' diff --git a/src/react/types.ts b/src/react/types.ts index 7f599576e8..fcd0495b74 100644 --- a/src/react/types.ts +++ b/src/react/types.ts @@ -21,6 +21,15 @@ export interface UseQueryOptions< TData = TQueryFnData > extends UseBaseQueryOptions {} +export type UseTrackedQueryOptions< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData +> = Omit< + UseQueryOptions, + 'notifyOnChangeProps' | 'notifyOnChangePropsExclusions' +> + export interface UseInfiniteQueryOptions< TQueryFnData = unknown, TError = unknown, diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts index efa0e77427..a93f392f44 100644 --- a/src/react/useBaseQuery.ts +++ b/src/react/useBaseQuery.ts @@ -91,5 +91,5 @@ export function useBaseQuery( } } - return currentResult + return observer.createQueryResult(currentResult) } diff --git a/src/react/useTrackedQuery.ts b/src/react/useTrackedQuery.ts new file mode 100644 index 0000000000..89e330b9ee --- /dev/null +++ b/src/react/useTrackedQuery.ts @@ -0,0 +1,42 @@ +import { TrackedQueryObserver } from '../core/trackedQueryObserver' +import { QueryFunction, QueryKey } from '../core/types' +import { parseQueryArgs } from '../core/utils' +import { UseTrackedQueryOptions, UseQueryResult } from './types' +import { useBaseQuery } from './useBaseQuery' + +// HOOK + +export function useTrackedQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData +>( + options: UseTrackedQueryOptions +): UseQueryResult +export function useTrackedQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData +>( + queryKey: QueryKey, + options?: UseTrackedQueryOptions +): UseQueryResult +export function useTrackedQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData +>( + queryKey: QueryKey, + queryFn: QueryFunction, + options?: UseTrackedQueryOptions +): UseQueryResult +export function useTrackedQuery( + arg1: QueryKey | UseTrackedQueryOptions, + arg2?: + | QueryFunction + | UseTrackedQueryOptions, + arg3?: UseTrackedQueryOptions +): UseQueryResult { + const parsedOptions = parseQueryArgs(arg1, arg2, arg3) + return useBaseQuery(parsedOptions, TrackedQueryObserver) +} From 761a1a768178b4bfdba937372ae0103260346cc3 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Thu, 7 Jan 2021 17:04:37 +0100 Subject: [PATCH 02/15] feat: notifyOnChangeTracked different approach to tracking used props by using an option rather than a new hook, so that we can also easily combine this with useInfiniteQuery; also, using Object.defineProperty rather than a Proxy --- src/core/queryObserver.ts | 29 ++++++++++++++++++++-- src/core/trackedQueryObserver.ts | 33 ------------------------- src/core/types.ts | 4 +++ src/react/index.ts | 1 - src/react/types.ts | 9 ------- src/react/useTrackedQuery.ts | 42 -------------------------------- 6 files changed, 31 insertions(+), 87 deletions(-) delete mode 100644 src/core/trackedQueryObserver.ts delete mode 100644 src/react/useTrackedQuery.ts diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 8cfb02c2fc..d7b099a78f 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -149,9 +149,34 @@ export class QueryObserver< } createQueryResult( - result: QueryObserverResult + queryObserverResult: QueryObserverResult ): QueryObserverResult { - return result + if (this.options.notifyOnChangeTracked) { + const trackedResult = {} + const addNotifyOnChangeProps = (prop: keyof QueryObserverResult) => { + if (!this.options.notifyOnChangeProps) { + this.options.notifyOnChangeProps = [] + } + + if (!this.options.notifyOnChangeProps.includes(prop)) { + this.options.notifyOnChangeProps.push(prop) + } + } + + Object.keys(queryObserverResult).forEach(key => { + Object.defineProperty(trackedResult, key, { + configurable: false, + enumerable: true, + get() { + addNotifyOnChangeProps(key as keyof QueryObserverResult) + return queryObserverResult[key as keyof QueryObserverResult] + }, + }) + }) + + return trackedResult as QueryObserverResult + } + return queryObserverResult } setOptions( diff --git a/src/core/trackedQueryObserver.ts b/src/core/trackedQueryObserver.ts deleted file mode 100644 index c200a801ce..0000000000 --- a/src/core/trackedQueryObserver.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { QueryObserver } from './queryObserver' -import { InfiniteQueryObserverResult } from './types' -import type { QueryObserverResult } from './types' - -export class TrackedQueryObserver< - TQueryFnData = unknown, - TError = unknown, - TData = TQueryFnData, - TQueryData = TQueryFnData -> extends QueryObserver { - createQueryResult( - result: QueryObserverResult - ): QueryObserverResult { - const addNotifyOnChangeProps = ( - prop: keyof InfiniteQueryObserverResult - ) => { - if (!this.options.notifyOnChangeProps) { - this.options.notifyOnChangeProps = [] - } - - if (!this.options.notifyOnChangeProps.includes(prop)) { - this.options.notifyOnChangeProps.push(prop) - } - } - - return new Proxy(result, { - get(target, prop, receiver) { - addNotifyOnChangeProps(prop as keyof InfiniteQueryObserverResult) - return Reflect.get(target, prop, receiver) - }, - }) - } -} diff --git a/src/core/types.ts b/src/core/types.ts index 7ad1fd1e84..6c36f822d8 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -142,6 +142,10 @@ export interface QueryObserverOptions< * If set, the component will not re-render if any of the listed properties change. */ notifyOnChangePropsExclusions?: Array + /** + * If set, access to properties will be tracked and only re-rendered if one of the tracked properties change. + */ + notifyOnChangeTracked?: boolean /** * This callback will fire any time the query successfully fetches new data. */ diff --git a/src/react/index.ts b/src/react/index.ts index ac17d9d7e0..e9245345e6 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -10,7 +10,6 @@ export { export { useIsFetching } from './useIsFetching' export { useMutation } from './useMutation' export { useQuery } from './useQuery' -export { useTrackedQuery } from './useTrackedQuery' export { useQueries } from './useQueries' export { useInfiniteQuery } from './useInfiniteQuery' diff --git a/src/react/types.ts b/src/react/types.ts index fcd0495b74..7f599576e8 100644 --- a/src/react/types.ts +++ b/src/react/types.ts @@ -21,15 +21,6 @@ export interface UseQueryOptions< TData = TQueryFnData > extends UseBaseQueryOptions {} -export type UseTrackedQueryOptions< - TQueryFnData = unknown, - TError = unknown, - TData = TQueryFnData -> = Omit< - UseQueryOptions, - 'notifyOnChangeProps' | 'notifyOnChangePropsExclusions' -> - export interface UseInfiniteQueryOptions< TQueryFnData = unknown, TError = unknown, diff --git a/src/react/useTrackedQuery.ts b/src/react/useTrackedQuery.ts deleted file mode 100644 index 89e330b9ee..0000000000 --- a/src/react/useTrackedQuery.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { TrackedQueryObserver } from '../core/trackedQueryObserver' -import { QueryFunction, QueryKey } from '../core/types' -import { parseQueryArgs } from '../core/utils' -import { UseTrackedQueryOptions, UseQueryResult } from './types' -import { useBaseQuery } from './useBaseQuery' - -// HOOK - -export function useTrackedQuery< - TQueryFnData = unknown, - TError = unknown, - TData = TQueryFnData ->( - options: UseTrackedQueryOptions -): UseQueryResult -export function useTrackedQuery< - TQueryFnData = unknown, - TError = unknown, - TData = TQueryFnData ->( - queryKey: QueryKey, - options?: UseTrackedQueryOptions -): UseQueryResult -export function useTrackedQuery< - TQueryFnData = unknown, - TError = unknown, - TData = TQueryFnData ->( - queryKey: QueryKey, - queryFn: QueryFunction, - options?: UseTrackedQueryOptions -): UseQueryResult -export function useTrackedQuery( - arg1: QueryKey | UseTrackedQueryOptions, - arg2?: - | QueryFunction - | UseTrackedQueryOptions, - arg3?: UseTrackedQueryOptions -): UseQueryResult { - const parsedOptions = parseQueryArgs(arg1, arg2, arg3) - return useBaseQuery(parsedOptions, TrackedQueryObserver) -} From 16e3e5f76cb4ce79cddbf1ac2259740f54cad1a0 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Thu, 7 Jan 2021 17:19:11 +0100 Subject: [PATCH 03/15] feat: notifyOnChangeTracked add some documentation for notifyOnChangeTracked --- docs/src/pages/comparison.md | 2 +- docs/src/pages/reference/useQuery.md | 6 +++++- src/core/types.ts | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/src/pages/comparison.md b/docs/src/pages/comparison.md index d0a16f85c9..6106e8c6f4 100644 --- a/docs/src/pages/comparison.md +++ b/docs/src/pages/comparison.md @@ -62,7 +62,7 @@ Feature/Capability Key: > **1 Lagged Query Data** - React Query provides a way to continue to see an existing query's data while the next query loads (similar to the same UX that suspense will soon provide natively). This is extremely important when writing pagination UIs or infinite loading UIs where you do not want to show a hard loading state whenever a new query is requested. Other libraries do not have this capability and render a hard loading state for the new query (unless it has been prefetched), while the new query loads. -> **2 Render Optimization** - React Query has excellent rendering performance. It will only re-render your components when a query is updated. For example because it has new data, or to indicate it is fetching. React Query also batches updates together to make sure your application only re-renders once when multiple components are using the same query. If you are only interested in the `data` or `error` properties, you can reduce the number of renders even more by setting `notifyOnChangeProps` to `['data', 'error']`. +> **2 Render Optimization** - React Query has excellent rendering performance. It will only re-render your components when a query is updated. For example because it has new data, or to indicate it is fetching. React Query also batches updates together to make sure your application only re-renders once when multiple components are using the same query. If you are only interested in the `data` or `error` properties, you can reduce the number of renders even more by setting `notifyOnChangeProps` to `['data', 'error']`, or set `notifyOnChangeTracked: true` to automatically track which fields are accessed and only re-render if one of them changes. > **3 Partial query matching** - Because React Query uses deterministic query key serialization, this allows you to manipulate variable groups of queries without having to know each individual query-key that you want to match, eg. you can refetch every query that starts with `todos` in its key, regardless of variables, or you can target specific queries with (or without) variables or nested properties, and even use a filter function to only match queries that pass your specific conditions. diff --git a/docs/src/pages/reference/useQuery.md b/docs/src/pages/reference/useQuery.md index 2a1956dc85..57a4aff654 100644 --- a/docs/src/pages/reference/useQuery.md +++ b/docs/src/pages/reference/useQuery.md @@ -28,6 +28,7 @@ const { keepPreviousData, notifyOnChangeProps, notifyOnChangePropsExclusions, + notifyOnChangeTracked, onError, onSettled, onSuccess, @@ -39,7 +40,7 @@ const { refetchOnWindowFocus, retry, retryDelay, - select + select, staleTime, structuralSharing, suspense, @@ -117,6 +118,9 @@ const result = useQuery({ - Optional - If set, the component will not re-render if any of the listed properties change. - If set to `['isStale']` for example, the component will not re-render when the `isStale` property changes. +- `notifyOnChangeTracked` + - Optional + - If set, access to properties will be tracked, and the component will only re-render when one of the tracked properties change. - `onSuccess: (data: TData) => void` - Optional - This function will fire any time the query successfully fetches new data. diff --git a/src/core/types.ts b/src/core/types.ts index 6c36f822d8..b1fb145fa0 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -143,7 +143,7 @@ export interface QueryObserverOptions< */ notifyOnChangePropsExclusions?: Array /** - * If set, access to properties will be tracked and only re-rendered if one of the tracked properties change. + * If set, access to properties will be tracked, and the component will only re-render when one of the tracked properties change. */ notifyOnChangeTracked?: boolean /** From 61d479401fda528fb29acdeb0c598b9b26aac000 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Fri, 8 Jan 2021 08:04:26 +0100 Subject: [PATCH 04/15] feat: notifyOnChangeTracked add a test for notifyOnChangeTracked --- src/react/tests/useQuery.test.tsx | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index cd879164cd..338ee49688 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -737,6 +737,41 @@ describe('useQuery', () => { expect(states[1].dataUpdatedAt).not.toBe(states[2].dataUpdatedAt) }) + it('should track properties and only re-render when a tracked property changes', async () => { + const key = queryKey() + const states: UseQueryResult[] = [] + + function Page() { + const state = useQuery(key, () => 'test', { + notifyOnChangeTracked: true, + }) + + states.push(state) + + const { refetch, data } = state + + React.useEffect(() => { + if (data) { + refetch() + } + }, [refetch, data]) + + return ( +
+

{data ?? null}

+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => rendered.getByText('test')) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'test' }) + }) + it('should be able to remove a query', async () => { const key = queryKey() const states: UseQueryResult[] = [] From ba1bb199c6ceb86ffd8605b8b38e3e2f23a5606d Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 9 Jan 2021 21:36:46 +0100 Subject: [PATCH 05/15] feat: notifyOnChangeTracked keep trackedProps separate from notifyOnChangeProps and only merge them in shouldNotifyListeners --- src/core/queryObserver.ts | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index d7b099a78f..7456be089a 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -54,6 +54,7 @@ export class QueryObserver< private initialErrorUpdateCount: number private staleTimeoutId?: number private refetchIntervalId?: number + private trackedProps!: Array constructor( client: QueryClient, @@ -65,6 +66,7 @@ export class QueryObserver< this.options = options this.initialDataUpdateCount = 0 this.initialErrorUpdateCount = 0 + this.trackedProps = [] this.bindMethods() this.setOptions(options) } @@ -151,15 +153,12 @@ export class QueryObserver< createQueryResult( queryObserverResult: QueryObserverResult ): QueryObserverResult { + this.trackedProps = [] if (this.options.notifyOnChangeTracked) { const trackedResult = {} - const addNotifyOnChangeProps = (prop: keyof QueryObserverResult) => { - if (!this.options.notifyOnChangeProps) { - this.options.notifyOnChangeProps = [] - } - - if (!this.options.notifyOnChangeProps.includes(prop)) { - this.options.notifyOnChangeProps.push(prop) + const addTrackedProps = (prop: keyof QueryObserverResult) => { + if (!this.trackedProps.includes(prop)) { + this.trackedProps.push(prop) } } @@ -168,7 +167,7 @@ export class QueryObserver< configurable: false, enumerable: true, get() { - addNotifyOnChangeProps(key as keyof QueryObserverResult) + addTrackedProps(key as keyof QueryObserverResult) return queryObserverResult[key as keyof QueryObserverResult] }, }) @@ -468,22 +467,33 @@ export class QueryObserver< prevResult: QueryObserverResult, result: QueryObserverResult ): boolean { - const { notifyOnChangeProps, notifyOnChangePropsExclusions } = this.options + const { + notifyOnChangeProps, + notifyOnChangePropsExclusions, + notifyOnChangeTracked, + } = this.options if (prevResult === result) { return false } - if (!notifyOnChangeProps && !notifyOnChangePropsExclusions) { + if ( + !notifyOnChangeProps && + !notifyOnChangePropsExclusions && + !notifyOnChangeTracked + ) { return true } const keys = Object.keys(result) + const includedProps = notifyOnChangeProps + ? notifyOnChangeProps.concat(this.trackedProps) + : this.trackedProps for (let i = 0; i < keys.length; i++) { const key = keys[i] as keyof QueryObserverResult const changed = prevResult[key] !== result[key] - const isIncluded = notifyOnChangeProps?.some(x => x === key) + const isIncluded = includedProps.some(x => x === key) const isExcluded = notifyOnChangePropsExclusions?.some(x => x === key) if (changed) { @@ -491,7 +501,7 @@ export class QueryObserver< continue } - if (!notifyOnChangeProps || isIncluded) { + if ((!notifyOnChangeProps && !notifyOnChangeTracked) || isIncluded) { return true } } From 978fb758a0df4e7ac9e569e88b592c7118192460 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 9 Jan 2021 22:22:02 +0100 Subject: [PATCH 06/15] feat: notifyOnChangeTracked store tracked result object on queryObserver instance so that the reference stays the same between renders --- src/core/queryObserver.ts | 9 +++++---- src/react/tests/useQuery.test.tsx | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 7456be089a..84bcd2cedc 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -55,6 +55,7 @@ export class QueryObserver< private staleTimeoutId?: number private refetchIntervalId?: number private trackedProps!: Array + private trackedResult!: QueryObserverResult constructor( client: QueryClient, @@ -67,6 +68,7 @@ export class QueryObserver< this.initialDataUpdateCount = 0 this.initialErrorUpdateCount = 0 this.trackedProps = [] + this.trackedResult = {} as QueryObserverResult this.bindMethods() this.setOptions(options) } @@ -155,7 +157,6 @@ export class QueryObserver< ): QueryObserverResult { this.trackedProps = [] if (this.options.notifyOnChangeTracked) { - const trackedResult = {} const addTrackedProps = (prop: keyof QueryObserverResult) => { if (!this.trackedProps.includes(prop)) { this.trackedProps.push(prop) @@ -163,8 +164,8 @@ export class QueryObserver< } Object.keys(queryObserverResult).forEach(key => { - Object.defineProperty(trackedResult, key, { - configurable: false, + Object.defineProperty(this.trackedResult, key, { + configurable: true, enumerable: true, get() { addTrackedProps(key as keyof QueryObserverResult) @@ -173,7 +174,7 @@ export class QueryObserver< }) }) - return trackedResult as QueryObserverResult + return this.trackedResult } return queryObserverResult } diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index 338ee49688..87758bab69 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -739,14 +739,14 @@ describe('useQuery', () => { it('should track properties and only re-render when a tracked property changes', async () => { const key = queryKey() - const states: UseQueryResult[] = [] + const states: (string | undefined)[] = [] function Page() { const state = useQuery(key, () => 'test', { notifyOnChangeTracked: true, }) - states.push(state) + states.push(state.data) const { refetch, data } = state @@ -768,8 +768,8 @@ describe('useQuery', () => { await waitFor(() => rendered.getByText('test')) expect(states.length).toBe(2) - expect(states[0]).toMatchObject({ data: undefined }) - expect(states[1]).toMatchObject({ data: 'test' }) + expect(states[0]).toEqual(undefined) + expect(states[1]).toEqual('test') }) it('should be able to remove a query', async () => { From 4ea4eaa367bee7533f4fad57e29017734ae5dd3d Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sun, 10 Jan 2021 12:26:15 +0100 Subject: [PATCH 07/15] feat: notifyOnChangeTracked create a new trackedResult whenever we create a new result this ensures that we keep referential identity of the tracked result object if nothing changes, but still get a new instance if we need to (to avoid stale closure problems) --- src/core/queryObserver.ts | 47 +++++++++++++++---------------- src/react/tests/useQuery.test.tsx | 8 +++--- src/react/useBaseQuery.ts | 6 +++- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 84bcd2cedc..25d7bd8faa 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -68,7 +68,6 @@ export class QueryObserver< this.initialDataUpdateCount = 0 this.initialErrorUpdateCount = 0 this.trackedProps = [] - this.trackedResult = {} as QueryObserverResult this.bindMethods() this.setOptions(options) } @@ -152,31 +151,8 @@ export class QueryObserver< this.currentQuery.removeObserver(this) } - createQueryResult( - queryObserverResult: QueryObserverResult - ): QueryObserverResult { + resetTrackedProps(): void { this.trackedProps = [] - if (this.options.notifyOnChangeTracked) { - const addTrackedProps = (prop: keyof QueryObserverResult) => { - if (!this.trackedProps.includes(prop)) { - this.trackedProps.push(prop) - } - } - - Object.keys(queryObserverResult).forEach(key => { - Object.defineProperty(this.trackedResult, key, { - configurable: true, - enumerable: true, - get() { - addTrackedProps(key as keyof QueryObserverResult) - return queryObserverResult[key as keyof QueryObserverResult] - }, - }) - }) - - return this.trackedResult - } - return queryObserverResult } setOptions( @@ -239,6 +215,10 @@ export class QueryObserver< return this.currentResult } + getTrackedResult(): QueryObserverResult { + return this.trackedResult + } + getNextResult( options?: ResultOptions ): Promise> { @@ -520,6 +500,23 @@ export class QueryObserver< // Only update if something has changed if (!shallowEqualObjects(result, this.currentResult)) { this.currentResult = result + const addTrackedProps = (prop: keyof QueryObserverResult) => { + if (!this.trackedProps.includes(prop)) { + this.trackedProps.push(prop) + } + } + this.trackedResult = {} as QueryObserverResult + + Object.keys(result).forEach(key => { + Object.defineProperty(this.trackedResult, key, { + configurable: false, + enumerable: true, + get() { + addTrackedProps(key as keyof QueryObserverResult) + return result[key as keyof QueryObserverResult] + }, + }) + }) } } diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index 87758bab69..338ee49688 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -739,14 +739,14 @@ describe('useQuery', () => { it('should track properties and only re-render when a tracked property changes', async () => { const key = queryKey() - const states: (string | undefined)[] = [] + const states: UseQueryResult[] = [] function Page() { const state = useQuery(key, () => 'test', { notifyOnChangeTracked: true, }) - states.push(state.data) + states.push(state) const { refetch, data } = state @@ -768,8 +768,8 @@ describe('useQuery', () => { await waitFor(() => rendered.getByText('test')) expect(states.length).toBe(2) - expect(states[0]).toEqual(undefined) - expect(states[1]).toEqual('test') + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'test' }) }) it('should be able to remove a query', async () => { diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts index a93f392f44..7e74023d7c 100644 --- a/src/react/useBaseQuery.ts +++ b/src/react/useBaseQuery.ts @@ -91,5 +91,9 @@ export function useBaseQuery( } } - return observer.createQueryResult(currentResult) + observer.resetTrackedProps() + + return observer.options.notifyOnChangeTracked + ? observer.getTrackedResult() + : currentResult } From fe0b4dc778c192216d343bba1cd2c2805c2231ea Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Tue, 12 Jan 2021 14:54:01 +0100 Subject: [PATCH 08/15] feat: notifyOnChangeTracked add test for referential stability between re-renders; even if we are tracking changes, we should return the same object if we re-render (for some other reason than react-query changes) and nothing has changed --- src/react/tests/useQuery.test.tsx | 42 +++++++++++++++++++++++++++++++ src/react/tests/utils.tsx | 11 +++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index 338ee49688..05d987b016 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -772,6 +772,48 @@ describe('useQuery', () => { expect(states[1]).toMatchObject({ data: 'test' }) }) + 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', { + notifyOnChangeTracked: true, + }) + + 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 be able to remove a query', async () => { const key = queryKey() const states: UseQueryResult[] = [] diff --git a/src/react/tests/utils.tsx b/src/react/tests/utils.tsx index 17c3d07eaf..609a9d164b 100644 --- a/src/react/tests/utils.tsx +++ b/src/react/tests/utils.tsx @@ -4,7 +4,16 @@ import React from 'react' import { QueryClient, QueryClientProvider } from '../..' export function renderWithClient(client: QueryClient, ui: React.ReactElement) { - return render({ui}) + const { rerender, ...result } = render( + {ui} + ) + return { + ...result, + rerender: (rerenderUi: React.ReactElement) => + rerender( + {rerenderUi} + ), + } } export function mockVisibilityState(value: string) { From 6a93c80329956002f4d5c550346e8da89191a704 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 16 Jan 2021 14:56:00 +0100 Subject: [PATCH 09/15] feat: notifyOnChangeTracked rename trackedResult to trackedCurrentResult --- src/core/queryObserver.ts | 10 +++++----- src/react/useBaseQuery.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 25d7bd8faa..d5514aec10 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -55,7 +55,7 @@ export class QueryObserver< private staleTimeoutId?: number private refetchIntervalId?: number private trackedProps!: Array - private trackedResult!: QueryObserverResult + private trackedCurrentResult!: QueryObserverResult constructor( client: QueryClient, @@ -215,8 +215,8 @@ export class QueryObserver< return this.currentResult } - getTrackedResult(): QueryObserverResult { - return this.trackedResult + getTrackedCurrentResult(): QueryObserverResult { + return this.trackedCurrentResult } getNextResult( @@ -505,10 +505,10 @@ export class QueryObserver< this.trackedProps.push(prop) } } - this.trackedResult = {} as QueryObserverResult + this.trackedCurrentResult = {} as QueryObserverResult Object.keys(result).forEach(key => { - Object.defineProperty(this.trackedResult, key, { + Object.defineProperty(this.trackedCurrentResult, key, { configurable: false, enumerable: true, get() { diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts index 7e74023d7c..2dfa931c42 100644 --- a/src/react/useBaseQuery.ts +++ b/src/react/useBaseQuery.ts @@ -94,6 +94,6 @@ export function useBaseQuery( observer.resetTrackedProps() return observer.options.notifyOnChangeTracked - ? observer.getTrackedResult() + ? observer.getTrackedCurrentResult() : currentResult } From ad540e2db1ab4460cddffdfd8eb0bc6dc9268d6d Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 16 Jan 2021 15:04:59 +0100 Subject: [PATCH 10/15] feat: notifyOnChangeTracked combine notifyOnChangeTracked with notifyOnChangeProps --- src/core/queryObserver.ts | 23 ++++++++--------------- src/core/types.ts | 7 ++----- src/react/tests/useQuery.test.tsx | 4 ++-- src/react/useBaseQuery.ts | 2 +- 4 files changed, 13 insertions(+), 23 deletions(-) diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index d5514aec10..77884b1256 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -448,33 +448,26 @@ export class QueryObserver< prevResult: QueryObserverResult, result: QueryObserverResult ): boolean { - const { - notifyOnChangeProps, - notifyOnChangePropsExclusions, - notifyOnChangeTracked, - } = this.options + const { notifyOnChangeProps, notifyOnChangePropsExclusions } = this.options if (prevResult === result) { return false } - if ( - !notifyOnChangeProps && - !notifyOnChangePropsExclusions && - !notifyOnChangeTracked - ) { + if (!notifyOnChangeProps && !notifyOnChangePropsExclusions) { return true } const keys = Object.keys(result) - const includedProps = notifyOnChangeProps - ? notifyOnChangeProps.concat(this.trackedProps) - : this.trackedProps + 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] - const isIncluded = includedProps.some(x => x === key) + const isIncluded = includedProps?.some(x => x === key) const isExcluded = notifyOnChangePropsExclusions?.some(x => x === key) if (changed) { @@ -482,7 +475,7 @@ export class QueryObserver< continue } - if ((!notifyOnChangeProps && !notifyOnChangeTracked) || isIncluded) { + if (!notifyOnChangeProps || isIncluded) { return true } } diff --git a/src/core/types.ts b/src/core/types.ts index b1fb145fa0..9cabdcc2ab 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -136,16 +136,13 @@ export interface QueryObserverOptions< /** * If set, the component will only re-render if any of the listed properties change. * When set to `['data', 'error']`, the component will only re-render when the `data` or `error` properties change. + * When set to `tracked`, access to properties will be tracked, and the component will only re-render when one of the tracked properties change. */ - notifyOnChangeProps?: Array + notifyOnChangeProps?: Array | 'tracked' /** * If set, the component will not re-render if any of the listed properties change. */ notifyOnChangePropsExclusions?: Array - /** - * If set, access to properties will be tracked, and the component will only re-render when one of the tracked properties change. - */ - notifyOnChangeTracked?: boolean /** * This callback will fire any time the query successfully fetches new data. */ diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index 05d987b016..648624cf91 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -743,7 +743,7 @@ describe('useQuery', () => { function Page() { const state = useQuery(key, () => 'test', { - notifyOnChangeTracked: true, + notifyOnChangeProps: 'tracked', }) states.push(state) @@ -779,7 +779,7 @@ describe('useQuery', () => { function Page() { const state = useQuery(key, () => 'test', { - notifyOnChangeTracked: true, + notifyOnChangeProps: 'tracked', }) states.push(state) diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts index 2dfa931c42..5b7b876973 100644 --- a/src/react/useBaseQuery.ts +++ b/src/react/useBaseQuery.ts @@ -93,7 +93,7 @@ export function useBaseQuery( observer.resetTrackedProps() - return observer.options.notifyOnChangeTracked + return observer.options.notifyOnChangeProps === 'tracked' ? observer.getTrackedCurrentResult() : currentResult } From 149409093ebd98bf44580b89da68b8e0348a6880 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 16 Jan 2021 15:07:37 +0100 Subject: [PATCH 11/15] feat: notifyOnChangeTracked remove auto-resetting of tracked props, which means that every prop ever tracked will lead to a re-render; this is the "safe" side - we'd rather have an unnecessary re-render rather than a bug because we are not re-rendering when something is observed only in an effect --- src/core/queryObserver.ts | 4 ---- src/react/useBaseQuery.ts | 2 -- 2 files changed, 6 deletions(-) diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 77884b1256..5fe2f0fa76 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -151,10 +151,6 @@ export class QueryObserver< this.currentQuery.removeObserver(this) } - resetTrackedProps(): void { - this.trackedProps = [] - } - setOptions( options?: QueryObserverOptions ): void { diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts index 5b7b876973..326a2dbfc8 100644 --- a/src/react/useBaseQuery.ts +++ b/src/react/useBaseQuery.ts @@ -91,8 +91,6 @@ export function useBaseQuery( } } - observer.resetTrackedProps() - return observer.options.notifyOnChangeProps === 'tracked' ? observer.getTrackedCurrentResult() : currentResult From 8cfcfeaf2404ad63515e95d6db274e5d6b68372b Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 16 Jan 2021 15:23:20 +0100 Subject: [PATCH 12/15] feat: notifyOnChangeTracked always re-render if we are tracking props, but not using any. In that case, trackedProps will be an empty array, which would lead to no re-renders at all (same as setting notifyOnChangeProps to empty Array) --- src/core/queryObserver.ts | 6 +++++- src/react/tests/useQuery.test.tsx | 32 +++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 5fe2f0fa76..d199f452c3 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -471,7 +471,11 @@ export class QueryObserver< continue } - if (!notifyOnChangeProps || isIncluded) { + if ( + !notifyOnChangeProps || + isIncluded || + (notifyOnChangeProps === 'tracked' && this.trackedProps.length === 0) + ) { return true } } diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index 648624cf91..8f4b6b6e11 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -814,6 +814,38 @@ describe('useQuery', () => { 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 + const states: UseQueryResult[] = [] + + function Page() { + const state = useQuery(key, () => 'test', { + notifyOnChangeProps: 'tracked', + }) + + states.push(state) + + React.useEffect(() => { + renderCount++ + }, [state]) + + return ( +
+

hello

+
+ ) + } + + renderWithClient(queryClient, ) + + await waitFor(() => renderCount > 1) + expect(renderCount).toBe(2) + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'test' }) + }) + it('should be able to remove a query', async () => { const key = queryKey() const states: UseQueryResult[] = [] From c653de1f394f16d0ba23bbfc66312a6b1fefb109 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 16 Jan 2021 15:25:14 +0100 Subject: [PATCH 13/15] feat: notifyOnChangeTracked conditionally create trackedResult - we don't need it if we are not actually tracking props --- src/core/queryObserver.ts | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index d199f452c3..040fa38aa3 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -493,23 +493,26 @@ export class QueryObserver< // Only update if something has changed if (!shallowEqualObjects(result, this.currentResult)) { this.currentResult = result - const addTrackedProps = (prop: keyof QueryObserverResult) => { - if (!this.trackedProps.includes(prop)) { - this.trackedProps.push(prop) + + 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] - }, + 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] + }, + }) }) - }) + } } } From 5d923ceecc1dd3fe7fc083149f4c09152cf46410 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 16 Jan 2021 15:42:33 +0100 Subject: [PATCH 14/15] feat: notifyOnChangeTracked add a test for combining notifyOnChangeProps: 'tracked' and notifyOnChangePropsExclusion --- src/react/tests/useQuery.test.tsx | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index 8f4b6b6e11..a79a637ac6 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -772,6 +772,37 @@ describe('useQuery', () => { expect(states[1]).toMatchObject({ data: 'test' }) }) + it('should not re-render if a tracked prop changes, but it was excluded', async () => { + const key = queryKey() + const states: UseQueryResult[] = [] + + function Page() { + const state = useQuery(key, () => 'test', { + notifyOnChangeProps: 'tracked', + notifyOnChangePropsExclusions: ['data'], + }) + + states.push(state) + + return ( +
+

{state.data ?? 'null'}

+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => rendered.getByText('null')) + expect(states.length).toBe(1) + expect(states[0]).toMatchObject({ data: undefined }) + + await queryClient.refetchQueries(key) + await waitFor(() => rendered.getByText('null')) + expect(states.length).toBe(1) + 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 From db80c97db4442598ae3f55dbd7e7fa97911826e7 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 16 Jan 2021 15:55:12 +0100 Subject: [PATCH 15/15] feat: notifyOnChangeTracked update docs --- docs/src/pages/comparison.md | 2 +- docs/src/pages/reference/useQuery.md | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/src/pages/comparison.md b/docs/src/pages/comparison.md index 6106e8c6f4..3573f431f7 100644 --- a/docs/src/pages/comparison.md +++ b/docs/src/pages/comparison.md @@ -62,7 +62,7 @@ Feature/Capability Key: > **1 Lagged Query Data** - React Query provides a way to continue to see an existing query's data while the next query loads (similar to the same UX that suspense will soon provide natively). This is extremely important when writing pagination UIs or infinite loading UIs where you do not want to show a hard loading state whenever a new query is requested. Other libraries do not have this capability and render a hard loading state for the new query (unless it has been prefetched), while the new query loads. -> **2 Render Optimization** - React Query has excellent rendering performance. It will only re-render your components when a query is updated. For example because it has new data, or to indicate it is fetching. React Query also batches updates together to make sure your application only re-renders once when multiple components are using the same query. If you are only interested in the `data` or `error` properties, you can reduce the number of renders even more by setting `notifyOnChangeProps` to `['data', 'error']`, or set `notifyOnChangeTracked: true` to automatically track which fields are accessed and only re-render if one of them changes. +> **2 Render Optimization** - React Query has excellent rendering performance. It will only re-render your components when a query is updated. For example because it has new data, or to indicate it is fetching. React Query also batches updates together to make sure your application only re-renders once when multiple components are using the same query. If you are only interested in the `data` or `error` properties, you can reduce the number of renders even more by setting `notifyOnChangeProps` to `['data', 'error']`. Set `notifyOnChangeProps: 'tracked'` to automatically track which fields are accessed and only re-render if one of them changes. > **3 Partial query matching** - Because React Query uses deterministic query key serialization, this allows you to manipulate variable groups of queries without having to know each individual query-key that you want to match, eg. you can refetch every query that starts with `todos` in its key, regardless of variables, or you can target specific queries with (or without) variables or nested properties, and even use a filter function to only match queries that pass your specific conditions. diff --git a/docs/src/pages/reference/useQuery.md b/docs/src/pages/reference/useQuery.md index 57a4aff654..eb631b0747 100644 --- a/docs/src/pages/reference/useQuery.md +++ b/docs/src/pages/reference/useQuery.md @@ -28,7 +28,6 @@ const { keepPreviousData, notifyOnChangeProps, notifyOnChangePropsExclusions, - notifyOnChangeTracked, onError, onSettled, onSuccess, @@ -110,17 +109,15 @@ const result = useQuery({ - If set to `true`, the query will refetch on reconnect if the data is stale. - If set to `false`, the query will not refetch on reconnect. - If set to `"always"`, the query will always refetch on reconnect. -- `notifyOnChangeProps: string[]` +- `notifyOnChangeProps: string[] | "tracked"` - Optional - If set, the component will only re-render if any of the listed properties change. - If set to `['data', 'error']` for example, the component will only re-render when the `data` or `error` properties change. + - If set to `"tracked"`, access to properties will be tracked, and the component will only re-render when one of the tracked properties change. - `notifyOnChangePropsExclusions: string[]` - Optional - If set, the component will not re-render if any of the listed properties change. - If set to `['isStale']` for example, the component will not re-render when the `isStale` property changes. -- `notifyOnChangeTracked` - - Optional - - If set, access to properties will be tracked, and the component will only re-render when one of the tracked properties change. - `onSuccess: (data: TData) => void` - Optional - This function will fire any time the query successfully fetches new data.