Repository: tannerlinsley/react-query
Pull request #909 (2020-08-21 19:53:27)

feat: make sure paused queries resolve
This PR addresses three issues:
- Make sure existing promises also resolve when fetching is paused and resumed because of a window unfocus.
- Make sure existing promises also reject when fetching is cancelled. They will be rejected with a `CancelledError`.
- Make sure queries are still cached when the transport layer does not support cancellation.
- Set `CancelledError` as `error` to allow users to check if the query was cancelled (the `isCancelledError` helper function can be used to check if an error is a cancelled error).


Patch:
diff --git a/src/core/index.ts b/src/core/index.ts
index a87a2ebc2..fdb7dc2b3 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -1,6 +1,13 @@
 export { queryCache, queryCaches, makeQueryCache } from './queryCache'
 export { setFocusHandler } from './setFocusHandler'
-export { stableStringify, setConsole, deepIncludes } from './utils'
+export {
+  CancelledError,
+  deepIncludes,
+  isCancelledError,
+  isError,
+  setConsole,
+  stableStringify,
+} from './utils'
 
 // Types
 export * from './types'
diff --git a/src/core/query.ts b/src/core/query.ts
index 5f7db1452..33a62d5e8 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -1,13 +1,15 @@
 import {
-  isServer,
-  functionalUpdate,
-  cancelledError,
-  isDocumentVisible,
-  noop,
+  CancelledError,
   Console,
-  getStatusProps,
   Updater,
+  functionalUpdate,
+  getStatusProps,
+  isCancelable,
+  isCancelledError,
+  isDocumentVisible,
+  isServer,
   replaceEqualDeep,
+  sleep,
 } from './utils'
 import {
   ArrayQueryKey,
@@ -59,7 +61,7 @@ export interface FetchMoreOptions {
 }
 
 export interface RefetchOptions {
-  throwOnError?: boolean;
+  throwOnError?: boolean
 }
 
 export enum ActionType {
@@ -91,7 +93,6 @@ interface SuccessAction<TResult> {
 
 interface ErrorAction<TError> {
   type: ActionType.Error
-  cancelled: boolean
   error: TError
 }
 
@@ -111,22 +112,21 @@ export type Action<TResult, TError> =
 // CLASS
 
 export class Query<TResult, TError> {
-  queryCache: QueryCache
   queryKey: ArrayQueryKey
   queryHash: string
   config: QueryConfig<TResult, TError>
   observers: QueryObserver<TResult, TError>[]
   state: QueryState<TResult, TError>
-  shouldContinueRetryOnFocus?: boolean
-  promise?: Promise<TResult | undefined>
 
+  private queryCache: QueryCache
+  private promise?: Promise<TResult | undefined>
   private fetchMoreVariable?: unknown
   private pageVariables?: ArrayQueryKey[]
   private cacheTimeout?: number
-  private retryTimeout?: number
   private staleTimeout?: number
-  private cancelPromises?: () => void
-  private cancelled?: typeof cancelledError | null
+  private cancelFetch?: () => void
+  private continueFetch?: () => void
+  private isTransportCancelable?: boolean
   private notifyGlobalListeners: (query: Query<TResult, TError>) => void
 
   constructor(init: QueryInitConfig<TResult, TError>) {
@@ -161,12 +161,9 @@ export class Query<TResult, TError> {
     if (!isServer && this.state.data) {
       this.scheduleStaleTimeout()
 
-      // Simulate a query healing process
-      this.heal()
-
       // Schedule for garbage collection in case
       // nothing subscribes to this query
-      this.scheduleGarbageCollection()
+      this.scheduleCacheTimeout()
     }
   }
 
@@ -180,7 +177,7 @@ export class Query<TResult, TError> {
     this.notifyGlobalListeners(this)
   }
 
-  scheduleStaleTimeout(): void {
+  private scheduleStaleTimeout(): void {
     if (isServer) {
       return
     }
@@ -206,7 +203,7 @@ export class Query<TResult, TError> {
     this.dispatch({ type: ActionType.MarkStale })
   }
 
-  scheduleGarbageCollection(): void {
+  private scheduleCacheTimeout(): void {
     if (isServer) {
       return
     }
@@ -217,15 +214,9 @@ export class Query<TResult, TError> {
       return
     }
 
-    this.cacheTimeout = setTimeout(
-      () => {
-        this.clear()
-      },
-      typeof this.state.data === 'undefined' &&
-        this.state.status !== QueryStatus.Error
-        ? 0
-        : this.config.cacheTime
-    )
+    this.cacheTimeout = setTimeout(() => {
+      this.clear()
+    }, this.config.cacheTime)
   }
 
   async refetch(options?: RefetchOptions): Promise<TResult | undefined> {
@@ -233,29 +224,18 @@ export class Query<TResult, TError> {
       return await this.fetch()
     } catch (error) {
       if (options?.throwOnError === true) {
-        throw error;
+        throw error
       }
-      Console.error(error)
+      return undefined
     }
-    return;
-  }
-
-  heal(): void {
-    // Stop the query from being garbage collected
-    this.clearCacheTimeout()
-
-    // Mark the query as not cancelled
-    this.cancelled = null
   }
 
   cancel(): void {
-    this.cancelled = cancelledError
-
-    if (this.cancelPromises) {
-      this.cancelPromises()
-    }
+    this.cancelFetch?.()
+  }
 
-    delete this.promise
+  continue(): void {
+    this.continueFetch?.()
   }
 
   private clearTimersObservers(): void {
@@ -278,13 +258,6 @@ export class Query<TResult, TError> {
     }
   }
 
-  private clearRetryTimeout() {
-    if (this.retryTimeout) {
-      clearTimeout(this.retryTimeout)
-      this.retryTimeout = undefined
-    }
-  }
-
   private setState(
     updater: Updater<QueryState<TResult, TError>, QueryState<TResult, TError>>
   ): void {
@@ -325,7 +298,6 @@ export class Query<TResult, TError> {
   clear(): void {
     this.clearStaleTimeout()
     this.clearCacheTimeout()
-    this.clearRetryTimeout()
     this.clearTimersObservers()
     this.cancel()
     delete this.queryCache.queries[this.queryHash]
@@ -360,7 +332,9 @@ export class Query<TResult, TError> {
 
   subscribeObserver(observer: QueryObserver<TResult, TError>): void {
     this.observers.push(observer)
-    this.heal()
+
+    // Stop the query from being garbage collected
+    this.clearCacheTimeout()
   }
 
   unsubscribeObserver(
@@ -370,101 +344,141 @@ export class Query<TResult, TError> {
     this.observers = this.observers.filter(x => x !== observer)
 
     if (!this.observers.length) {
-      this.cancel()
+      // If the transport layer does not support cancellation
+      // we'll let the query continue so the result can be cached
+      if (this.isTransportCancelable) {
+        this.cancel()
+      }
 
       if (!preventGC) {
         // Schedule garbage collection
-        this.scheduleGarbageCollection()
+        this.scheduleCacheTimeout()
       }
     }
   }
 
   // Set up the core fetcher function
-  private async tryFetchData(
+  private async fetchData(
     fn: QueryFunction<TResult>,
     args: ArrayQueryKey
   ): Promise<TResult> {
-    try {
-      // Perform the query
-      const filter = this.config.queryFnParamsFilter
-      const params = filter ? filter(args) : args
+    return new Promise<TResult>((outerResolve, outerReject) => {
+      let resolved = false
+      let continueLoop: () => void
+      let cancelTransport: () => void
 
-      // Perform the query
-      const promiseOrValue = fn(...params)
+      const done = () => {
+        resolved = true
 
-      this.cancelPromises = () => (promiseOrValue as any)?.cancel?.()
+        delete this.cancelFetch
+        delete this.continueFetch
+        delete this.isTransportCancelable
 
-      const data = await promiseOrValue
-      delete this.shouldContinueRetryOnFocus
+        // End loop if currently paused
+        continueLoop?.()
+      }
 
-      delete this.cancelPromises
-      if (this.cancelled) throw this.cancelled
+      const resolve = (value: any) => {
+        done()
+        outerResolve(value)
+      }
 
-      return data
-    } catch (error) {
-      delete this.cancelPromises
-      if (this.cancelled) throw this.cancelled
-
-      // Do we need to retry the request?
-      if (
-        this.config.retry === true ||
-        this.state.failureCount < this.config.retry! ||
-        (typeof this.config.retry === 'function' &&
-          this.config.retry(this.state.failureCount, error))
-      ) {
-        // If we retry, increase the failureCount
-        this.dispatch({ type: ActionType.Failed })
-
-        // Only retry if the document is visible
-        if (!isDocumentVisible()) {
-          // set this flag to continue retries on focus
-          this.shouldContinueRetryOnFocus = true
-          // Resolve a
-          return new Promise(noop)
-        }
+      const reject = (value: any) => {
+        done()
+        outerReject(value)
+      }
 
-        delete this.shouldContinueRetryOnFocus
+      // Create callback to cancel this fetch
+      this.cancelFetch = () => {
+        reject(new CancelledError())
+        try {
+          cancelTransport?.()
+        } catch {}
+      }
 
-        // Determine the retryDelay
-        const delay = functionalUpdate(
-          this.config.retryDelay,
-          this.state.failureCount
-        )
+      // Create callback to continue this fetch
+      this.continueFetch = () => {
+        continueLoop?.()
+      }
 
-        // Return a new promise with the retry
-        return await new Promise((resolve, reject) => {
-          // Keep track of the retry timeout
-          this.retryTimeout = setTimeout(async () => {
-            if (this.cancelled) return reject(this.cancelled)
-
-            try {
-              const data = await this.tryFetchData(fn, args)
-              if (this.cancelled) return reject(this.cancelled)
-              resolve(data)
-            } catch (error) {
-              if (this.cancelled) return reject(this.cancelled)
-              reject(error)
+      // Filter the query function arguments if needed
+      const filter = this.config.queryFnParamsFilter
+      args = filter ? filter(args) : args
+
+      // Create loop function
+      const run = async () => {
+        try {
+          // Execute query
+          const promiseOrValue = fn(...args)
+
+          // Check if the transport layer support cancellation
+          if (isCancelable(promiseOrValue)) {
+            cancelTransport = () => {
+              promiseOrValue.cancel()
             }
-          }, delay)
-        })
+            this.isTransportCancelable = true
+          }
+
+          // Await data
+          resolve(await promiseOrValue)
+        } catch (error) {
+          // Stop if the fetch is already resolved
+          if (resolved) {
+            return
+          }
+
+          // Do we need to retry the request?
+          const { failureCount } = this.state
+          const { retry, retryDelay } = this.config
+
+          const shouldRetry =
+            retry === true ||
+            failureCount < retry! ||
+            (typeof retry === 'function' && retry(failureCount, error))
+
+          if (!shouldRetry) {
+            // We are done if the query does not need to be retried
+            reject(error)
+            return
+          }
+
+          // Increase the failureCount
+          this.dispatch({ type: ActionType.Failed })
+
+          // Delay
+          await sleep(functionalUpdate(retryDelay, failureCount) || 0)
+
+          // Pause retry if the document is not visible
+          if (!isDocumentVisible()) {
+            await new Promise(continueResolve => {
+              continueLoop = continueResolve
+            })
+          }
+
+          // Try again if not resolved yet
+          if (!resolved) {
+            run()
+          }
+        }
       }
 
-      throw error
-    }
+      // Start loop
+      run()
+    })
   }
 
   async fetch(options?: FetchOptions): Promise<TResult | undefined> {
+    // If we are already fetching, return current promise
+    if (this.promise) {
+      return this.promise
+    }
+
     let queryFn = this.config.queryFn
 
     if (!queryFn) {
       return
     }
 
-    // If we are already fetching, return current promise
-    if (this.promise) {
-      return this.promise
-    }
-
     if (this.config.infinite) {
       const infiniteConfig = this.config as InfiniteQueryConfig<TResult, TError>
       const infiniteData = (this.state.data as unknown) as TResult[] | undefined
@@ -565,9 +579,6 @@ export class Query<TResult, TError> {
     }
 
     this.promise = (async () => {
-      // If there are any retries pending for this query, kill them
-      this.cancelled = null
-
       try {
         // Set to fetching state if not already in it
         if (!this.state.isFetching) {
@@ -575,27 +586,33 @@ export class Query<TResult, TError> {
         }
 
         // Try to get the data
-        const data = await this.tryFetchData(queryFn!, this.queryKey)
+        const data = await this.fetchData(queryFn, this.queryKey)
 
+        // Set success state
         this.setData(data)
 
+        // Cleanup
         delete this.promise
 
+        // Return data
         return data
       } catch (error) {
+        // Set error state
         this.dispatch({
           type: ActionType.Error,
-          cancelled: error === this.cancelled,
           error,
         })
 
-        delete this.promise
-
-        if (error !== this.cancelled) {
-          throw error
+        // Log error
+        if (!isCancelledError(error)) {
+          Console.error(error)
         }
 
-        return
+        // Cleanup
+        delete this.promise
+
+        // Propagate error
+        throw error
       }
     })()
 
@@ -691,15 +708,13 @@ export function queryReducer<TResult, TError>(
     case ActionType.Error:
       return {
         ...state,
-        failureCount: state.failureCount + 1,
+        ...getStatusProps(QueryStatus.Error),
+        error: action.error,
         isFetched: true,
         isFetching: false,
         isStale: true,
-        ...(!action.cancelled && {
-          ...getStatusProps(QueryStatus.Error),
-          error: action.error,
-          throwInErrorBoundary: true,
-        }),
+        failureCount: state.failureCount + 1,
+        throwInErrorBoundary: true,
       }
     case ActionType.SetState:
       return functionalUpdate(action.updater, state)
diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts
index 1e92966f2..267b79bae 100644
--- a/src/core/queryCache.ts
+++ b/src/core/queryCache.ts
@@ -1,11 +1,10 @@
 import {
-  isServer,
-  getQueryArgs,
-  deepIncludes,
-  Console,
-  isObject,
   Updater,
+  deepIncludes,
   functionalUpdate,
+  getQueryArgs,
+  isObject,
+  isServer,
 } from './utils'
 import { getDefaultedQueryConfig } from './config'
 import { Query } from './query'
@@ -310,11 +309,10 @@ export class QueryCache {
         await query.fetch()
       }
       return query.state.data
-    } catch (err) {
+    } catch (error) {
       if (options?.throwOnError) {
-        throw err
+        throw error
       }
-      Console.error(err)
       return
     }
   }
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index 49da47b44..7fed7256e 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -1,4 +1,4 @@
-import { getStatusProps, isServer, isDocumentVisible, Console } from './utils'
+import { getStatusProps, isServer, isDocumentVisible } from './utils'
 import type { QueryResult, QueryObserverConfig } from './types'
 import type { Query, QueryState, Action, FetchMoreOptions, RefetchOptions } from './query'
 
@@ -101,10 +101,11 @@ export class QueryObserver<TResult, TError> {
 
   async fetch(): Promise<TResult | undefined> {
     this.currentQuery.updateConfig(this.config)
-    return this.currentQuery.fetch().catch(error => {
-      Console.error(error)
+    try {
+      return await this.currentQuery.fetch()
+    } catch (error) {
       return undefined
-    })
+    }
   }
 
   private optionalFetch(): void {
diff --git a/src/core/setFocusHandler.ts b/src/core/setFocusHandler.ts
index 517e39924..05fecb1c3 100644
--- a/src/core/setFocusHandler.ts
+++ b/src/core/setFocusHandler.ts
@@ -8,22 +8,17 @@ const focusEvent = 'focus'
 
 const onWindowFocus: FocusHandler = () => {
   if (isDocumentVisible() && isOnline()) {
-    queryCaches.forEach(queryCache =>
+    queryCaches.forEach(queryCache => {
+      // Continue any paused queries
+      queryCache.getQueries(query => {
+        query.continue()
+      })
+
+      // Invalidate queries which should refetch on window focus
       queryCache
-        .invalidateQueries(query => {
-          if (!query.shouldRefetchOnWindowFocus()) {
-            return false
-          }
-
-          if (query.shouldContinueRetryOnFocus) {
-            // delete promise, so refetching will create new one
-            delete query.promise
-          }
-
-          return true
-        })
+        .invalidateQueries(query => query.shouldRefetchOnWindowFocus())
         .catch(Console.error)
-    )
+    })
   }
 }
 
diff --git a/src/core/types.ts b/src/core/types.ts
index 98c907e73..f10766322 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -76,7 +76,7 @@ export interface QueryObserverConfig<TResult, TError = unknown>
    * If set to a number, the query will continuously refetch at this frequency in milliseconds.
    * Defaults to `false`.
    */
-  refetchInterval?: number
+  refetchInterval?: number | false
   /**
    * If set to `true`, the query will continue to refetch while their tab/window is in the background.
    * Defaults to `false`.
diff --git a/src/core/utils.ts b/src/core/utils.ts
index d857606f9..fdc9c9714 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -16,16 +16,23 @@ export interface ConsoleObject {
   error: ConsoleFunction
 }
 
+interface Cancelable {
+  cancel(): void
+}
+
+export class CancelledError {}
+
 // UTILS
 
 let _uid = 0
 export const uid = () => _uid++
-export const cancelledError = {}
-export const globalStateListeners = []
+
 export const isServer = typeof window === 'undefined'
-export function noop(): void {
+
+function noop(): void {
   return void 0
 }
+
 export let Console: ConsoleObject = console || {
   error: noop,
   warn: noop,
@@ -201,6 +208,24 @@ function hasObjectPrototype(o: any): boolean {
   return Object.prototype.toString.call(o) === '[object Object]'
 }
 
+export function isCancelable(value: any): value is Cancelable {
+  return typeof value?.cancel === 'function'
+}
+
+export function isError(value: any): value is Error {
+  return value instanceof Error
+}
+
+export function isCancelledError(value: any): value is CancelledError {
+  return value instanceof CancelledError
+}
+
+export function sleep(timeout: number): Promise<void> {
+  return new Promise(resolve => {
+    setTimeout(resolve, timeout)
+  })
+}
+
 export function getStatusProps<T extends QueryStatus>(status: T) {
   return {
     status,


Test patch:
diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx
index b7ba712dd..e5235c7c1 100644
--- a/src/core/tests/queryCache.test.tsx
+++ b/src/core/tests/queryCache.test.tsx
@@ -1,5 +1,11 @@
-import { sleep, queryKey } from '../../react/tests/utils'
+import {
+  sleep,
+  queryKey,
+  mockVisibilityState,
+  mockConsoleError,
+} from '../../react/tests/utils'
 import { makeQueryCache, queryCache as defaultQueryCache } from '..'
+import { isCancelledError, isError } from '../utils'
 
 describe('queryCache', () => {
   test('setQueryData does not crash if query could not be found', () => {
@@ -26,6 +32,8 @@ describe('queryCache', () => {
 
   // https://github.com/tannerlinsley/react-query/issues/652
   test('prefetchQuery should not retry by default', async () => {
+    const consoleMock = mockConsoleError()
+
     const key = queryKey()
 
     await expect(
@@ -38,6 +46,8 @@ describe('queryCache', () => {
         { throwOnError: true }
       )
     ).rejects.toEqual(new Error('error'))
+
+    consoleMock.mockRestore()
   })
 
   test('prefetchQuery returns the cached data on cache hits', async () => {
@@ -69,6 +79,8 @@ describe('queryCache', () => {
   })
 
   test('prefetchQuery should throw error when throwOnError is true', async () => {
+    const consoleMock = mockConsoleError()
+
     const key = queryKey()
 
     await expect(
@@ -83,13 +95,14 @@ describe('queryCache', () => {
         { throwOnError: true }
       )
     ).rejects.toEqual(new Error('error'))
+
+    consoleMock.mockRestore()
   })
 
   test('prefetchQuery should return undefined when an error is thrown', async () => {
-    const key = queryKey()
+    const consoleMock = mockConsoleError()
 
-    const consoleMock = jest.spyOn(console, 'error')
-    consoleMock.mockImplementation(() => undefined)
+    const key = queryKey()
 
     const result = await defaultQueryCache.prefetchQuery(
       key,
@@ -354,6 +367,7 @@ describe('queryCache', () => {
       const queryCache = makeQueryCache()
       const query = queryCache.buildQuery(key)
 
+      // @ts-expect-error
       expect(query.queryCache).toBe(queryCache)
     })
 
@@ -369,5 +383,251 @@ describe('queryCache', () => {
 
       unsubscribe()
     })
+
+    it('should continue retry after focus regain and resolve all promises', async () => {
+      const key = queryKey()
+
+      const originalVisibilityState = document.visibilityState
+
+      // make page unfocused
+      mockVisibilityState('hidden')
+
+      let count = 0
+      let result
+
+      const promise = defaultQueryCache.prefetchQuery(
+        key,
+        async () => {
+          count++
+
+          if (count === 3) {
+            return `data${count}`
+          }
+
+          throw new Error(`error${count}`)
+        },
+        {
+          retry: 3,
+          retryDelay: 1,
+        }
+      )
+
+      promise.then(data => {
+        result = data
+      })
+
+      // Check if we do not have a result
+      expect(result).toBeUndefined()
+
+      // Check if the query is really paused
+      await sleep(50)
+      expect(result).toBeUndefined()
+
+      // Reset visibilityState to original value
+      mockVisibilityState(originalVisibilityState)
+      window.dispatchEvent(new FocusEvent('focus'))
+
+      // There should not be a result yet
+      expect(result).toBeUndefined()
+
+      // By now we should have a value
+      await sleep(50)
+      expect(result).toBe('data3')
+    })
+
+    it('should throw a CancelledError when a paused query is cancelled', async () => {
+      const key = queryKey()
+
+      const originalVisibilityState = document.visibilityState
+
+      // make page unfocused
+      mockVisibilityState('hidden')
+
+      let count = 0
+      let result
+
+      const promise = defaultQueryCache.prefetchQuery(
+        key,
+        async () => {
+          count++
+          throw new Error(`error${count}`)
+        },
+        {
+          retry: 3,
+          retryDelay: 1,
+        },
+        {
+          throwOnError: true,
+        }
+      )
+
+      promise.catch(data => {
+        result = data
+      })
+
+      const query = defaultQueryCache.getQuery(key)!
+
+      // Check if the query is really paused
+      await sleep(50)
+      expect(result).toBeUndefined()
+
+      // Cancel query
+      query.cancel()
+
+      // Check if the error is set to the cancelled error
+      await sleep(0)
+      expect(isCancelledError(result)).toBe(true)
+
+      // Reset visibilityState to original value
+      mockVisibilityState(originalVisibilityState)
+      window.dispatchEvent(new FocusEvent('focus'))
+    })
+
+    test('query should continue if cancellation is not supported', async () => {
+      const key = queryKey()
+
+      defaultQueryCache.prefetchQuery(key, async () => {
+        await sleep(100)
+        return 'data'
+      })
+
+      await sleep(10)
+
+      const query = defaultQueryCache.getQuery(key)!
+
+      // Subscribe and unsubscribe to simulate cancellation because the last observer unsubscribed
+      const observer = query.subscribe()
+      observer.unsubscribe()
+
+      await sleep(100)
+
+      expect(query.state).toMatchObject({
+        data: 'data',
+        isLoading: false,
+        isFetched: true,
+      })
+    })
+
+    test('query should not continue if cancellation is supported', async () => {
+      const key = queryKey()
+
+      const cancel = jest.fn()
+
+      defaultQueryCache.prefetchQuery(key, async () => {
+        const promise = new Promise((resolve, reject) => {
+          sleep(100).then(() => resolve('data'))
+          cancel.mockImplementation(() => {
+            reject(new Error('Cancelled'))
+          })
+        }) as any
+        promise.cancel = cancel
+        return promise
+      })
+
+      await sleep(10)
+
+      const query = defaultQueryCache.getQuery(key)!
+
+      // Subscribe and unsubscribe to simulate cancellation because the last observer unsubscribed
+      const observer = query.subscribe()
+      observer.unsubscribe()
+
+      await sleep(100)
+
+      expect(cancel).toHaveBeenCalled()
+      expect(query.state).toMatchObject({
+        data: undefined,
+        isLoading: false,
+        isFetched: true,
+      })
+    })
+
+    test('query should not continue if explicitly cancelled', async () => {
+      const key = queryKey()
+
+      const queryFn = jest.fn()
+
+      queryFn.mockImplementation(async () => {
+        await sleep(10)
+        throw new Error()
+      })
+
+      let error
+
+      const promise = defaultQueryCache.prefetchQuery(
+        key,
+        queryFn,
+        {
+          retry: 3,
+          retryDelay: 10,
+        },
+        {
+          throwOnError: true,
+        }
+      )
+
+      promise.catch(e => {
+        error = e
+      })
+
+      const query = defaultQueryCache.getQuery(key)!
+      query.cancel()
+
+      await sleep(100)
+
+      expect(queryFn).toHaveBeenCalledTimes(1)
+      expect(isCancelledError(error)).toBe(true)
+    })
+
+    test('should be able to refetch a cancelled query', async () => {
+      const key = queryKey()
+
+      const queryFn = jest.fn()
+
+      queryFn.mockImplementation(async () => {
+        await sleep(50)
+        return 'data'
+      })
+
+      defaultQueryCache.prefetchQuery(key, queryFn)
+      const query = defaultQueryCache.getQuery(key)!
+      await sleep(10)
+      query.cancel()
+      await sleep(100)
+
+      expect(queryFn).toHaveBeenCalledTimes(1)
+      expect(isCancelledError(query.state.error)).toBe(true)
+      const result = await query.fetch()
+      expect(result).toBe('data')
+      expect(query.state.error).toBe(null)
+      expect(queryFn).toHaveBeenCalledTimes(2)
+    })
+
+    test('cancelling a resolved query should not have any effect', async () => {
+      const key = queryKey()
+      await defaultQueryCache.prefetchQuery(key, async () => 'data')
+      const query = defaultQueryCache.getQuery(key)!
+      query.cancel()
+      await sleep(10)
+      expect(query.state.data).toBe('data')
+    })
+
+    test('cancelling a rejected query should not have any effect', async () => {
+      const consoleMock = mockConsoleError()
+
+      const key = queryKey()
+
+      await defaultQueryCache.prefetchQuery(key, async () => {
+        throw new Error('error')
+      })
+      const query = defaultQueryCache.getQuery(key)!
+      query.cancel()
+      await sleep(10)
+
+      expect(isError(query.state.error)).toBe(true)
+      expect(isCancelledError(query.state.error)).toBe(false)
+
+      consoleMock.mockRestore()
+    })
   })
 })
diff --git a/src/react/tests/ReactQueryCacheProvider.test.tsx b/src/react/tests/ReactQueryCacheProvider.test.tsx
index 3c874ba0a..9b9b39583 100644
--- a/src/react/tests/ReactQueryCacheProvider.test.tsx
+++ b/src/react/tests/ReactQueryCacheProvider.test.tsx
@@ -141,7 +141,7 @@ describe('ReactQueryCacheProvider', () => {
     cache2.clear({ notify: false })
   })
 
-  test('when cache changes, previous cache is cleaned', () => {
+  test('when cache changes, previous cache is cleaned', async () => {
     const key = queryKey()
 
     const caches: QueryCache[] = []
@@ -149,6 +149,7 @@ describe('ReactQueryCacheProvider', () => {
 
     function Page() {
       const queryCache = useQueryCache()
+
       useEffect(() => {
         caches.push(queryCache)
       }, [queryCache])
@@ -175,6 +176,8 @@ describe('ReactQueryCacheProvider', () => {
 
     const rendered = render(<App />)
 
+    await waitFor(() => rendered.getByText('test'))
+
     expect(caches).toHaveLength(1)
     jest.spyOn(caches[0], 'clear')
 
@@ -182,6 +185,9 @@ describe('ReactQueryCacheProvider', () => {
 
     expect(caches).toHaveLength(2)
     expect(caches[0].clear).toHaveBeenCalled()
+
+    await waitFor(() => rendered.getByText('test'))
+
     customCache.clear({ notify: false })
   })
 
diff --git a/src/react/tests/suspense.test.tsx b/src/react/tests/suspense.test.tsx
index 6d08d0686..0dd3cd084 100644
--- a/src/react/tests/suspense.test.tsx
+++ b/src/react/tests/suspense.test.tsx
@@ -2,7 +2,7 @@ import { render, waitFor, fireEvent } from '@testing-library/react'
 import { ErrorBoundary } from 'react-error-boundary'
 import * as React from 'react'
 
-import { sleep, queryKey } from './utils'
+import { sleep, queryKey, mockConsoleError } from './utils'
 import { useQuery } from '..'
 import { queryCache } from '../../core'
 
@@ -133,8 +133,7 @@ describe("useQuery's in Suspense mode", () => {
     const key = queryKey()
 
     let succeed = false
-    const consoleMock = jest.spyOn(console, 'error')
-    consoleMock.mockImplementation(() => undefined)
+    const consoleMock = mockConsoleError()
 
     function Page() {
       useQuery(
diff --git a/src/react/tests/useIsFetching.test.tsx b/src/react/tests/useIsFetching.test.tsx
index da223a09d..96f562dd5 100644
--- a/src/react/tests/useIsFetching.test.tsx
+++ b/src/react/tests/useIsFetching.test.tsx
@@ -1,7 +1,7 @@
 import { render, fireEvent, waitFor } from '@testing-library/react'
 import * as React from 'react'
 
-import { sleep, queryKey } from './utils'
+import { sleep, queryKey, mockConsoleError } from './utils'
 import { useQuery, useIsFetching } from '..'
 
 describe('useIsFetching', () => {
@@ -42,7 +42,7 @@ describe('useIsFetching', () => {
   })
 
   it('should not update state while rendering', async () => {
-    const spy = jest.spyOn(console, 'error')
+    const consoleMock = mockConsoleError()
 
     const key1 = queryKey()
     const key2 = queryKey()
@@ -91,9 +91,9 @@ describe('useIsFetching', () => {
 
     render(<Page />)
     await waitFor(() => expect(isFetchings).toEqual([1, 1, 2, 1, 0]))
-    expect(spy).not.toHaveBeenCalled()
-    expect(spy.mock.calls[0]?.[0] ?? '').not.toMatch('setState')
+    expect(consoleMock).not.toHaveBeenCalled()
+    expect(consoleMock.mock.calls[0]?.[0] ?? '').not.toMatch('setState')
 
-    spy.mockRestore()
+    consoleMock.mockRestore()
   })
 })
diff --git a/src/react/tests/useMutation.test.tsx b/src/react/tests/useMutation.test.tsx
index e44529588..07e7ed4c6 100644
--- a/src/react/tests/useMutation.test.tsx
+++ b/src/react/tests/useMutation.test.tsx
@@ -2,6 +2,7 @@ import { render, fireEvent, waitFor } from '@testing-library/react'
 import * as React from 'react'
 
 import { useMutation } from '..'
+import { mockConsoleError } from './utils'
 
 describe('useMutation', () => {
   it('should be able to reset `data`', async () => {
@@ -37,8 +38,7 @@ describe('useMutation', () => {
   })
 
   it('should be able to reset `error`', async () => {
-    const consoleMock = jest.spyOn(console, 'error')
-    consoleMock.mockImplementation(() => undefined)
+    const consoleMock = mockConsoleError()
 
     function Page() {
       const [mutate, mutationResult] = useMutation<string, Error>(
@@ -128,8 +128,8 @@ describe('useMutation', () => {
   })
 
   it('should be able to call `onError` and `onSettled` after each failed mutate', async () => {
-    const consoleMock = jest.spyOn(console, 'error')
-    consoleMock.mockImplementation(() => undefined)
+    const consoleMock = mockConsoleError()
+
     const onErrorMock = jest.fn()
     const onSettledMock = jest.fn()
     let count = 0
@@ -189,5 +189,7 @@ describe('useMutation', () => {
     )
 
     expect(getByTestId('title').textContent).toBe('3')
+
+    consoleMock.mockRestore()
   })
 })
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index ba3f2952a..78a4e94e7 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -1,7 +1,13 @@
 import { render, act, waitFor, fireEvent } from '@testing-library/react'
 import * as React from 'react'
 
-import { sleep, expectType, queryKey } from './utils'
+import {
+  sleep,
+  expectType,
+  queryKey,
+  mockVisibilityState,
+  mockConsoleError,
+} from './utils'
 import { useQuery } from '..'
 import { queryCache, QueryResult } from '../../core'
 
@@ -159,8 +165,7 @@ describe('useQuery', () => {
 
   it('should return the correct states for an unsuccessful query', async () => {
     const key = queryKey()
-    const consoleMock = jest.spyOn(console, 'error')
-    consoleMock.mockImplementation(() => undefined)
+    const consoleMock = mockConsoleError()
 
     const states: QueryResult<undefined, string>[] = []
 
@@ -249,6 +254,8 @@ describe('useQuery', () => {
       status: 'error',
       updatedAt: expect.any(Number),
     })
+
+    consoleMock.mockRestore()
   })
 
   // https://github.com/tannerlinsley/react-query/issues/896
@@ -582,8 +589,7 @@ describe('useQuery', () => {
 
   it('should set status to error if queryFn throws', async () => {
     const key = queryKey()
-    const consoleMock = jest.spyOn(console, 'error')
-    consoleMock.mockImplementation(() => undefined)
+    const consoleMock = mockConsoleError()
 
     function Page() {
       const { status, error } = useQuery<undefined, string>(
@@ -612,8 +618,7 @@ describe('useQuery', () => {
 
   it('should retry specified number of times', async () => {
     const key = queryKey()
-    const consoleMock = jest.spyOn(console, 'error')
-    consoleMock.mockImplementation(() => undefined)
+    const consoleMock = mockConsoleError()
 
     const queryFn = jest.fn()
     queryFn.mockImplementation(() => {
@@ -649,8 +654,7 @@ describe('useQuery', () => {
   it('should not retry if retry function `false`', async () => {
     const key = queryKey()
 
-    const consoleMock = jest.spyOn(console, 'error')
-    consoleMock.mockImplementation(() => undefined)
+    const consoleMock = mockConsoleError()
 
     const queryFn = jest.fn()
 
@@ -693,69 +697,35 @@ describe('useQuery', () => {
     consoleMock.mockRestore()
   })
 
-  it('should garbage collect queries without data immediately', async () => {
-    const key = queryKey()
-
-    function Page() {
-      type Filters = { filter: string }
-      const [filter, setFilter] = React.useState('')
-      const filters: Filters = { filter }
-      const { data } = useQuery(
-        [key, filters],
-        async (_key: string, { filter }: Filters) => {
-          await sleep(10)
-          return `todo ${filter}`
-        }
-      )
-
-      return (
-        <div>
-          <div>{data}</div>
-          <button onClick={() => setFilter(filter + 'a')}>update</button>
-        </div>
-      )
-    }
-
-    const rendered = render(<Page />)
-
-    await waitFor(() => rendered.getByText('update'))
-
-    fireEvent.click(rendered.getByText('update'))
-    fireEvent.click(rendered.getByText('update'))
-    fireEvent.click(rendered.getByText('update'))
-    fireEvent.click(rendered.getByText('update'))
-
-    expect(queryCache.getQuery([key, { filter: 'a' }])).not.toBeUndefined()
-
-    await waitFor(() => rendered.getByText('todo aaaa'))
-
-    expect(queryCache.getQuery([key, { filter: 'a' }])).toBeUndefined()
-  })
-
   // See https://github.com/tannerlinsley/react-query/issues/160
   it('should continue retry after focus regain', async () => {
     const key = queryKey()
 
-    const originalVisibilityState = document.visibilityState
+    const consoleMock = mockConsoleError()
 
-    function mockVisibilityState(value: string) {
-      Object.defineProperty(document, 'visibilityState', {
-        value,
-        configurable: true,
-      })
-    }
+    const originalVisibilityState = document.visibilityState
 
     // make page unfocused
     mockVisibilityState('hidden')
 
+    let count = 0
+
     function Page() {
-      const query = useQuery(key, () => Promise.reject('fetching error'), {
-        retry: 3,
-        retryDelay: 1,
-      })
+      const query = useQuery(
+        key,
+        () => {
+          count++
+          return Promise.reject(`fetching error ${count}`)
+        },
+        {
+          retry: 3,
+          retryDelay: 1,
+        }
+      )
 
       return (
         <div>
+          <div>error {String(query.error)}</div>
           <div>status {query.status}</div>
           <div>failureCount {query.failureCount}</div>
         </div>
@@ -764,8 +734,14 @@ describe('useQuery', () => {
 
     const rendered = render(<Page />)
 
+    // The query should display the first error result
     await waitFor(() => rendered.getByText('failureCount 1'))
     await waitFor(() => rendered.getByText('status loading'))
+    await waitFor(() => rendered.getByText('error null'))
+
+    // Check if the query really paused
+    await sleep(10)
+    await waitFor(() => rendered.getByText('failureCount 1'))
 
     act(() => {
       // reset visibilityState to original value
@@ -773,8 +749,19 @@ describe('useQuery', () => {
       window.dispatchEvent(new FocusEvent('focus'))
     })
 
+    // Wait for the final result
     await waitFor(() => rendered.getByText('failureCount 4'))
     await waitFor(() => rendered.getByText('status error'))
+    await waitFor(() => rendered.getByText('error fetching error 4'))
+
+    // Check if the query really stopped
+    await sleep(10)
+    await waitFor(() => rendered.getByText('failureCount 4'))
+
+    // Check if the error has been logged in the console
+    expect(consoleMock).toHaveBeenCalledWith('fetching error 4')
+
+    consoleMock.mockRestore()
   })
 
   // See https://github.com/tannerlinsley/react-query/issues/195
@@ -838,8 +825,7 @@ describe('useQuery', () => {
   it('should reset failureCount on successful fetch', async () => {
     const key = queryKey()
 
-    const consoleMock = jest.spyOn(console, 'error')
-    consoleMock.mockImplementation(() => undefined)
+    const consoleMock = mockConsoleError()
 
     function Page() {
       let counter = 0
@@ -1160,8 +1146,7 @@ describe('useQuery', () => {
   })
 
   it('should error when using functions as query keys', () => {
-    const consoleMock = jest.spyOn(console, 'error')
-    consoleMock.mockImplementation(() => undefined)
+    const consoleMock = mockConsoleError()
 
     function Page() {
       useQuery(
diff --git a/src/react/tests/utils.tsx b/src/react/tests/utils.tsx
index ce5380f9c..481b1836f 100644
--- a/src/react/tests/utils.tsx
+++ b/src/react/tests/utils.tsx
@@ -1,5 +1,18 @@
 let queryKeyCount = 0
 
+export function mockVisibilityState(value: string) {
+  Object.defineProperty(document, 'visibilityState', {
+    value,
+    configurable: true,
+  })
+}
+
+export function mockConsoleError() {
+  const consoleMock = jest.spyOn(console, 'error')
+  consoleMock.mockImplementation(() => undefined)
+  return consoleMock
+}
+
 export function queryKey(): string {
   queryKeyCount++
   return `query_${queryKeyCount}`