Repository: tannerlinsley/react-query
Pull request #919 (2020-08-24 18:01:46)

feat: add refetch on reconnect functionality

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.



Patch:
diff --git a/docs/src/pages/docs/api.md b/docs/src/pages/docs/api.md
index 052bdcfb0..d673e48ef 100644
--- a/docs/src/pages/docs/api.md
+++ b/docs/src/pages/docs/api.md
@@ -29,6 +29,7 @@ const {
   cacheTime,
   keepPreviousData,
   refetchOnWindowFocus,
+  refetchOnReconnect,
   refetchInterval,
   refetchIntervalInBackground,
   queryFnParamsFilter,
@@ -92,6 +93,9 @@ const queryInfo = useQuery({
 - `refetchOnWindowFocus: Boolean`
   - Optional
   - Set this to `true` or `false` to enable/disable automatic refetching on window focus for this query.
+- `refetchOnReconnect: Boolean`
+  - Optional
+  - Set this to `true` or `false` to enable/disable automatic refetching on reconnect for this query.
 - `onSuccess: Function(data) => data`
   - Optional
   - This function will fire any time the query successfully fetches new data.
diff --git a/docs/src/pages/docs/comparison.md b/docs/src/pages/docs/comparison.md
index 911c3f338..147820c80 100644
--- a/docs/src/pages/docs/comparison.md
+++ b/docs/src/pages/docs/comparison.md
@@ -38,7 +38,7 @@ Feature/Capability Key:
 | Query Cancellation                           | ✅                                     | 🛑                                             | 🛑                                                                        |
 | Partial Query Matching<sup>2</sup>           | ✅                                     | 🛑                                             | 🛑                                                                        |
 | Window Focus Refetching                      | ✅                                     | ✅                                             | 🛑                                                                        |
-| Network Status Refetching                    | 🛑                                     | ✅                                             | ✅                                                                        |
+| Network Status Refetching                    | ✅                                     | ✅                                             | ✅                                                                        |
 | Automatic Refetch after Mutation<sup>3</sup> | 🔶                                     | 🔶                                             | ✅                                                                        |
 | Cache Dehydration/Rehydration                | 🛑                                     | 🛑                                             | ✅                                                                        |
 | React Suspense (Experimental)                | ✅                                     | ✅                                             | 🛑                                                                        |
diff --git a/docs/src/pages/docs/guides/important-defaults.md b/docs/src/pages/docs/guides/important-defaults.md
index 9fd5036ea..61223a841 100644
--- a/docs/src/pages/docs/guides/important-defaults.md
+++ b/docs/src/pages/docs/guides/important-defaults.md
@@ -7,6 +7,6 @@ Out of the box, React Query is configured with **aggressive but sane** defaults.
 
 - Query results that are _currently rendered on the screen_ (via `useQuery` and similar hooks) will become "stale" immediately after they are resolved and will be refetched automatically in the background when they are rendered or used again. To change this, you can alter the default `staleTime` for queries to something other than `0` milliseconds.
 - Query results that become unused (all instances of the query are unmounted) will still be cached in case they are used again for a default of 5 minutes before they are garbage collected. To change this, you can alter the default `cacheTime` for queries to something other than `1000 * 60 * 5` milliseconds.
-- Stale queries will automatically be refetched in the background **when the browser window is refocused by the user**. You can disable this using the `refetchOnWindowFocus` option in queries or the global config.
+- Stale queries will automatically be refetched in the background **when the browser window is refocused by the user or when the browser reconnects**. You can disable this using the `refetchOnWindowFocus` and `refetchOnReconnect` options in queries or the global config.
 - Queries that fail will silently be retried **3 times, with exponential backoff delay** before capturing and displaying an error to the UI. To change this, you can alter the default `retry` and `retryDelay` options for queries to something other than `3` and the default exponential backoff function.
 - Query results by default are structurally shared to detect if data has actually changed and if not, the data reference remains unchanged to better help with value stabilization with regards to useMemo and useCallback. Structural sharing only works with JSON-compatible values, any other value types will always be considered as changed. If you are seeing performance issues because of large responses for example, you can disable this feature with the `config.structuralSharing` flag. If you are dealing with non-JSON compatible values in your query responses and still want to detect if data has changed or not, you can define a data compare function with `config.isDataEqual`.
diff --git a/src/core/config.ts b/src/core/config.ts
index 2fa181002..74a1cc71d 100644
--- a/src/core/config.ts
+++ b/src/core/config.ts
@@ -56,6 +56,7 @@ export const DEFAULT_CONFIG: ReactQueryConfig = {
     staleTime: 0,
     cacheTime: 5 * 60 * 1000,
     refetchOnWindowFocus: true,
+    refetchOnReconnect: true,
     refetchOnMount: true,
     structuralSharing: true,
   },
diff --git a/src/core/index.ts b/src/core/index.ts
index fdb7dc2b3..a4636e68f 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -1,13 +1,7 @@
 export { queryCache, queryCaches, makeQueryCache } from './queryCache'
 export { setFocusHandler } from './setFocusHandler'
-export {
-  CancelledError,
-  deepIncludes,
-  isCancelledError,
-  isError,
-  setConsole,
-  stableStringify,
-} from './utils'
+export { setOnlineHandler } from './setOnlineHandler'
+export { CancelledError, isCancelledError, isError, setConsole } from './utils'
 
 // Types
 export * from './types'
diff --git a/src/core/query.ts b/src/core/query.ts
index 9da09f017..aebf93693 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -7,6 +7,7 @@ import {
   isCancelable,
   isCancelledError,
   isDocumentVisible,
+  isOnline,
   isServer,
   replaceEqualDeep,
   sleep,
@@ -208,7 +209,7 @@ export class Query<TResult, TError> {
     this.cancelFetch?.()
   }
 
-  continue(): void {
+  private continue(): void {
     this.continueFetch?.()
   }
 
@@ -280,12 +281,30 @@ export class Query<TResult, TError> {
     return this.observers.some(observer => observer.config.enabled)
   }
 
-  shouldRefetchOnWindowFocus(): boolean {
-    return (
-      this.isEnabled() &&
+  onWindowFocus(): void {
+    if (
       this.state.isStale &&
-      this.observers.some(observer => observer.config.refetchOnWindowFocus)
-    )
+      this.observers.some(
+        observer =>
+          observer.config.enabled && observer.config.refetchOnWindowFocus
+      )
+    ) {
+      this.fetch()
+    }
+    this.continue()
+  }
+
+  onOnline(): void {
+    if (
+      this.state.isStale &&
+      this.observers.some(
+        observer =>
+          observer.config.enabled && observer.config.refetchOnReconnect
+      )
+    ) {
+      this.fetch()
+    }
+    this.continue()
   }
 
   subscribe(
@@ -410,8 +429,8 @@ export class Query<TResult, TError> {
           // Delay
           await sleep(functionalUpdate(retryDelay, failureCount) || 0)
 
-          // Pause retry if the document is not visible
-          if (!isDocumentVisible()) {
+          // Pause retry if the document is not visible or when the device is offline
+          if (!isDocumentVisible() || !isOnline()) {
             await new Promise(continueResolve => {
               continueLoop = continueResolve
             })
diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts
index 267b79bae..652640db3 100644
--- a/src/core/queryCache.ts
+++ b/src/core/queryCache.ts
@@ -3,7 +3,9 @@ import {
   deepIncludes,
   functionalUpdate,
   getQueryArgs,
+  isDocumentVisible,
   isObject,
+  isOnline,
   isServer,
 } from './utils'
 import { getDefaultedQueryConfig } from './config'
@@ -344,3 +346,17 @@ export const queryCaches = [defaultQueryCache]
 export function makeQueryCache(config?: QueryCacheConfig) {
   return new QueryCache(config)
 }
+
+export function onVisibilityOrOnlineChange(isOnlineChange: boolean) {
+  if (isDocumentVisible() && isOnline()) {
+    queryCaches.forEach(queryCache => {
+      queryCache.getQueries(query => {
+        if (isOnlineChange) {
+          query.onOnline()
+        } else {
+          query.onWindowFocus()
+        }
+      })
+    })
+  }
+}
diff --git a/src/core/setFocusHandler.ts b/src/core/setFocusHandler.ts
index 05fecb1c3..2fdb02795 100644
--- a/src/core/setFocusHandler.ts
+++ b/src/core/setFocusHandler.ts
@@ -1,49 +1,22 @@
-import { isOnline, isDocumentVisible, Console, isServer } from './utils'
-import { queryCaches } from './queryCache'
+import { createSetHandler, isServer } from './utils'
+import { onVisibilityOrOnlineChange } from './queryCache'
 
-type FocusHandler = () => void
+export const setFocusHandler = createSetHandler(() =>
+  onVisibilityOrOnlineChange(false)
+)
 
-const visibilityChangeEvent = 'visibilitychange'
-const focusEvent = 'focus'
-
-const onWindowFocus: FocusHandler = () => {
-  if (isDocumentVisible() && isOnline()) {
-    queryCaches.forEach(queryCache => {
-      // Continue any paused queries
-      queryCache.getQueries(query => {
-        query.continue()
-      })
-
-      // Invalidate queries which should refetch on window focus
-      queryCache
-        .invalidateQueries(query => query.shouldRefetchOnWindowFocus())
-        .catch(Console.error)
-    })
-  }
-}
-
-let removePreviousHandler: (() => void) | void
-
-export function setFocusHandler(callback: (callback: FocusHandler) => void) {
-  // Unsub the old watcher
-  if (removePreviousHandler) {
-    removePreviousHandler()
+setFocusHandler(handleFocus => {
+  if (isServer || !window?.addEventListener) {
+    return
   }
-  // Sub the new watcher
-  removePreviousHandler = callback(onWindowFocus)
-}
 
-setFocusHandler((handleFocus: FocusHandler) => {
   // Listen to visibillitychange and focus
-  if (!isServer && window?.addEventListener) {
-    window.addEventListener(visibilityChangeEvent, handleFocus, false)
-    window.addEventListener(focusEvent, handleFocus, false)
+  window.addEventListener('visibilitychange', handleFocus, false)
+  window.addEventListener('focus', handleFocus, false)
 
-    return () => {
-      // Be sure to unsubscribe if a new handler is set
-      window.removeEventListener(visibilityChangeEvent, handleFocus)
-      window.removeEventListener(focusEvent, handleFocus)
-    }
+  return () => {
+    // Be sure to unsubscribe if a new handler is set
+    window.removeEventListener('visibilitychange', handleFocus)
+    window.removeEventListener('focus', handleFocus)
   }
-  return
 })
diff --git a/src/core/setOnlineHandler.ts b/src/core/setOnlineHandler.ts
new file mode 100644
index 000000000..853bf08cb
--- /dev/null
+++ b/src/core/setOnlineHandler.ts
@@ -0,0 +1,20 @@
+import { createSetHandler, isServer } from './utils'
+import { onVisibilityOrOnlineChange } from './queryCache'
+
+export const setOnlineHandler = createSetHandler(() =>
+  onVisibilityOrOnlineChange(true)
+)
+
+setOnlineHandler(handleOnline => {
+  if (isServer || !window?.addEventListener) {
+    return
+  }
+
+  // Listen to online
+  window.addEventListener('online', handleOnline, false)
+
+  return () => {
+    // Be sure to unsubscribe if a new handler is set
+    window.removeEventListener('online', handleOnline)
+  }
+})
diff --git a/src/core/types.ts b/src/core/types.ts
index 1e8a7168e..4883d4068 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -1,4 +1,4 @@
-import type { Query, FetchMoreOptions, RefetchOptions } from './query';
+import type { Query, FetchMoreOptions, RefetchOptions } from './query'
 import type { QueryCache } from './queryCache'
 
 export type QueryKey =
@@ -95,6 +95,11 @@ export interface QueryObserverConfig<
    * Defaults to `true`.
    */
   refetchOnWindowFocus?: boolean
+  /**
+   * Set this to `true` or `false` to enable/disable automatic refetching on reconnect for this query.
+   * Defaults to `true`.
+   */
+  refetchOnReconnect?: boolean
   /**
    * If set to `false`, will disable additional instances of a query to trigger background refetches.
    * Defaults to `true`.
diff --git a/src/core/utils.ts b/src/core/utils.ts
index fdc9c9714..2d8cb6e24 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -235,3 +235,15 @@ export function getStatusProps<T extends QueryStatus>(status: T) {
     isIdle: status === QueryStatus.Idle,
   }
 }
+
+export function createSetHandler(fn: () => void) {
+  let removePreviousHandler: (() => void) | void
+  return (callback: (handler: () => void) => void) => {
+    // Unsub the old handler
+    if (removePreviousHandler) {
+      removePreviousHandler()
+    }
+    // Sub the new handler
+    removePreviousHandler = callback(fn)
+  }
+}


Test patch:
diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx
index e5235c7c1..c84f3b2f2 100644
--- a/src/core/tests/queryCache.test.tsx
+++ b/src/core/tests/queryCache.test.tsx
@@ -3,6 +3,7 @@ import {
   queryKey,
   mockVisibilityState,
   mockConsoleError,
+  mockNavigatorOnLine,
 } from '../../react/tests/utils'
 import { makeQueryCache, queryCache as defaultQueryCache } from '..'
 import { isCancelledError, isError } from '../utils'
@@ -435,6 +436,54 @@ describe('queryCache', () => {
       expect(result).toBe('data3')
     })
 
+    it('should continue retry after reconnect and resolve all promises', async () => {
+      const key = queryKey()
+
+      mockNavigatorOnLine(false)
+
+      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 navigator to original value
+      mockNavigatorOnLine(true)
+      window.dispatchEvent(new Event('online'))
+
+      // 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()
 
diff --git a/src/react/tests/utils.tsx b/src/react/tests/utils.tsx
index 481b1836f..4e00ff1a3 100644
--- a/src/react/tests/utils.tsx
+++ b/src/react/tests/utils.tsx
@@ -7,6 +7,13 @@ export function mockVisibilityState(value: string) {
   })
 }
 
+export function mockNavigatorOnLine(value: boolean) {
+  Object.defineProperty(navigator, 'onLine', {
+    value,
+    configurable: true,
+  })
+}
+
 export function mockConsoleError() {
   const consoleMock = jest.spyOn(console, 'error')
   consoleMock.mockImplementation(() => undefined)