Repository: tannerlinsley/react-query
Pull request #2866 (2021-11-01 20:50:10)

fix(useQueries): search for unused observers if keepPreviousData (#2680)
#closes 2680

When `keepPreviousData` is on, search for a query observer no longer used instead of relying on observer order and indexing. Trying to match observers by index could cause infinite request loops when switching queries. See #2680 for more 

This does complicate the `getOptimisticResult` and `updateObservers` calls a bit. Now they both essentially do the same search for unused observers. The observers lists for the `queriesObserver` are only updated in the `updateObservers`, though. 

This does cause some extra updates, as can be seen in the tests. If I understand correctly, they probably are due to the `setOptions` calls being called both on `getOptimisticResult` and `updateObservers` calls. I tried for quite a while to get rid of the extra updates, but couldn't find a way that would still always return previous data, so eventually just left it like this and edited the tests. Any ideas to get rid of the extra updates are very welcome 😊 


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


codesandbox[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/tannerlinsley/react-query/pr/2866/builds/184176) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 4096064562224d99c8e8f21ffce273ce8cd42eec:

|Sandbox| Source |
|--|--|
|[tannerlinsley/react-query: basic](https://codesandbox.io/s/tannerlinsley-react-query-basic-ytfg5)| Configuration |
|[tannerlinsley/react-query: basic-typescript](https://codesandbox.io/s/tannerlinsley-react-query-basic-typescript-q627k)| Configuration |

TkDodo: > When keepPreviousData is on, search for a query observer no longer used instead of relying on observer order and indexing. Trying to match observers by index could cause infinite request loops when switching queries. See #2680 for more

I'm not yet fully understanding _how_ you're actually matching the observers together if not by index 🤔 

also, prettier formatting is failing, please have a look 👀 
jvuoti: Thank you for the comments, good points, I now fixed the namings and formatting.

And oh yeah, you are right that this still fulfills the observer for previous data by index 😅 Just now the indexing is done from the list of observers that are no longer being used.

Previously the code fulfilled the observer by index from the main observer list, assuming the order of the queries would have stayed the same. However, that was not always the case when e.g. toggling queries off and on. This could then cause the same observer being returned first to an existing query (by hash) and then to a new query as previous data (by index), causing the infinite request loop. The new test should test for this issue.

The matching could be more advanced, to e.g. try to find the nearest match of the query key parts. However, this now at least should fix the biggest issue of infinite request loops. It also fulfills our use case of toggling between similar queries, with only an identifier being changed.

The extra re-renders do happen only when keepPreviousData is turned on. If I have understood correctly, this happens because now the `getOptimisticResults` also needs to call the same search and update code as `updateObservers`. Because `getOptimisticResults` does not update the internal observer map caches, [firstObserver.setOptions](https://github.com/tannerlinsley/react-query/pull/2866/files#diff-ba20afedab2419b82900a184fbde1adb5d4408a0b7eaead6896c7482ae00bf7bR115) gets called twice. This then seems to trigger the listeners also twice, even if the `getOptimisticResults` has left the `notifyOptions` undefined - or even when explicitly setting all the options false. 
codecov[bot]: # [Codecov](https://codecov.io/gh/tannerlinsley/react-query/pull/2866?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley) Report
> Merging [#2866](https://codecov.io/gh/tannerlinsley/react-query/pull/2866?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley) (4a7b40c) into [master](https://codecov.io/gh/tannerlinsley/react-query/commit/9edb305f45146b902e7dbaa81419382ca813c0b6?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley) (9edb305) will **increase** coverage by `0.03%`.
> The diff coverage is `98.27%`.

[![Impacted file tree graph](https://codecov.io/gh/tannerlinsley/react-query/pull/2866/graphs/tree.svg?width=650&height=150&src=pr&token=jqEbswkEDQ&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley)](https://codecov.io/gh/tannerlinsley/react-query/pull/2866?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley)

```diff
@@            Coverage Diff             @@
##           master    #2866      +/-   ##
==========================================
+ Coverage   93.22%   93.25%   +0.03%     
==========================================
  Files          45       45              
  Lines        2228     2254      +26     
  Branches      638      644       +6     
==========================================
+ Hits         2077     2102      +25     
- Misses        148      149       +1     
  Partials        3        3              
```


| [Impacted Files](https://codecov.io/gh/tannerlinsley/react-query/pull/2866?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley) | Coverage Δ | |
|---|---|---|
| [src/core/queriesObserver.ts](https://codecov.io/gh/tannerlinsley/react-query/pull/2866/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley#diff-c3JjL2NvcmUvcXVlcmllc09ic2VydmVyLnRz) | `99.00% <98.21%> (-1.00%)` | :arrow_down: |
| [src/core/utils.ts](https://codecov.io/gh/tannerlinsley/react-query/pull/2866/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley#diff-c3JjL2NvcmUvdXRpbHMudHM=) | `95.85% <100.00%> (+0.04%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/tannerlinsley/react-query/pull/2866?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/tannerlinsley/react-query/pull/2866?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley). Last update [9edb305...4a7b40c](https://codecov.io/gh/tannerlinsley/react-query/pull/2866?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley).

TkDodo: can you please update with the latest `master`, as we've added some more tests. And please make sure that the test coverage does not drop (it currently does, see the codecov report)
jvuoti: > can you please add a `useQueries` test for the initial issue, the infinite render loop? Thanks.

The ["should keep previous data when switching between queries" test](https://github.com/tannerlinsley/react-query/pull/2866/files#diff-27ab6cf11862f134526488fd6c22381bf484a5ad9a6d7dc36535e1d3e2d5aa42R255) added to `useQueries.test` does test the infinite render loop issue as a side effect: if you run that test against master, the [expect on line 296](https://github.com/tannerlinsley/react-query/pull/2866/files#diff-27ab6cf11862f134526488fd6c22381bf484a5ad9a6d7dc36535e1d3e2d5aa42R296) will fail, because there are going to be over 250 result states in the list 😬 

But good point, it would be nice to have a simpler test explicitly for that issue. If needed, I can probably look at that over the weekend.
TkDodo: > But good point, it would be nice to have a simpler test explicitly for that issue. If needed, I can probably look at that over the weekend.

that would be great, thanks

Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index 6e5b781cf..432147811 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -156,6 +156,34 @@
       "contributions": [
         "code"
       ]
+    },
+    {
+      "login": "EddyVinck",
+      "name": "Eddy",
+      "avatar_url": "https://avatars.githubusercontent.com/u/23434753?v=4",
+      "profile": "http://www.eddyvinck.nl",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "prateek3255",
+      "name": "Prateek Surana",
+      "avatar_url": "https://avatars.githubusercontent.com/u/21277179?v=4",
+      "profile": "http://prateeksurana.me",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "babycourageous",
+      "name": "Rene Dellefont",
+      "avatar_url": "https://avatars.githubusercontent.com/u/14936212?v=4",
+      "profile": "https://github.com/babycourageous",
+      "contributions": [
+        "code",
+        "doc"
+      ]
     }
   ],
   "contributorsPerLine": 7,
diff --git a/README.md b/README.md
index 0925c1813..cab9ac311 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
   </tr>
   <tr>
     <td align="center"><a href="https://katt.dev"><img src="https://avatars.githubusercontent.com/u/459267?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Johansson</b></sub></a><br /><a href="https://github.com/tannerlinsley/react-query/commits?author=KATT" title="Code">💻</a></td>
+    <td align="center"><a href="http://www.eddyvinck.nl"><img src="https://avatars.githubusercontent.com/u/23434753?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Eddy</b></sub></a><br /><a href="https://github.com/tannerlinsley/react-query/commits?author=EddyVinck" title="Code">💻</a></td>
+    <td align="center"><a href="http://prateeksurana.me"><img src="https://avatars.githubusercontent.com/u/21277179?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Prateek Surana</b></sub></a><br /><a href="https://github.com/tannerlinsley/react-query/commits?author=prateek3255" title="Code">💻</a></td>
+    <td align="center"><a href="https://github.com/babycourageous"><img src="https://avatars.githubusercontent.com/u/14936212?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Rene Dellefont</b></sub></a><br /><a href="https://github.com/tannerlinsley/react-query/commits?author=babycourageous" title="Code">💻</a> <a href="https://github.com/tannerlinsley/react-query/commits?author=babycourageous" title="Documentation">📖</a></td>
   </tr>
 </table>
 
diff --git a/docs/src/pages/guides/query-keys.md b/docs/src/pages/guides/query-keys.md
index eb1cdc67b..f9239ea91 100644
--- a/docs/src/pages/guides/query-keys.md
+++ b/docs/src/pages/guides/query-keys.md
@@ -34,7 +34,7 @@ When a query needs more information to uniquely describe its data, you can use a
 useQuery(['todo', 5], ...)
 // queryKey === ['todo', 5]
 
-// And individual todo in a "preview" format
+// An individual todo in a "preview" format
 useQuery(['todo', 5, { preview: true }], ...)
 // queryKey === ['todo', 5, { preview: true }]
 
diff --git a/docs/src/pages/guides/window-focus-refetching.md b/docs/src/pages/guides/window-focus-refetching.md
index 2d41e1765..dc3b757d3 100644
--- a/docs/src/pages/guides/window-focus-refetching.md
+++ b/docs/src/pages/guides/window-focus-refetching.md
@@ -87,3 +87,7 @@ focusManager.setFocused(true)
 // Fallback to the default focus check
 focusManager.setFocused(undefined)
 ```
+
+## Pitfalls & Caveats
+
+Some browser internal dialogue windows, such as spawned by `alert()` or file upload dialogues (as created by `<input type="file" />`) might also trigger focus refetching after they close. This can result in unwanted side effects, as the refetching might trigger component unmounts or remounts before your file upload handler is executed. See [this issue on GitHub](https://github.com/tannerlinsley/react-query/issues/2960) for background and possible workarounds.
\ No newline at end of file
diff --git a/src/core/queriesObserver.ts b/src/core/queriesObserver.ts
index 99ddf7e77..1a32d51e6 100644
--- a/src/core/queriesObserver.ts
+++ b/src/core/queriesObserver.ts
@@ -64,57 +64,94 @@ export class QueriesObserver extends Subscribable<QueriesObserverListener> {
   }
 
   getOptimisticResult(queries: QueryObserverOptions[]): QueryObserverResult[] {
-    return queries.map((options, index) => {
-      const defaultedOptions = this.client.defaultQueryObserverOptions(options)
-      return this.getObserver(defaultedOptions, index).getOptimisticResult(
-        defaultedOptions
-      )
-    })
+    return this.findMatchingObservers(queries).map(match =>
+      match.observer.getCurrentResult()
+    )
   }
 
-  private getObserver(
-    options: QueryObserverOptions,
-    index: number
-  ): QueryObserver {
+  private findMatchingObservers(
+    queries: QueryObserverOptions[]
+  ): QueryObserverMatch[] {
+    const prevObservers = this.observers
+    const defaultedQueryOptions = queries.map(options =>
+      this.client.defaultQueryObserverOptions(options)
+    )
+
+    const matchingObservers: QueryObserverMatch[] = defaultedQueryOptions.flatMap(
+      defaultedOptions => {
+        const match = prevObservers.find(
+          observer => observer.options.queryHash === defaultedOptions.queryHash
+        )
+        if (match != null) {
+          return [{ defaultedQueryOptions: defaultedOptions, observer: match }]
+        }
+        return []
+      }
+    )
+
+    const matchedQueryHashes = matchingObservers.map(
+      match => match.defaultedQueryOptions.queryHash
+    )
+    const unmatchedQueries = defaultedQueryOptions.filter(
+      defaultedOptions =>
+        !matchedQueryHashes.includes(defaultedOptions.queryHash)
+    )
+
+    const unmatchedObservers = prevObservers.filter(
+      prevObserver =>
+        !matchingObservers.some(match => match.observer === prevObserver)
+    )
+
+    const newOrReusedObservers: QueryObserverMatch[] = unmatchedQueries.map(
+      (options, index) => {
+        if (options.keepPreviousData) {
+          // return previous data from one of the observers that no longer match
+          const previouslyUsedObserver = unmatchedObservers[index]
+          if (previouslyUsedObserver !== undefined) {
+            return {
+              defaultedQueryOptions: options,
+              observer: previouslyUsedObserver,
+            }
+          }
+        }
+        return {
+          defaultedQueryOptions: options,
+          observer: this.getObserver(options),
+        }
+      }
+    )
+
+    return matchingObservers.concat(newOrReusedObservers)
+  }
+
+  private getObserver(options: QueryObserverOptions): QueryObserver {
     const defaultedOptions = this.client.defaultQueryObserverOptions(options)
-    let currentObserver = this.observersMap[defaultedOptions.queryHash!]
-    if (!currentObserver && defaultedOptions.keepPreviousData) {
-      currentObserver = this.observers[index]
-    }
+    const currentObserver = this.observersMap[defaultedOptions.queryHash!]
     return currentObserver ?? new QueryObserver(this.client, defaultedOptions)
   }
 
   private updateObservers(notifyOptions?: NotifyOptions): void {
     notifyManager.batch(() => {
-      let hasIndexChange = false
-
       const prevObservers = this.observers
-      const prevObserversMap = this.observersMap
 
-      const newResult: QueryObserverResult[] = []
-      const newObservers: QueryObserver[] = []
-      const newObserversMap: Record<string, QueryObserver> = {}
+      const newObserverMatches = this.findMatchingObservers(this.queries)
 
-      this.queries.forEach((options, i) => {
-        const defaultedOptions = this.client.defaultQueryObserverOptions(
-          options
-        )
-        const queryHash = defaultedOptions.queryHash!
-        const observer = this.getObserver(defaultedOptions, i)
-
-        if (prevObserversMap[queryHash] || defaultedOptions.keepPreviousData) {
-          observer.setOptions(defaultedOptions, notifyOptions)
-        }
-
-        if (observer !== prevObservers[i]) {
-          hasIndexChange = true
-        }
+      // set options for the new observers to notify of changes
+      newObserverMatches.forEach(match =>
+        match.observer.setOptions(match.defaultedQueryOptions, notifyOptions)
+      )
 
-        newObservers.push(observer)
-        newResult.push(observer.getCurrentResult())
-        newObserversMap[queryHash] = observer
-      })
+      const newObservers = newObserverMatches.map(match => match.observer)
+      const newObserversMap = Object.fromEntries(
+        newObservers.map(observer => [observer.options.queryHash, observer])
+      )
+      const newResult = newObservers.map(observer =>
+        observer.getCurrentResult()
+      )
 
+      const hasIndexChange = newObservers.some(
+        (observer, index) => observer !== prevObservers[index]
+      )
       if (prevObservers.length === newObservers.length && !hasIndexChange) {
         return
       }
@@ -157,3 +194,8 @@ export class QueriesObserver extends Subscribable<QueriesObserverListener> {
     })
   }
 }
+
+type QueryObserverMatch = {
+  defaultedQueryOptions: QueryObserverOptions
+  observer: QueryObserver
+}
diff --git a/src/core/query.ts b/src/core/query.ts
index 336e9b874..c275876e9 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -163,9 +163,11 @@ export class Query<
   private observers: QueryObserver<any, any, any, any, any>[]
   private defaultOptions?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
   private abortSignalConsumed: boolean
+  private hadObservers: boolean
 
   constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
     this.abortSignalConsumed = false
+    this.hadObservers = false
     this.defaultOptions = config.defaultOptions
     this.setOptions(config.options)
     this.observers = []
@@ -214,8 +216,14 @@ export class Query<
   }
 
   private optionalRemove() {
-    if (!this.observers.length && !this.state.isFetching) {
-      this.cache.remove(this)
+    if (!this.observers.length) {
+      if (this.state.isFetching) {
+        if (this.hadObservers) {
+          this.scheduleGc()
+        }
+      } else {
+        this.cache.remove(this)
+      }
     }
   }
 
@@ -318,6 +326,7 @@ export class Query<
   addObserver(observer: QueryObserver<any, any, any, any, any>): void {
     if (this.observers.indexOf(observer) === -1) {
       this.observers.push(observer)
+      this.hadObservers = true
 
       // Stop the query from being garbage collected
       this.clearGcTimeout()
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index f1ab5c8d2..aa36cfd2c 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -232,24 +232,38 @@ export class QueryObserver<
   }
 
   trackResult(
-    result: QueryObserverResult<TData, TError>
+    result: QueryObserverResult<TData, TError>,
+    defaultedOptions: QueryObserverOptions<
+      TQueryFnData,
+      TError,
+      TData,
+      TQueryData,
+      TQueryKey
+    >
   ): QueryObserverResult<TData, TError> {
     const trackedResult = {} as QueryObserverResult<TData, TError>
 
+    const trackProp = (key: keyof QueryObserverResult) => {
+      if (!this.trackedProps.includes(key)) {
+        this.trackedProps.push(key)
+      }
+    }
+
     Object.keys(result).forEach(key => {
       Object.defineProperty(trackedResult, key, {
         configurable: false,
         enumerable: true,
         get: () => {
-          const typedKey = key as keyof QueryObserverResult
-          if (!this.trackedProps.includes(typedKey)) {
-            this.trackedProps.push(typedKey)
-          }
-          return result[typedKey]
+          trackProp(key as keyof QueryObserverResult)
+          return result[key as keyof QueryObserverResult]
         },
       })
     })
 
+    if (defaultedOptions.useErrorBoundary || defaultedOptions.suspense) {
+      trackProp('error')
+    }
+
     return trackedResult
   }
 
diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts
index 693944fc3..3adb64e05 100644
--- a/src/react/useBaseQuery.ts
+++ b/src/react/useBaseQuery.ts
@@ -144,7 +144,7 @@ export function useBaseQuery<
 
   // Handle result property usage tracking
   if (defaultedOptions.notifyOnChangeProps === 'tracked') {
-    result = observer.trackResult(result)
+    result = observer.trackResult(result, defaultedOptions)
   }
 
   return result
diff --git a/src/react/useQueries.ts b/src/react/useQueries.ts
index 2e7ec90d3..cd2d5d0ad 100644
--- a/src/react/useQueries.ts
+++ b/src/react/useQueries.ts
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, { useMemo } from 'react'
 import { QueryFunction } from '../core/types'
 
 import { notifyManager } from '../core/notifyManager'
@@ -118,14 +118,20 @@ export function useQueries<T extends any[]>(
 
   const queryClient = useQueryClient()
 
-  const defaultedQueries = queries.map(options => {
-    const defaultedOptions = queryClient.defaultQueryObserverOptions(options)
+  const defaultedQueries = useMemo(
+    () =>
+      queries.map(options => {
+        const defaultedOptions = queryClient.defaultQueryObserverOptions(
+          options
+        )
 
-    // Make sure the results are already in fetching state before subscribing or updating options
-    defaultedOptions.optimisticResults = true
+        // Make sure the results are already in fetching state before subscribing or updating options
+        defaultedOptions.optimisticResults = true
 
-    return defaultedOptions
-  })
+        return defaultedOptions
+      }),
+    [queries, queryClient]
+  )
 
   const [observer] = React.useState(
     () => new QueriesObserver(queryClient, defaultedQueries)


Test patch:
diff --git a/src/core/tests/queriesObserver.test.tsx b/src/core/tests/queriesObserver.test.tsx
index 8aca22333..f2fcae984 100644
--- a/src/core/tests/queriesObserver.test.tsx
+++ b/src/core/tests/queriesObserver.test.tsx
@@ -38,6 +38,51 @@ describe('queriesObserver', () => {
     expect(observerResult).toMatchObject([{ data: 1 }, { data: 2 }])
   })
 
+  test('should still return value for undefined query key', async () => {
+    const key1 = queryKey()
+    const queryFn1 = jest.fn().mockReturnValue(1)
+    const queryFn2 = jest.fn().mockReturnValue(2)
+    const observer = new QueriesObserver(queryClient, [
+      { queryKey: key1, queryFn: queryFn1 },
+      { queryKey: undefined, queryFn: queryFn2 },
+    ])
+    let observerResult
+    const unsubscribe = observer.subscribe(result => {
+      observerResult = result
+    })
+    await sleep(1)
+    unsubscribe()
+    expect(observerResult).toMatchObject([{ data: 1 }, { data: 2 }])
+  })
+
+  test('should return same value for multiple falsy query keys', async () => {
+    const queryFn1 = jest.fn().mockReturnValue(1)
+    const queryFn2 = jest.fn().mockReturnValue(2)
+    const observer = new QueriesObserver(queryClient, [
+      { queryKey: undefined, queryFn: queryFn1 },
+    ])
+    const results: QueryObserverResult[][] = []
+    results.push(observer.getCurrentResult())
+    const unsubscribe = observer.subscribe(result => {
+      results.push(result)
+    })
+    await sleep(1)
+    observer.setQueries([
+      { queryKey: undefined, queryFn: queryFn1 },
+      { queryKey: '', queryFn: queryFn2 },
+    ])
+    await sleep(1)
+    unsubscribe()
+    expect(results.length).toBe(4)
+    expect(results[0]).toMatchObject([{ status: 'idle', data: undefined }])
+    expect(results[1]).toMatchObject([{ status: 'loading', data: undefined }])
+    expect(results[2]).toMatchObject([{ status: 'success', data: 1 }])
+    expect(results[3]).toMatchObject([
+      { status: 'success', data: 1 },
+      { status: 'success', data: 1 },
+    ])
+  })
+
   test('should update when a query updates', async () => {
     const key1 = queryKey()
     const key2 = queryKey()
diff --git a/src/core/tests/query.test.tsx b/src/core/tests/query.test.tsx
index 583799cfb..efdc4894e 100644
--- a/src/core/tests/query.test.tsx
+++ b/src/core/tests/query.test.tsx
@@ -544,6 +544,29 @@ describe('query', () => {
     expect(queryCache.find(key)).toBeUndefined()
   })
 
+  test('should be garbage collected later when unsubscribed and query is fetching', async () => {
+    const key = queryKey()
+    const observer = new QueryObserver(queryClient, {
+      queryKey: key,
+      queryFn: async () => {
+        await sleep(20)
+        return 'data'
+      },
+      cacheTime: 10,
+    })
+    const unsubscribe = observer.subscribe()
+    await sleep(20)
+    expect(queryCache.find(key)).toBeDefined()
+    observer.refetch()
+    unsubscribe()
+    await sleep(10)
+    // unsubscribe should not remove even though cacheTime has elapsed b/c query is still fetching
+    expect(queryCache.find(key)).toBeDefined()
+    await sleep(10)
+    // should be removed after an additional staleTime wait
+    expect(queryCache.find(key)).toBeUndefined()
+  })
+
   test('should not be garbage collected unless there are no subscribers', async () => {
     const key = queryKey()
     const observer = new QueryObserver(queryClient, {
diff --git a/src/core/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx
index 5e0ee089a..30f8e2996 100644
--- a/src/core/tests/queryClient.test.tsx
+++ b/src/core/tests/queryClient.test.tsx
@@ -544,6 +544,21 @@ describe('queryClient', () => {
 
       consoleMock.mockRestore()
     })
+
+    test('should be garbage collected after cacheTime if unused', async () => {
+      const key = queryKey()
+
+      await queryClient.prefetchQuery(
+        key,
+        async () => {
+          return 'data'
+        },
+        { cacheTime: 10 }
+      )
+      expect(queryCache.find(key)).toBeDefined()
+      await sleep(15)
+      expect(queryCache.find(key)).not.toBeDefined()
+    })
   })
 
   describe('removeQueries', () => {
diff --git a/src/react/tests/QueryResetErrorBoundary.test.tsx b/src/react/tests/QueryResetErrorBoundary.test.tsx
index f73210d25..5fc1e366f 100644
--- a/src/react/tests/QueryResetErrorBoundary.test.tsx
+++ b/src/react/tests/QueryResetErrorBoundary.test.tsx
@@ -592,4 +592,64 @@ describe('QueryErrorResetBoundary', () => {
 
     expect(rendered.queryByText('page')).not.toBeNull()
   })
+
+  it('should show error boundary when using tracked queries even though we do not track the error field', async () => {
+    const key = queryKey()
+
+    let succeed = false
+    const consoleMock = mockConsoleError()
+
+    function Page() {
+      const { data } = useQuery(
+        key,
+        async () => {
+          await sleep(10)
+          if (!succeed) {
+            throw new Error('Error')
+          } else {
+            return 'data'
+          }
+        },
+        {
+          retry: false,
+          useErrorBoundary: true,
+          notifyOnChangeProps: 'tracked',
+        }
+      )
+      return <div>{data}</div>
+    }
+
+    const rendered = renderWithClient(
+      queryClient,
+      <QueryErrorResetBoundary>
+        {({ reset }) => (
+          <ErrorBoundary
+            onReset={reset}
+            fallbackRender={({ resetErrorBoundary }) => (
+              <div>
+                <div>error boundary</div>
+                <button
+                  onClick={() => {
+                    resetErrorBoundary()
+                  }}
+                >
+                  retry
+                </button>
+              </div>
+            )}
+          >
+            <Page />
+          </ErrorBoundary>
+        )}
+      </QueryErrorResetBoundary>
+    )
+
+    await waitFor(() => rendered.getByText('error boundary'))
+    await waitFor(() => rendered.getByText('retry'))
+    succeed = true
+    fireEvent.click(rendered.getByText('retry'))
+    await waitFor(() => rendered.getByText('data'))
+
+    consoleMock.mockRestore()
+  })
 })
diff --git a/src/react/tests/useQueries.test.tsx b/src/react/tests/useQueries.test.tsx
index 423184b9a..f8611c648 100644
--- a/src/react/tests/useQueries.test.tsx
+++ b/src/react/tests/useQueries.test.tsx
@@ -128,8 +128,8 @@ describe('useQueries', () => {
       { status: 'success', data: 5, isPreviousData: false, isFetching: false },
     ])
     expect(states[3]).toMatchObject([
-      { status: 'success', data: 2, isPreviousData: true, isFetching: true },
-      { status: 'success', data: 5, isPreviousData: true, isFetching: true },
+      { status: 'success', data: 2, isPreviousData: false, isFetching: false },
+      { status: 'success', data: 5, isPreviousData: false, isFetching: false },
     ])
     expect(states[4]).toMatchObject([
       { status: 'success', data: 2, isPreviousData: true, isFetching: true },
@@ -206,8 +206,8 @@ describe('useQueries', () => {
     ])
 
     expect(states[3]).toMatchObject([
-      { status: 'success', data: 4, isPreviousData: true, isFetching: true },
-      { status: 'success', data: 8, isPreviousData: true, isFetching: true },
+      { status: 'success', data: 4, isPreviousData: false, isFetching: false },
+      { status: 'success', data: 8, isPreviousData: false, isFetching: false },
       {
         status: 'loading',
         data: undefined,
@@ -252,6 +252,197 @@ describe('useQueries', () => {
     ])
   })
 
+  it('should keep previous data when switching between queries', async () => {
+    const key = queryKey()
+    const states: UseQueryResult[][] = []
+
+    function Page() {
+      const [series1, setSeries1] = React.useState(1)
+      const [series2, setSeries2] = React.useState(2)
+      const ids = [series1, series2]
+
+      const result = useQueries(
+        ids.map(id => {
+          return {
+            queryKey: [key, id],
+            queryFn: async () => {
+              await sleep(5)
+              return id * 5
+            },
+            keepPreviousData: true,
+          }
+        })
+      )
+
+      states.push(result)
+
+      React.useEffect(() => {
+        setActTimeout(() => {
+          setSeries2(3)
+        }, 20)
+      }, [])
+
+      React.useEffect(() => {
+        setActTimeout(() => {
+          setSeries1(2)
+        }, 50)
+      }, [])
+
+      return null
+    }
+
+    renderWithClient(queryClient, <Page />)
+
+    await waitFor(() => expect(states.length).toBe(10))
+
+    expect(states[0]).toMatchObject([
+      {
+        status: 'loading',
+        data: undefined,
+        isPreviousData: false,
+        isFetching: true,
+      },
+      {
+        status: 'loading',
+        data: undefined,
+        isPreviousData: false,
+        isFetching: true,
+      },
+    ])
+    expect(states[1]).toMatchObject([
+      { status: 'success', data: 5, isPreviousData: false, isFetching: false },
+      {
+        status: 'loading',
+        data: undefined,
+        isPreviousData: false,
+        isFetching: true,
+      },
+    ])
+    expect(states[2]).toMatchObject([
+      { status: 'success', data: 5, isPreviousData: false, isFetching: false },
+      { status: 'success', data: 10, isPreviousData: false, isFetching: false },
+    ])
+    expect(states[3]).toMatchObject([
+      { status: 'success', data: 5, isPreviousData: false, isFetching: false },
+      { status: 'success', data: 10, isPreviousData: false, isFetching: false },
+    ])
+    expect(states[4]).toMatchObject([
+      { status: 'success', data: 5, isPreviousData: false, isFetching: false },
+      { status: 'success', data: 10, isPreviousData: true, isFetching: true },
+    ])
+    expect(states[5]).toMatchObject([
+      { status: 'success', data: 5, isPreviousData: false, isFetching: false },
+      { status: 'success', data: 15, isPreviousData: false, isFetching: false },
+    ])
+    expect(states[6]).toMatchObject([
+      { status: 'success', data: 15, isPreviousData: false, isFetching: false },
+      { status: 'success', data: 5, isPreviousData: false, isFetching: false },
+    ])
+    expect(states[7]).toMatchObject([
+      { status: 'success', data: 10, isPreviousData: false, isFetching: true },
+      { status: 'success', data: 15, isPreviousData: false, isFetching: false },
+    ])
+    expect(states[8]).toMatchObject([
+      { status: 'success', data: 10, isPreviousData: false, isFetching: true },
+      { status: 'success', data: 15, isPreviousData: false, isFetching: false },
+    ])
+    expect(states[9]).toMatchObject([
+      { status: 'success', data: 10, isPreviousData: false, isFetching: false },
+      { status: 'success', data: 15, isPreviousData: false, isFetching: false },
+    ])
+  })
+
+  it('should not go to infinite render loop with previous data when toggling queries', async () => {
+    const key = queryKey()
+    const states: UseQueryResult[][] = []
+
+    function Page() {
+      const [enableId1, setEnableId1] = React.useState(true)
+      const ids = enableId1 ? [1, 2] : [2]
+
+      const result = useQueries(
+        ids.map(id => {
+          return {
+            queryKey: [key, id],
+            queryFn: async () => {
+              await sleep(5)
+              return id * 5
+            },
+            keepPreviousData: true,
+          }
+        })
+      )
+
+      states.push(result)
+
+      React.useEffect(() => {
+        setActTimeout(() => {
+          setEnableId1(false)
+        }, 20)
+
+        setActTimeout(() => {
+          setEnableId1(true)
+        }, 30)
+      }, [])
+
+      return null
+    }
+
+    renderWithClient(queryClient, <Page />)
+
+    await waitFor(() => expect(states.length).toBe(9))
+
+    expect(states[0]).toMatchObject([
+      {
+        status: 'loading',
+        data: undefined,
+        isPreviousData: false,
+        isFetching: true,
+      },
+      {
+        status: 'loading',
+        data: undefined,
+        isPreviousData: false,
+        isFetching: true,
+      },
+    ])
+    expect(states[1]).toMatchObject([
+      { status: 'success', data: 5, isPreviousData: false, isFetching: false },
+      {
+        status: 'loading',
+        data: undefined,
+        isPreviousData: false,
+        isFetching: true,
+      },
+    ])
+    expect(states[2]).toMatchObject([
+      { status: 'success', data: 5, isPreviousData: false, isFetching: false },
+      { status: 'success', data: 10, isPreviousData: false, isFetching: false },
+    ])
+    expect(states[3]).toMatchObject([
+      { status: 'success', data: 10, isPreviousData: false, isFetching: false },
+    ])
+    expect(states[4]).toMatchObject([
+      { status: 'success', data: 10, isPreviousData: false, isFetching: false },
+    ])
+    expect(states[5]).toMatchObject([
+      { status: 'success', data: 10, isPreviousData: false, isFetching: false },
+      { status: 'success', data: 5, isPreviousData: false, isFetching: true },
+    ])
+    expect(states[6]).toMatchObject([
+      { status: 'success', data: 5, isPreviousData: false, isFetching: true },
+      { status: 'success', data: 10, isPreviousData: false, isFetching: false },
+    ])
+    expect(states[7]).toMatchObject([
+      { status: 'success', data: 5, isPreviousData: false, isFetching: true },
+      { status: 'success', data: 10, isPreviousData: false, isFetching: false },
+    ])
+    expect(states[8]).toMatchObject([
+      { status: 'success', data: 5, isPreviousData: false, isFetching: false },
+      { status: 'success', data: 10, isPreviousData: false, isFetching: false },
+    ])
+  })
+
   it('handles type parameter - tuple of tuples', async () => {
     const key1 = queryKey()
     const key2 = queryKey()