Repository: tannerlinsley/react-query
Pull request #1792 (2021-02-13 06:23:15)

feat(subscriptions): add event types and metadata to subscriptions

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/22322av5l](https://vercel.com/tannerlinsley/react-query/22322av5l)  
✅ Preview: *In Progress*



Patch:
diff --git a/docs/src/pages/guides/initial-query-data.md b/docs/src/pages/guides/initial-query-data.md
index 502f4a69d..c15f024b6 100644
--- a/docs/src/pages/guides/initial-query-data.md
+++ b/docs/src/pages/guides/initial-query-data.md
@@ -52,7 +52,7 @@ By default, `initialData` is treated as totally fresh, as if it were just fetche
   }
   ```
 
-- So what if your `initialData` isn't totally fresh? That leaves us with the last configuration that is actually the most accurate and uses an option called `initialDataUpdatedAt`. This options allows you to pass a numeric JS timestamp in milliseconds of when the initialData itself was last updated, e.g. what `Date.now()` provides. Take note that if you have a unix timestamp, you'll need to convert it to a JS timestamp by multiplying it by `1000`.
+- So what if your `initialData` isn't totally fresh? That leaves us with the last configuration that is actually the most accurate and uses an option called `initialDataUpdatedAt`. This option allows you to pass a numeric JS timestamp in milliseconds of when the initialData itself was last updated, e.g. what `Date.now()` provides. Take note that if you have a unix timestamp, you'll need to convert it to a JS timestamp by multiplying it by `1000`.
   ```js
   function Todos() {
     // Show initialTodos immeidately, but won't refetch until another interaction event is encountered after 1000 ms
diff --git a/docs/src/pages/guides/migrating-to-react-query-3.md b/docs/src/pages/guides/migrating-to-react-query-3.md
index 16c2ec800..4190cd3a6 100644
--- a/docs/src/pages/guides/migrating-to-react-query-3.md
+++ b/docs/src/pages/guides/migrating-to-react-query-3.md
@@ -346,7 +346,7 @@ The `queryFnParamsFilter` option has been removed because query functions now ge
 
 Parameters can still be filtered within the query function itself as the `QueryFunctionContext` also contains the query key.
 
-### The `QueryOptions.notifyOnStatusChange` option has been superceded by the new `notifyonChangeProps` and `notifyOnChangePropsExclusions` options.
+### The `QueryOptions.notifyOnStatusChange` option has been superceded by the new `notifyOnChangeProps` and `notifyOnChangePropsExclusions` options.
 
 With these new options it is possible to configure when a component should re-render on a granular level.
 
diff --git a/docs/src/pages/plugins/persistQueryClient.md b/docs/src/pages/plugins/persistQueryClient.md
index 0306a746d..ba5aae179 100644
--- a/docs/src/pages/plugins/persistQueryClient.md
+++ b/docs/src/pages/plugins/persistQueryClient.md
@@ -9,7 +9,7 @@ title: persistQueryClient (Experimental)
 
 ## Officially Supported Persistors
 
-- [createLocalStoragePersistor (Experimental)](../createLocalStoragePersistor)
+- [createLocalStoragePersistor (Experimental)](/plugins/createLocalStoragePersistor)
 
 ## Installation
 
diff --git a/src/core/infiniteQueryObserver.ts b/src/core/infiniteQueryObserver.ts
index 4b00be998..1959435a0 100644
--- a/src/core/infiniteQueryObserver.ts
+++ b/src/core/infiniteQueryObserver.ts
@@ -108,14 +108,14 @@ export class InfiniteQueryObserver<
       TQueryData
     >
   ): InfiniteQueryObserverResult<TData, TError> {
-    const { state } = this.getCurrentQuery()
+    const { state } = query
     const result = super.createResult(query, options)
     return {
       ...result,
       fetchNextPage: this.fetchNextPage,
       fetchPreviousPage: this.fetchPreviousPage,
-      hasNextPage: hasNextPage(this.options, state.data?.pages),
-      hasPreviousPage: hasPreviousPage(this.options, state.data?.pages),
+      hasNextPage: hasNextPage(options, state.data?.pages),
+      hasPreviousPage: hasPreviousPage(options, state.data?.pages),
       isFetchingNextPage:
         state.isFetching && state.fetchMeta?.fetchMore?.direction === 'forward',
       isFetchingPreviousPage:
diff --git a/src/core/queriesObserver.ts b/src/core/queriesObserver.ts
index b18b6d275..e133d7c3f 100644
--- a/src/core/queriesObserver.ts
+++ b/src/core/queriesObserver.ts
@@ -1,4 +1,4 @@
-import { difference, getQueryKeyHashFn, replaceAt } from './utils'
+import { difference, replaceAt } from './utils'
 import { notifyManager } from './notifyManager'
 import type { QueryObserverOptions, QueryObserverResult } from './types'
 import type { QueryClient } from './queryClient'
@@ -12,15 +12,20 @@ export class QueriesObserver extends Subscribable<QueriesObserverListener> {
   private result: QueryObserverResult[]
   private queries: QueryObserverOptions[]
   private observers: QueryObserver[]
+  private observersMap: Record<string, QueryObserver>
 
   constructor(client: QueryClient, queries?: QueryObserverOptions[]) {
     super()
 
     this.client = client
-    this.queries = queries || []
+    this.queries = []
     this.result = []
     this.observers = []
-    this.setQueries(this.queries)
+    this.observersMap = {}
+
+    if (queries) {
+      this.setQueries(queries)
+    }
   }
 
   protected onSubscribe(): void {
@@ -59,60 +64,51 @@ export class QueriesObserver extends Subscribable<QueriesObserverListener> {
   }
 
   getOptimisticResult(queries: QueryObserverOptions[]): QueryObserverResult[] {
-    return queries.map((options, i) => {
-      let observer: QueryObserver | undefined = this.observers[i]
-
+    return queries.map(options => {
       const defaultedOptions = this.client.defaultQueryObserverOptions(options)
-      const hashFn = getQueryKeyHashFn(defaultedOptions)
-      defaultedOptions.queryHash = hashFn(defaultedOptions.queryKey!)
-
-      if (
-        !observer ||
-        observer.getCurrentQuery().queryHash !== defaultedOptions.queryHash
-      ) {
-        observer = this.observers.find(
-          x => x.getCurrentQuery().queryHash === defaultedOptions.queryHash
-        )
-      }
-
-      if (!observer) {
-        observer = new QueryObserver(this.client, defaultedOptions)
-      }
-
-      return observer.getOptimisticResult(defaultedOptions)
+      return this.getObserver(defaultedOptions).getOptimisticResult(
+        defaultedOptions
+      )
     })
   }
 
+  private getObserver(options: QueryObserverOptions): QueryObserver {
+    const defaultedOptions = this.client.defaultQueryObserverOptions(options)
+    return (
+      this.observersMap[defaultedOptions.queryHash!] ||
+      new QueryObserver(this.client, defaultedOptions)
+    )
+  }
+
   private updateObservers(notifyOptions?: NotifyOptions): void {
     notifyManager.batch(() => {
       let hasIndexChange = false
 
       const prevObservers = this.observers
-      const newObservers = this.queries.map((options, i) => {
-        let observer: QueryObserver | undefined = prevObservers[i]
+      const prevOberversMap = this.observersMap
 
+      const newResult: QueryObserverResult[] = []
+      const newObservers: QueryObserver[] = []
+      const newObserversMap: Record<string, QueryObserver> = {}
+
+      this.queries.forEach((options, i) => {
         const defaultedOptions = this.client.defaultQueryObserverOptions(
           options
         )
-        const hashFn = getQueryKeyHashFn(defaultedOptions)
-        defaultedOptions.queryHash = hashFn(defaultedOptions.queryKey!)
+        const queryHash = defaultedOptions.queryHash!
+        const observer = this.getObserver(defaultedOptions)
 
-        if (
-          !observer ||
-          observer.getCurrentQuery().queryHash !== defaultedOptions.queryHash
-        ) {
-          hasIndexChange = true
-          observer = prevObservers.find(
-            x => x.getCurrentQuery().queryHash === defaultedOptions.queryHash
-          )
+        if (prevOberversMap[queryHash]) {
+          observer.setOptions(defaultedOptions, notifyOptions)
         }
 
-        if (observer) {
-          observer.setOptions(defaultedOptions, notifyOptions)
-          return observer
+        if (observer !== prevObservers[i]) {
+          hasIndexChange = true
         }
 
-        return new QueryObserver(this.client, defaultedOptions)
+        newObservers.push(observer)
+        newResult.push(observer.getCurrentResult())
+        newObserversMap[queryHash] = observer
       })
 
       if (prevObservers.length === newObservers.length && !hasIndexChange) {
@@ -120,9 +116,10 @@ export class QueriesObserver extends Subscribable<QueriesObserverListener> {
       }
 
       this.observers = newObservers
-      this.result = newObservers.map(observer => observer.getCurrentResult())
+      this.observersMap = newObserversMap
+      this.result = newResult
 
-      if (!this.listeners.length) {
+      if (!this.hasListeners()) {
         return
       }
 
diff --git a/src/core/query.ts b/src/core/query.ts
index 353b0b89e..69be7422a 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -289,7 +289,7 @@ export class Query<
       // Stop the query from being garbage collected
       this.clearGcTimeout()
 
-      this.cache.notify(this)
+      this.cache.notify({ type: 'observerAdded', query: this, observer })
     }
   }
 
@@ -315,7 +315,7 @@ export class Query<
         }
       }
 
-      this.cache.notify(this)
+      this.cache.notify({ type: 'observerRemoved', query: this, observer })
     }
   }
 
@@ -401,7 +401,7 @@ export class Query<
           this.optionalRemove()
         }
       },
-      onError: error => {
+      onError: (error: TError | { silent?: boolean }) => {
         // Optimistically update state if needed
         if (!(isCancelledError(error) && error.silent)) {
           this.dispatch({
@@ -451,7 +451,7 @@ export class Query<
         observer.onQueryUpdate(action)
       })
 
-      this.cache.notify(this)
+      this.cache.notify({ query: this, type: 'queryUpdated', action })
     })
   }
 
diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts
index a0652fe6d..c9c6ed3ae 100644
--- a/src/core/queryCache.ts
+++ b/src/core/queryCache.ts
@@ -1,14 +1,15 @@
 import {
   QueryFilters,
-  getQueryKeyHashFn,
+  hashQueryKeyByOptions,
   matchQuery,
   parseFilterArgs,
 } from './utils'
-import { Query, QueryState } from './query'
+import { Action, Query, QueryState } from './query'
 import type { QueryKey, QueryOptions } from './types'
 import { notifyManager } from './notifyManager'
 import type { QueryClient } from './queryClient'
 import { Subscribable } from './subscribable'
+import { QueryObserver } from './queryObserver'
 
 // TYPES
 
@@ -20,7 +21,48 @@ interface QueryHashMap {
   [hash: string]: Query<any, any>
 }
 
-type QueryCacheListener = (query?: Query) => void
+interface NotifyEventQueryAdded {
+  type: 'queryAdded'
+  query: Query<any, any>
+}
+
+interface NotifyEventQueryRemoved {
+  type: 'queryRemoved'
+  query: Query<any, any>
+}
+
+interface NotifyEventQueryUpdated {
+  type: 'queryUpdated'
+  query: Query<any, any>
+  action: Action<any, any>
+}
+
+interface NotifyEventObserverAdded {
+  type: 'observerAdded'
+  query: Query<any, any>
+  observer: QueryObserver<any, any, any, any>
+}
+
+interface NotifyEventObserverRemoved {
+  type: 'observerRemoved'
+  query: Query<any, any>
+  observer: QueryObserver<any, any, any, any>
+}
+
+interface NotifyEventObserverResultsUpdated {
+  type: 'observerResultsUpdated'
+  query: Query<any, any>
+}
+
+type QueryCacheNotifyEvent =
+  | NotifyEventQueryAdded
+  | NotifyEventQueryRemoved
+  | NotifyEventQueryUpdated
+  | NotifyEventObserverAdded
+  | NotifyEventObserverRemoved
+  | NotifyEventObserverResultsUpdated
+
+type QueryCacheListener = (event?: QueryCacheNotifyEvent) => void
 
 // CLASS
 
@@ -43,7 +85,8 @@ export class QueryCache extends Subscribable<QueryCacheListener> {
     state?: QueryState<TData, TError>
   ): Query<TQueryFnData, TError, TData> {
     const queryKey = options.queryKey!
-    const queryHash = options.queryHash ?? getQueryKeyHashFn(options)(queryKey)
+    const queryHash =
+      options.queryHash ?? hashQueryKeyByOptions(queryKey, options)
     let query = this.get<TQueryFnData, TError, TData>(queryHash)
 
     if (!query) {
@@ -65,7 +108,10 @@ export class QueryCache extends Subscribable<QueryCacheListener> {
     if (!this.queriesMap[query.queryHash]) {
       this.queriesMap[query.queryHash] = query
       this.queries.push(query)
-      this.notify(query)
+      this.notify({
+        type: 'queryAdded',
+        query,
+      })
     }
   }
 
@@ -81,7 +127,7 @@ export class QueryCache extends Subscribable<QueryCacheListener> {
         delete this.queriesMap[query.queryHash]
       }
 
-      this.notify(query)
+      this.notify({ type: 'queryRemoved', query })
     }
   }
 
@@ -126,10 +172,10 @@ export class QueryCache extends Subscribable<QueryCacheListener> {
       : this.queries
   }
 
-  notify(query?: Query<any, any>) {
+  notify(event: QueryCacheNotifyEvent) {
     notifyManager.batch(() => {
       this.listeners.forEach(listener => {
-        listener(query)
+        listener(event)
       })
     })
   }
diff --git a/src/core/queryClient.ts b/src/core/queryClient.ts
index a855b8064..a7230fbfb 100644
--- a/src/core/queryClient.ts
+++ b/src/core/queryClient.ts
@@ -6,7 +6,7 @@ import {
   parseFilterArgs,
   parseQueryArgs,
   partialMatchKey,
-  getQueryKeyHashFn,
+  hashQueryKeyByOptions,
 } from './utils'
 import type {
   DefaultOptions,
@@ -456,8 +456,9 @@ export class QueryClient {
     } as T
 
     if (!defaultedOptions.queryHash && defaultedOptions.queryKey) {
-      defaultedOptions.queryHash = getQueryKeyHashFn(defaultedOptions)(
-        defaultedOptions.queryKey
+      defaultedOptions.queryHash = hashQueryKeyByOptions(
+        defaultedOptions.queryKey,
+        defaultedOptions
       )
     }
 
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index 5c698f3ce..f88b4e791 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -245,6 +245,21 @@ export class QueryObserver<
     return this.fetch(options)
   }
 
+  fetchOptimistic(
+    options: QueryObserverOptions<TQueryFnData, TError, TData, TQueryData>
+  ): Promise<QueryObserverResult<TData, TError>> {
+    const defaultedOptions = this.client.defaultQueryObserverOptions(options)
+
+    const query = this.client
+      .getQueryCache()
+      .build(
+        this.client,
+        defaultedOptions as QueryOptions<TQueryFnData, TError, TQueryData>
+      )
+
+    return query.fetch().then(() => this.createResult(query, defaultedOptions))
+  }
+
   protected fetch(
     fetchOptions?: ObserverFetchOptions
   ): Promise<QueryObserverResult<TData, TError>> {
@@ -599,7 +614,9 @@ export class QueryObserver<
 
       // Then the cache listeners
       if (notifyOptions.cache) {
-        this.client.getQueryCache().notify(this.currentQuery)
+        this.client
+          .getQueryCache()
+          .notify({ query: this.currentQuery, type: 'observerResultsUpdated' })
       }
     })
   }
@@ -666,8 +683,8 @@ function shouldFetchOptionally(
   prevOptions: QueryObserverOptions<any, any>
 ): boolean {
   return (
-    (query !== prevQuery ||
-      (options.enabled !== false && prevOptions.enabled === false)) &&
+    options.enabled !== false &&
+    (query !== prevQuery || prevOptions.enabled === false) &&
     isStale(query, options)
   )
 }
diff --git a/src/core/utils.ts b/src/core/utils.ts
index 56dc8ca72..7b7870d07 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -5,7 +5,6 @@ import type {
   MutationOptions,
   QueryFunction,
   QueryKey,
-  QueryKeyHashFunction,
   QueryOptions,
 } from './types'
 
@@ -153,8 +152,7 @@ export function matchQuery(
 
   if (isQueryKey(queryKey)) {
     if (exact) {
-      const hashFn = getQueryKeyHashFn(query.options)
-      if (query.queryHash !== hashFn(queryKey)) {
+      if (query.queryHash !== hashQueryKeyByOptions(queryKey, query.options)) {
         return false
       }
     } else if (!partialMatchKey(query.queryKey, queryKey)) {
@@ -189,17 +187,20 @@ export function matchQuery(
   return true
 }
 
-export function getQueryKeyHashFn(
+export function hashQueryKeyByOptions(
+  queryKey: QueryKey,
   options?: QueryOptions<any, any>
-): QueryKeyHashFunction {
-  return options?.queryKeyHashFn || hashQueryKey
+): string {
+  const hashFn = options?.queryKeyHashFn || hashQueryKey
+  return hashFn(queryKey)
 }
 
 /**
  * Default query keys hash function.
  */
 export function hashQueryKey(queryKey: QueryKey): string {
-  return stableValueHash(queryKey)
+  const asArray = Array.isArray(queryKey) ? queryKey : [queryKey]
+  return stableValueHash(asArray)
 }
 
 /**
diff --git a/src/devtools/devtools.tsx b/src/devtools/devtools.tsx
index a4c9728ad..62bebb5e6 100644
--- a/src/devtools/devtools.tsx
+++ b/src/devtools/devtools.tsx
@@ -268,8 +268,6 @@ export function ReactQueryDevtools({
             border: 0,
             padding: 0,
             position: 'fixed',
-            bottom: '0',
-            right: '0',
             zIndex: '99999',
             display: 'inline-flex',
             fontSize: '1.5rem',
diff --git a/src/react/QueryClientProvider.tsx b/src/react/QueryClientProvider.tsx
index 3e17145a1..174363785 100644
--- a/src/react/QueryClientProvider.tsx
+++ b/src/react/QueryClientProvider.tsx
@@ -2,22 +2,31 @@ import React from 'react'
 
 import { QueryClient } from '../core'
 
-const QueryClientContext = (() => {
-  const context = React.createContext<QueryClient | undefined>(undefined)
-  if (typeof window !== 'undefined') {
-    // @ts-ignore
-    window.ReactQueryClientContext = context
+declare global {
+  interface Window {
+    ReactQueryClientContext?: React.Context<QueryClient | undefined>
   }
-  return context
-})()
+}
 
+const defaultContext = React.createContext<QueryClient | undefined>(undefined)
+
+// We share the first and at least one
+// instance of the context across the window
+// to ensure that if React Query is used across
+// different bundles or microfrontends they will
+// all use the same **instance** of context, regardless
+// of module scoping.
 function getQueryClientContext() {
-  return typeof window !== 'undefined'
-    ? // @ts-ignore
-      (window.ReactQueryClientContext as React.Context<
-        QueryClient | undefined
-      >) ?? QueryClientContext
-    : QueryClientContext
+  // @ts-ignore (for global)
+  if (typeof window !== 'undefined') {
+    if (!window.ReactQueryClientContext) {
+      window.ReactQueryClientContext = defaultContext
+    }
+
+    return window.ReactQueryClientContext
+  }
+
+  return defaultContext
 }
 
 export const useQueryClient = () => {
diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts
index 220cefbfd..9dfc224e7 100644
--- a/src/react/useBaseQuery.ts
+++ b/src/react/useBaseQuery.ts
@@ -41,7 +41,7 @@ export function useBaseQuery<TQueryFnData, TError, TData, TQueryData>(
 
   if (defaultedOptions.suspense) {
     // Always set stale time when using suspense to prevent
-    // fetching again when directly re-mounting after suspense
+    // fetching again when directly mounting after suspending
     if (typeof defaultedOptions.staleTime !== 'number') {
       defaultedOptions.staleTime = 1000
     }
@@ -65,9 +65,10 @@ export function useBaseQuery<TQueryFnData, TError, TData, TQueryData>(
   React.useEffect(() => {
     mountedRef.current = true
 
+    errorResetBoundary.clearReset()
+
     const unsubscribe = obsRef.current!.subscribe(
       notifyManager.batchCalls(() => {
-        errorResetBoundary.clearReset()
         if (mountedRef.current) {
           forceUpdate(x => x + 1)
         }
@@ -91,10 +92,10 @@ export function useBaseQuery<TQueryFnData, TError, TData, TQueryData>(
   }, [defaultedOptions])
 
   // Handle suspense
-  if (obsRef.current.options.suspense && result.isLoading) {
-    throw queryClient
-      .fetchQuery(defaultedOptions)
-      .then(data => {
+  if (defaultedOptions.suspense && result.isLoading) {
+    throw obsRef.current
+      .fetchOptimistic(defaultedOptions)
+      .then(({ data }) => {
         defaultedOptions.onSuccess?.(data)
         defaultedOptions.onSettled?.(data, null)
       })
@@ -107,15 +108,14 @@ export function useBaseQuery<TQueryFnData, TError, TData, TQueryData>(
 
   // Handle error boundary
   if (
-    (obsRef.current.options.suspense ||
-      obsRef.current.options.useErrorBoundary) &&
+    (defaultedOptions.suspense || defaultedOptions.useErrorBoundary) &&
     result.isError
   ) {
     throw result.error
   }
 
   // Handle result property usage tracking
-  if (obsRef.current.options.notifyOnChangeProps === 'tracked') {
+  if (defaultedOptions.notifyOnChangeProps === 'tracked') {
     result = obsRef.current.trackResult(result)
   }
 


Test patch:
diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx
index 179d016f9..b130e4812 100644
--- a/src/core/tests/queryCache.test.tsx
+++ b/src/core/tests/queryCache.test.tsx
@@ -22,7 +22,7 @@ describe('queryCache', () => {
       queryClient.setQueryData(key, 'foo')
       const query = queryCache.find(key)
       await sleep(1)
-      expect(subscriber).toHaveBeenCalledWith(query)
+      expect(subscriber).toHaveBeenCalledWith({ query, type: 'queryAdded' })
       unsubscribe()
     })
 
@@ -42,7 +42,7 @@ describe('queryCache', () => {
       queryClient.prefetchQuery(key, () => 'data')
       const query = queryCache.find(key)
       await sleep(100)
-      expect(callback).toHaveBeenCalledWith(query)
+      expect(callback).toHaveBeenCalledWith({ query, type: 'queryAdded' })
     })
 
     test('should notify subscribers when new query with initialData is added', async () => {
diff --git a/src/core/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx
index 1de12ad59..f34d7a080 100644
--- a/src/core/tests/queryClient.test.tsx
+++ b/src/core/tests/queryClient.test.tsx
@@ -140,6 +140,19 @@ describe('queryClient', () => {
       expect(queryClient.getQueryData(key)).toBe('qux')
     })
 
+    test('should use the same query when using similar string or array query keys', () => {
+      const key = queryKey()
+      queryClient.setQueryData(key, '1')
+      expect(queryClient.getQueryData(key)).toBe('1')
+      expect(queryClient.getQueryData([key])).toBe('1')
+      queryClient.setQueryData([key], '2')
+      expect(queryClient.getQueryData(key)).toBe('2')
+      expect(queryClient.getQueryData([key])).toBe('2')
+      queryClient.setQueryData(key, '1')
+      expect(queryClient.getQueryData(key)).toBe('1')
+      expect(queryClient.getQueryData([key])).toBe('1')
+    })
+
     test('should accept an update function', () => {
       const key = queryKey()
 
diff --git a/src/react/tests/ssr.test.tsx b/src/react/tests/ssr.test.tsx
index 0fcc19af1..2363a160c 100644
--- a/src/react/tests/ssr.test.tsx
+++ b/src/react/tests/ssr.test.tsx
@@ -7,7 +7,13 @@ import React from 'react'
 import { renderToString } from 'react-dom/server'
 
 import { sleep, queryKey } from './utils'
-import { useQuery, QueryClient, QueryClientProvider, QueryCache } from '../..'
+import {
+  useQuery,
+  QueryClient,
+  QueryClientProvider,
+  QueryCache,
+  useInfiniteQuery,
+} from '../..'
 
 describe('Server Side Rendering', () => {
   it('should not trigger fetch', () => {
@@ -112,4 +118,37 @@ describe('Server Side Rendering', () => {
     expect(keys).toEqual([[key, 1]])
     queryCache.clear()
   })
+
+  it('useInfiniteQuery should return the correct state', async () => {
+    const queryCache = new QueryCache()
+    const queryClient = new QueryClient({ queryCache })
+    const key = queryKey()
+    const queryFn = jest.fn(async () => {
+      await sleep(5)
+      return 'page 1'
+    })
+
+    function Page() {
+      const query = useInfiniteQuery(key, queryFn)
+      return (
+        <ul>
+          {query.data?.pages?.map(page => (
+            <li key={page}>{page}</li>
+          ))}
+        </ul>
+      )
+    }
+
+    await queryClient.prefetchInfiniteQuery(key, queryFn)
+
+    const markup = renderToString(
+      <QueryClientProvider client={queryClient}>
+        <Page />
+      </QueryClientProvider>
+    )
+
+    expect(markup).toContain('page 1')
+    expect(queryFn).toHaveBeenCalledTimes(1)
+    queryCache.clear()
+  })
 })
diff --git a/src/react/tests/suspense.test.tsx b/src/react/tests/suspense.test.tsx
index a8534bc88..a52af6602 100644
--- a/src/react/tests/suspense.test.tsx
+++ b/src/react/tests/suspense.test.tsx
@@ -10,6 +10,8 @@ import {
   QueryErrorResetBoundary,
   useQueryErrorResetBoundary,
   UseQueryResult,
+  UseInfiniteQueryResult,
+  useInfiniteQuery,
 } from '../..'
 
 describe("useQuery's in Suspense mode", () => {
@@ -65,6 +67,39 @@ describe("useQuery's in Suspense mode", () => {
     expect(states[1]).toMatchObject({ data: 2, status: 'success' })
   })
 
+  it('should return the correct states for a successful infinite query', async () => {
+    const key = queryKey()
+    const states: UseInfiniteQueryResult<number>[] = []
+
+    function Page() {
+      const state = useInfiniteQuery(
+        key,
+        ({ pageParam = 0 }) => Number(pageParam),
+        {
+          suspense: true,
+          getNextPageParam: lastPage => lastPage + 1,
+        }
+      )
+      states.push(state)
+      return null
+    }
+
+    renderWithClient(
+      queryClient,
+      <React.Suspense fallback="loading">
+        <Page />
+      </React.Suspense>
+    )
+
+    await sleep(10)
+
+    expect(states.length).toBe(1)
+    expect(states[0]).toMatchObject({
+      data: { pages: [0], pageParams: [undefined] },
+      status: 'success',
+    })
+  })
+
   it('should not call the queryFn twice when used in Suspense mode', async () => {
     const key = queryKey()
 
@@ -133,10 +168,18 @@ describe("useQuery's in Suspense mode", () => {
     const successFn = jest.fn()
 
     function Page() {
-      useQuery([key], () => sleep(10), {
-        suspense: true,
-        onSuccess: successFn,
-      })
+      useQuery(
+        [key],
+        async () => {
+          await sleep(10)
+          return key
+        },
+        {
+          suspense: true,
+          select: () => 'selected',
+          onSuccess: successFn,
+        }
+      )
 
       return <>rendered</>
     }
@@ -151,6 +194,7 @@ describe("useQuery's in Suspense mode", () => {
     await waitFor(() => rendered.getByText('rendered'))
 
     expect(successFn).toHaveBeenCalledTimes(1)
+    expect(successFn).toHaveBeenCalledWith('selected')
   })
 
   it('should call every onSuccess handler within a suspense boundary', async () => {
diff --git a/src/react/tests/useInfiniteQuery.test.tsx b/src/react/tests/useInfiniteQuery.test.tsx
index 51e8a5579..81b9b5b3e 100644
--- a/src/react/tests/useInfiniteQuery.test.tsx
+++ b/src/react/tests/useInfiniteQuery.test.tsx
@@ -1144,6 +1144,143 @@ describe('useInfiniteQuery', () => {
     })
   })
 
+  it('should render correct states even in case of concurrent renders with different properties', async () => {
+    const key = queryKey()
+    const states: UseInfiniteQueryResult<number>[] = []
+    let concurrent = false
+    const originalUseEffect = React.useEffect
+    const dummyUseEffect = (...args: any[]) => {
+      originalUseEffect(() => {
+        return
+      }, args[1])
+    }
+
+    function Page() {
+      const [count, setCount] = React.useState(0)
+
+      if (concurrent) {
+        React.useEffect = dummyUseEffect
+      }
+
+      const state = useInfiniteQuery(
+        [key, count],
+        async () => {
+          await sleep(10)
+          return count
+        },
+        {
+          staleTime: Infinity,
+          keepPreviousData: true,
+          getNextPageParam: () => (count === 0 ? undefined : count + 1),
+        }
+      )
+
+      if (concurrent) {
+        React.useEffect = originalUseEffect
+      }
+
+      states.push(state)
+
+      React.useEffect(() => {
+        setActTimeout(() => {
+          setCount(1)
+        }, 20)
+
+        // Try to simulate concurrent render which does not trigger effects
+        setActTimeout(() => {
+          concurrent = true
+          setCount(0)
+        }, 40)
+
+        setActTimeout(() => {
+          concurrent = false
+          setCount(2)
+        }, 60)
+      }, [])
+
+      return null
+    }
+
+    renderWithClient(queryClient, <Page />)
+
+    await sleep(100)
+
+    expect(states.length).toBe(9)
+
+    // Load query 0
+    expect(states[0]).toMatchObject({
+      status: 'loading',
+      data: undefined,
+      isFetching: true,
+      isPreviousData: false,
+      hasNextPage: undefined,
+    })
+    // Fetch done
+    expect(states[1]).toMatchObject({
+      status: 'success',
+      data: { pageParams: [undefined], pages: [0] },
+      isFetching: false,
+      isPreviousData: false,
+      hasNextPage: false,
+    })
+    // Set state to query 1
+    expect(states[2]).toMatchObject({
+      status: 'success',
+      data: { pageParams: [undefined], pages: [0] },
+      isFetching: true,
+      isPreviousData: true,
+      hasNextPage: undefined,
+    })
+    // Fetch start
+    expect(states[3]).toMatchObject({
+      status: 'success',
+      data: { pageParams: [undefined], pages: [0] },
+      isFetching: true,
+      isPreviousData: true,
+      hasNextPage: undefined,
+    })
+    // Fetch done
+    expect(states[4]).toMatchObject({
+      status: 'success',
+      data: { pageParams: [undefined], pages: [1] },
+      isFetching: false,
+      isPreviousData: false,
+      hasNextPage: true,
+    })
+    // Concurrent render for query 0
+    expect(states[5]).toMatchObject({
+      status: 'success',
+      data: { pageParams: [undefined], pages: [0] },
+      isFetching: false,
+      isPreviousData: false,
+      hasNextPage: false,
+    })
+    // Set state to query 2 (should have query 1 has previous data)
+    expect(states[6]).toMatchObject({
+      status: 'success',
+      data: { pageParams: [undefined], pages: [1] },
+      isFetching: true,
+      isPreviousData: true,
+      hasNextPage: undefined,
+    })
+    // Fetch start
+    expect(states[7]).toMatchObject({
+      status: 'success',
+      data: { pageParams: [undefined], pages: [1] },
+      isFetching: true,
+      isPreviousData: true,
+      hasNextPage: undefined,
+    })
+    // Fetch done
+    expect(states[8]).toMatchObject({
+      status: 'success',
+      data: { pageParams: [undefined], pages: [2] },
+      isFetching: false,
+      isPreviousData: false,
+      hasNextPage: true,
+    })
+  })
+
   it('should build fresh cursors on refetch', async () => {
     const key = queryKey()
 
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index c7826564f..ceb746bb0 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -1218,6 +1218,59 @@ describe('useQuery', () => {
     })
   })
 
+  it('should not fetch when switching to a disabled query', async () => {
+    const key = queryKey()
+    const states: UseQueryResult<number>[] = []
+
+    function Page() {
+      const [count, setCount] = React.useState(0)
+
+      const state = useQuery(
+        [key, count],
+        async () => {
+          await sleep(5)
+          return count
+        },
+        { enabled: count === 0 }
+      )
+
+      states.push(state)
+
+      React.useEffect(() => {
+        setActTimeout(() => {
+          setCount(1)
+        }, 10)
+      }, [])
+
+      return null
+    }
+
+    renderWithClient(queryClient, <Page />)
+
+    await sleep(50)
+
+    expect(states.length).toBe(3)
+
+    // Fetch query
+    expect(states[0]).toMatchObject({
+      data: undefined,
+      isFetching: true,
+      isSuccess: false,
+    })
+    // Fetched query
+    expect(states[1]).toMatchObject({
+      data: 0,
+      isFetching: false,
+      isSuccess: true,
+    })
+    // Switch to disabled query
+    expect(states[2]).toMatchObject({
+      data: undefined,
+      isFetching: false,
+      isSuccess: false,
+    })
+  })
+
   it('should keep the previous data when keepPreviousData is set', async () => {
     const key = queryKey()
     const states: UseQueryResult<number>[] = []
@@ -1525,7 +1578,7 @@ describe('useQuery', () => {
     // Set state
     expect(states[3]).toMatchObject({
       data: 0,
-      isFetching: true,
+      isFetching: false,
       isSuccess: true,
       isPreviousData: true,
     })
@@ -1600,14 +1653,14 @@ describe('useQuery', () => {
     // Set state
     expect(states[1]).toMatchObject({
       data: 10,
-      isFetching: true,
+      isFetching: false,
       isSuccess: true,
       isPreviousData: true,
     })
-    // Set state
+    // State update
     expect(states[2]).toMatchObject({
       data: 10,
-      isFetching: true,
+      isFetching: false,
       isSuccess: true,
       isPreviousData: true,
     })