Repository: tannerlinsley/react-query
Pull request #1036 (2020-09-17 08:00:47)

fix: include config callbacks in batch render

vercel[bot]: This pull request is being automatically deployed with Vercel ([learn more](https://vercel.link/github-learn-more)).  
To see the status of your deployment, click below or on the icon next to each commit.

🔍 Inspect: [https://vercel.com/tannerlinsley/react-query/cbtgb8ya9](https://vercel.com/tannerlinsley/react-query/cbtgb8ya9)  
✅ Preview: *In Progress*



Patch:
diff --git a/docs/src/pages/docs/api.md b/docs/src/pages/docs/api.md
index 39237b634..4928ccf62 100644
--- a/docs/src/pages/docs/api.md
+++ b/docs/src/pages/docs/api.md
@@ -689,44 +689,62 @@ const isFetching = useIsFetching()
 `ReactQueryConfigProvider` is an optional provider component and can be used to define defaults for all instances of `useQuery` within it's sub-tree:
 
 ```js
-import { ReactQueryConfigProvider } from 'react-query'
+import {
+  QueryCache,
+  ReactQueryCacheProvider,
+  ReactQueryConfigProvider,
+} from 'react-query'
 
-const queryConfig = {
+const queryCache = new QueryCache({
+  defaultConfig: {
+    queries: {
+      suspense: false,
+      queryKeySerializerFn: defaultQueryKeySerializerFn,
+      queryFn,
+      enabled: true,
+      retry: 3,
+      retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
+      staleTime: 0,
+      cacheTime: 5 * 60 * 1000,
+      refetchOnWindowFocus: true,
+      refetchInterval: false,
+      queryFnParamsFilter: identity,
+      refetchOnMount: true,
+      isDataEqual: deepEqual,
+      onError: noop,
+      onSuccess: noop,
+      onSettled: noop,
+      useErrorBoundary: false, // falls back to suspense
+    },
+    mutations: {
+      suspense: false,
+      throwOnError: false,
+      onMutate: noop,
+      onError: noop,
+      onSuccess: noop,
+      onSettled: noop,
+      useErrorBoundary: false, // falls back to suspense
+    },
+  },
+})
+
+const overrides = {
   queries: {
-    suspense: false,
-    queryKeySerializerFn: defaultQueryKeySerializerFn,
-    queryFn,
-    enabled: true,
-    retry: 3,
-    retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
-    staleTime: 0,
-    cacheTime: 5 * 60 * 1000,
-    refetchOnWindowFocus: true,
-    refetchInterval: false,
-    queryFnParamsFilter: identity,
-    refetchOnMount: true,
-    isDataEqual: deepEqual,
-    onError: noop,
-    onSuccess: noop,
-    onSettled: noop,
-    useErrorBoundary: false, // falls back to suspense
+    suspense: true,
   },
   mutations: {
-    suspense: false,
-    throwOnError: false,
-    onMutate: noop,
-    onError: noop,
-    onSuccess: noop,
-    onSettled: noop,
-    useErrorBoundary: false, // falls back to suspense
+    suspense: true,
   },
 }
 
 function App() {
   return (
-    <ReactQueryConfigProvider config={queryConfig}>
+    <ReactQueryCacheProvider queryCache={queryCache}>
       ...
-    </ReactQueryConfigProvider>
+      <ReactQueryConfigProvider config={overrides}>
+        ...
+      </ReactQueryConfigProvider>
+    </ReactQueryCacheProvider>
   )
 }
 ```
diff --git a/docs/src/pages/docs/guides/default-query-function.md b/docs/src/pages/docs/guides/default-query-function.md
index aa3ea4ec8..9ac60dcfc 100644
--- a/docs/src/pages/docs/guides/default-query-function.md
+++ b/docs/src/pages/docs/guides/default-query-function.md
@@ -12,18 +12,20 @@ const defaultQueryFn = async key => {
   return data
 }
 
+// provide the default query function to your app with defaultConfig
+const queryCache = new QueryCache({
+  defaultConfig: {
+    queries: {
+      queryFn: defaultQueryFn,
+    },
+  },
+})
+
 function App() {
-  // provide the default query function to your app via the config provider
   return (
-    <ReactQueryConfigProvider
-      config={{
-        queries: {
-          queryFn: defaultQueryFn,
-        },
-      }}
-    >
+    <ReactQueryCacheProvider queryCache={queryCache}>
       <YourApp />
-    </ReactQueryConfigProvider>
+    </ReactQueryCacheProvider>
   )
 }
 
diff --git a/docs/src/pages/docs/guides/query-retries.md b/docs/src/pages/docs/guides/query-retries.md
index 65b3cf44b..776b968b7 100644
--- a/docs/src/pages/docs/guides/query-retries.md
+++ b/docs/src/pages/docs/guides/query-retries.md
@@ -29,19 +29,21 @@ The default `retryDelay` is set to double (starting at `1000`ms) with each attem
 
 ```js
 // Configure for all queries
-import { ReactQueryConfigProvider } from 'react-query'
+import { QueryCache, ReactQueryCacheProvider } from 'react-query'
 
-const queryConfig = {
-  queries: {
-    retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
+const queryCache = new QueryCache({
+  defaultConfig: {
+    queries: {
+      retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
+    },
   },
-}
+})
 
 function App() {
   return (
-    <ReactQueryConfigProvider config={queryConfig}>
+    <ReactQueryCacheProvider queryCache={queryCache}>
       ...
-    </ReactQueryConfigProvider>
+    </ReactQueryCacheProvider>
   )
 }
 ```
diff --git a/docs/src/pages/docs/guides/suspense.md b/docs/src/pages/docs/guides/suspense.md
index f08209449..a03622bf9 100644
--- a/docs/src/pages/docs/guides/suspense.md
+++ b/docs/src/pages/docs/guides/suspense.md
@@ -11,19 +11,21 @@ Global configuration:
 
 ```js
 // Configure for all queries
-import { ReactQueryConfigProvider } from 'react-query'
+import { QueryCache, ReactQueryCacheProvider } from 'react-query'
 
-const queryConfig = {
-  queries: {
-    suspense: true,
+const queryCache = new QueryCache({
+  defaultConfig: {
+    queries: {
+      suspense: true,
+    },
   },
-}
+})
 
 function App() {
   return (
-    <ReactQueryConfigProvider config={queryConfig}>
+    <ReactQueryCacheProvider queryCache={queryCache}>
       ...
-    </ReactQueryConfigProvider>
+    </ReactQueryCacheProvider>
   )
 }
 ```
diff --git a/docs/src/pages/docs/guides/window-focus-refetching.md b/docs/src/pages/docs/guides/window-focus-refetching.md
index d24946477..06d5adc76 100644
--- a/docs/src/pages/docs/guides/window-focus-refetching.md
+++ b/docs/src/pages/docs/guides/window-focus-refetching.md
@@ -3,16 +3,22 @@ id: window-focus-refetching
 title: Window Focus Refetching
 ---
 
-If a user leaves your application and returns to stale data, you may want to trigger an update in the background to update any stale queries. Thankfully, **React Query does this automatically for you**, but if you choose to disable it, you can use the `ReactQueryConfigProvider`'s `refetchOnWindowFocus` option to disable it:
+If a user leaves your application and returns to stale data, you may want to trigger an update in the background to update any stale queries. Thankfully, **React Query does this automatically for you**, but if you choose to disable it, you can use the `refetchOnWindowFocus` option to disable it:
 
 ```js
-const queryConfig = { queries: { refetchOnWindowFocus: false } }
+const queryCache = new QueryCache({
+  defaultConfig: {
+    queries: {
+      refetchOnWindowFocus: false,
+    },
+  },
+})
 
 function App() {
   return (
-    <ReactQueryConfigProvider config={queryConfig}>
+    <ReactQueryCacheProvider queryCache={queryCache}>
       ...
-    </ReactQueryConfigProvider>
+    </ReactQueryCacheProvider>
   )
 }
 ```
@@ -53,16 +59,16 @@ setFocusHandler(onWindowFocus) // Boom!
 Instead of event listeners on `window`, React Native provides focus information through the [`AppState` module](https://reactnative.dev/docs/appstate#app-states). You can use the `AppState` "change" event to trigger an update when the app state changes to "active":
 
 ```js
-import { setFocusHandler } from 'react-query';
-import { AppState } from 'react-native';
+import { setFocusHandler } from 'react-query'
+import { AppState } from 'react-native'
 
-setFocusHandler((handleFocus) => {
-  const handleAppStateChange = (appState) => {
+setFocusHandler(handleFocus => {
+  const handleAppStateChange = appState => {
     if (appState === 'active') {
-      handleFocus();
+      handleFocus()
     }
-  };
-  AppState.addEventListener('change', handleAppStateChange);
-  return () => AppState.removeEventListener('change', handleAppStateChange);
-});
+  }
+  AppState.addEventListener('change', handleAppStateChange)
+  return () => AppState.removeEventListener('change', handleAppStateChange)
+})
 ```
diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts
index cf98dc415..01d6a4b7d 100644
--- a/src/core/queryCache.ts
+++ b/src/core/queryCache.ts
@@ -133,7 +133,9 @@ export class QueryCache {
     predicate?: QueryPredicate,
     options?: QueryPredicateOptions
   ): Query<TResult, TError>[] {
-    if (!options && (predicate === true || typeof predicate === 'undefined')) {
+    const anyKey = predicate === true || typeof predicate === 'undefined'
+
+    if (anyKey && !options) {
       return this.queriesArray
     }
 
@@ -142,20 +144,36 @@ export class QueryCache {
     if (typeof predicate === 'function') {
       predicateFn = predicate as QueryPredicateFn
     } else {
+      const { exact, active, stale } = options || {}
       const resolvedConfig = this.getResolvedQueryConfig(predicate)
 
       predicateFn = query => {
-        if (
-          options &&
-          ((options.exact && query.queryHash !== resolvedConfig.queryHash) ||
-            (typeof options.active === 'boolean' &&
-              query.isActive() !== options.active) ||
-            (typeof options.stale === 'boolean' &&
-              query.isStale() !== options.stale))
-        ) {
+        // Check query key if needed
+        if (!anyKey) {
+          if (exact) {
+            // Check if the query key matches exactly
+            if (query.queryHash !== resolvedConfig.queryHash) {
+              return false
+            }
+          } else {
+            // Check if the query key matches partially
+            if (!deepIncludes(query.queryKey, resolvedConfig.queryKey)) {
+              return false
+            }
+          }
+        }
+
+        // Check active state if needed
+        if (typeof active === 'boolean' && query.isActive() !== active) {
           return false
         }
-        return deepIncludes(query.queryKey, resolvedConfig.queryKey)
+
+        // Check stale state if needed
+        if (typeof stale === 'boolean' && query.isStale() !== stale) {
+          return false
+        }
+
+        return true
       }
     }
 
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index 6241f5800..0fb8333cc 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -13,6 +13,13 @@ export type UpdateListener<TResult, TError> = (
   result: QueryResult<TResult, TError>
 ) => void
 
+interface NotifyOptions {
+  globalListeners?: boolean
+  listener?: boolean
+  onError?: boolean
+  onSuccess?: boolean
+}
+
 export class QueryObserver<TResult, TError> {
   config: ResolvedQueryConfig<TResult, TError>
 
@@ -150,13 +157,46 @@ export class QueryObserver<TResult, TError> {
     }
   }
 
-  private notify(global?: boolean): void {
+  private notify(options: NotifyOptions): void {
+    const { config, currentResult, currentQuery, listener } = this
+    const { onSuccess, onSettled, onError } = config
+
     notifyManager.batch(() => {
-      notifyManager.schedule(() => {
-        this.listener?.(this.currentResult)
-      })
-      if (global) {
-        this.config.queryCache.notifyGlobalListeners(this.currentQuery)
+      // First trigger the configuration callbacks
+      if (options.onSuccess) {
+        if (onSuccess) {
+          notifyManager.schedule(() => {
+            onSuccess(currentResult.data!)
+          })
+        }
+        if (onSettled) {
+          notifyManager.schedule(() => {
+            onSettled(currentResult.data!, null)
+          })
+        }
+      } else if (options.onError) {
+        if (onError) {
+          notifyManager.schedule(() => {
+            onError(currentResult.error!)
+          })
+        }
+        if (onSettled) {
+          notifyManager.schedule(() => {
+            onSettled(undefined, currentResult.error!)
+          })
+        }
+      }
+
+      // Then trigger the listener
+      if (options.listener && listener) {
+        notifyManager.schedule(() => {
+          listener(currentResult)
+        })
+      }
+
+      // Then the global listeners
+      if (options.globalListeners) {
+        config.queryCache.notifyGlobalListeners(currentQuery)
       }
     })
   }
@@ -180,7 +220,7 @@ export class QueryObserver<TResult, TError> {
       if (!this.isStale) {
         this.isStale = true
         this.updateResult()
-        this.notify(true)
+        this.notify({ listener: true, globalListeners: true })
       }
     }, timeout)
   }
@@ -327,28 +367,30 @@ export class QueryObserver<TResult, TError> {
       this.updateTimers()
     }
 
-    // Trigger callbacks on success or error
-    if (type === 2) {
-      config.onSuccess?.(currentResult.data!)
-      config.onSettled?.(currentResult.data!, null)
-    } else if (type === 3) {
-      config.onError?.(currentResult.error!)
-      config.onSettled?.(undefined, currentResult.error!)
-    }
-
     // Do not notify if the query was invalidated but the stale state did not changed
     if (type === 4 && currentResult.isStale === prevResult.isStale) {
       return
     }
 
+    // Determine which callbacks to trigger
+    const notifyOptions: NotifyOptions = {}
+
+    if (type === 2) {
+      notifyOptions.onSuccess = true
+    } else if (type === 3) {
+      notifyOptions.onError = true
+    }
+
     if (
-      // Always notify on data or error change
+      // Always notify if notifyOnStatusChange is set
+      config.notifyOnStatusChange ||
+      // Otherwise only notify on data or error change
       currentResult.data !== prevResult.data ||
-      currentResult.error !== prevResult.error ||
-      // Maybe notify on other changes
-      config.notifyOnStatusChange
+      currentResult.error !== prevResult.error
     ) {
-      this.notify()
+      notifyOptions.listener = true
     }
+
+    this.notify(notifyOptions)
   }
 }
diff --git a/src/core/types.ts b/src/core/types.ts
index e86030300..af7c9879c 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -230,18 +230,18 @@ export interface MutateConfig<
   TVariables = unknown,
   TSnapshot = unknown
 > {
-  onSuccess?: (data: TResult, variables: TVariables) => Promise<void> | void
+  onSuccess?: (data: TResult, variables: TVariables) => Promise<unknown> | void
   onError?: (
     error: TError,
     variables: TVariables,
     snapshotValue: TSnapshot
-  ) => Promise<void> | void
+  ) => Promise<unknown> | void
   onSettled?: (
     data: undefined | TResult,
     error: TError | null,
     variables: TVariables,
     snapshotValue?: TSnapshot
-  ) => Promise<void> | void
+  ) => Promise<unknown> | void
   throwOnError?: boolean
 }
 


Test patch:
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index ad31cebc4..a92546995 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -1112,6 +1112,45 @@ describe('useQuery', () => {
     expect(renders).toBe(2)
   })
 
+  it('should batch re-renders including hook callbacks', async () => {
+    const key = queryKey()
+
+    let renders = 0
+    let renderedCount = 0
+
+    const queryFn = async () => {
+      await sleep(10)
+      return 'data'
+    }
+
+    function Page() {
+      const [count, setCount] = React.useState(0)
+      useQuery(key, queryFn, {
+        onSuccess: () => {
+          setCount(x => x + 1)
+        },
+      })
+      useQuery(key, queryFn, {
+        onSuccess: () => {
+          setCount(x => x + 1)
+        },
+      })
+      renders++
+      renderedCount = count
+      return null
+    }
+
+    render(<Page />)
+
+    await waitForMs(20)
+
+    // Should be 2 instead of 5
+    expect(renders).toBe(2)
+
+    // Both callbacks should have been executed
+    expect(renderedCount).toBe(2)
+  })
+
   // See https://github.com/tannerlinsley/react-query/issues/170
   it('should start with status idle if enabled is false', async () => {
     const key1 = queryKey()