Repository: tannerlinsley/react-query
Pull request #1000 (2020-09-11 19:24:07)

feat: cancel current fetch when fetching more
Fixes https://github.com/tannerlinsley/react-query/issues/972 and https://github.com/tannerlinsley/react-query/issues/880

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



Patch:
diff --git a/src/core/query.ts b/src/core/query.ts
index 54483ac29..64da77d5d 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -10,6 +10,7 @@ import {
   isOnline,
   isServer,
   isValidTimeout,
+  noop,
   replaceEqualDeep,
   sleep,
 } from './utils'
@@ -108,7 +109,7 @@ export class Query<TResult, TError> {
   private queryCache: QueryCache
   private promise?: Promise<TResult | undefined>
   private gcTimeout?: number
-  private cancelFetch?: () => void
+  private cancelFetch?: (silent?: boolean) => void
   private continueFetch?: () => void
   private isTransportCancelable?: boolean
 
@@ -154,8 +155,12 @@ export class Query<TResult, TError> {
     }, this.cacheTime)
   }
 
-  cancel(): void {
-    this.cancelFetch?.()
+  async cancel(silent?: boolean): Promise<void> {
+    const promise = this.promise
+    if (promise && this.cancelFetch) {
+      this.cancelFetch(silent)
+      await promise.catch(noop)
+    }
   }
 
   private continue(): void {
@@ -311,9 +316,14 @@ export class Query<TResult, TError> {
     options?: FetchOptions,
     config?: ResolvedQueryConfig<TResult, TError>
   ): Promise<TResult | undefined> {
-    // If we are already fetching, return current promise
     if (this.promise) {
-      return this.promise
+      if (options?.fetchMore && this.state.data) {
+        // Silently cancel current fetch if the user wants to fetch more
+        await this.cancel(true)
+      } else {
+        // Return current promise if we are already fetching
+        return this.promise
+      }
     }
 
     // Update config if passed, otherwise the config from the last execution is used
@@ -346,11 +356,13 @@ export class Query<TResult, TError> {
         // Return data
         return data
       } catch (error) {
-        // Set error state
-        this.dispatch({
-          type: ActionType.Error,
-          error,
-        })
+        // Set error state if needed
+        if (!(isCancelledError(error) && error.silent)) {
+          this.dispatch({
+            type: ActionType.Error,
+            error,
+          })
+        }
 
         // Log error
         if (!isCancelledError(error)) {
@@ -432,7 +444,10 @@ export class Query<TResult, TError> {
     }
 
     // Set to fetching state if not already in it
-    if (!this.state.isFetching) {
+    if (
+      !this.state.isFetching ||
+      this.state.isFetchingMore !== isFetchingMore
+    ) {
       this.dispatch({ type: ActionType.Fetch, isFetchingMore })
     }
 
@@ -471,11 +486,9 @@ export class Query<TResult, TError> {
       }
 
       // Create callback to cancel this fetch
-      this.cancelFetch = () => {
-        reject(new CancelledError())
-        try {
-          cancelTransport?.()
-        } catch {}
+      this.cancelFetch = silent => {
+        reject(new CancelledError(silent))
+        cancelTransport?.()
       }
 
       // Create callback to continue this fetch
@@ -492,7 +505,9 @@ export class Query<TResult, TError> {
           // Check if the transport layer support cancellation
           if (isCancelable(promiseOrValue)) {
             cancelTransport = () => {
-              promiseOrValue.cancel()
+              try {
+                promiseOrValue.cancel()
+              } catch {}
             }
             this.isTransportCancelable = true
           }
diff --git a/src/core/utils.ts b/src/core/utils.ts
index a1a36a3e0..c10e53631 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -20,7 +20,12 @@ interface Cancelable {
   cancel(): void
 }
 
-export class CancelledError {}
+export class CancelledError {
+  silent?: boolean
+  constructor(silent?: boolean) {
+    this.silent = silent
+  }
+}
 
 // UTILS
 
@@ -140,10 +145,6 @@ export function getQueryArgs<TResult, TError, TOptions = undefined>(
   return [queryKey, config, options]
 }
 
-export function deepEqual(a: any, b: any): boolean {
-  return replaceEqualDeep(a, b) === a
-}
-
 /**
  * This function returns `a` if `b` is deeply equal.
  * If not, it will replace any deeply equal children of `b` with those of `a`.


Test patch:
diff --git a/src/core/tests/utils.test.tsx b/src/core/tests/utils.test.tsx
index aca579458..da7adeb67 100644
--- a/src/core/tests/utils.test.tsx
+++ b/src/core/tests/utils.test.tsx
@@ -1,9 +1,4 @@
-import {
-  deepEqual,
-  replaceEqualDeep,
-  deepIncludes,
-  isPlainObject,
-} from '../utils'
+import { replaceEqualDeep, deepIncludes, isPlainObject } from '../utils'
 import { setConsole, queryCache } from '..'
 import { queryKey } from '../../react/tests/utils'
 
@@ -66,26 +61,6 @@ describe('core/utils', () => {
     })
   })
 
-  describe('deepEqual', () => {
-    it('should return `true` for equal objects', () => {
-      const a = { a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] }
-      const b = { a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] }
-      expect(deepEqual(a, b)).toEqual(true)
-    })
-
-    it('should return `false` for non equal objects', () => {
-      const a = { a: { b: 'b' }, c: 'c' }
-      const b = { a: { b: 'c' }, c: 'c' }
-      expect(deepEqual(a, b)).toEqual(false)
-    })
-
-    it('return `false` for equal dates', () => {
-      const date1 = new Date(2020, 3, 1)
-      const date2 = new Date(2020, 3, 1)
-      expect(deepEqual(date1, date2)).toEqual(false)
-    })
-  })
-
   describe('replaceEqualDeep', () => {
     it('should return the previous value when the next value is an equal primitive', () => {
       expect(replaceEqualDeep(1, 1)).toBe(1)
diff --git a/src/react/tests/useInfiniteQuery.test.tsx b/src/react/tests/useInfiniteQuery.test.tsx
index 86deb29d0..fdf5a04b8 100644
--- a/src/react/tests/useInfiniteQuery.test.tsx
+++ b/src/react/tests/useInfiniteQuery.test.tsx
@@ -1,7 +1,7 @@
 import { render, waitFor, fireEvent } from '@testing-library/react'
 import * as React from 'react'
 
-import { sleep, queryKey } from './utils'
+import { sleep, queryKey, waitForMs } from './utils'
 import { useInfiniteQuery, useQueryCache } from '..'
 import { InfiniteQueryResult } from '../../core'
 
@@ -257,6 +257,131 @@ describe('useInfiniteQuery', () => {
     })
   })
 
+  it('should silently cancel any ongoing fetch when fetching more', async () => {
+    const key = queryKey()
+    const states: InfiniteQueryResult<number>[] = []
+
+    function Page() {
+      const start = 10
+      const state = useInfiniteQuery(
+        key,
+        async (_key, page: number = start) => {
+          await sleep(50)
+          return page
+        },
+        {
+          getFetchMore: (lastPage, _pages) => lastPage + 1,
+        }
+      )
+
+      states.push(state)
+
+      const { refetch, fetchMore } = state
+
+      React.useEffect(() => {
+        setTimeout(() => {
+          refetch()
+        }, 100)
+        setTimeout(() => {
+          fetchMore()
+        }, 110)
+      }, [fetchMore, refetch])
+
+      return null
+    }
+
+    render(<Page />)
+
+    await waitFor(() => expect(states.length).toBe(5))
+
+    expect(states[0]).toMatchObject({
+      canFetchMore: undefined,
+      data: undefined,
+      isFetching: true,
+      isFetchingMore: false,
+      isSuccess: false,
+    })
+    expect(states[1]).toMatchObject({
+      canFetchMore: true,
+      data: [10],
+      isFetching: false,
+      isFetchingMore: false,
+      isSuccess: true,
+    })
+    expect(states[2]).toMatchObject({
+      canFetchMore: true,
+      data: [10],
+      isFetching: true,
+      isFetchingMore: false,
+      isSuccess: true,
+    })
+    expect(states[3]).toMatchObject({
+      canFetchMore: true,
+      data: [10],
+      isFetching: true,
+      isFetchingMore: 'next',
+      isSuccess: true,
+    })
+    expect(states[4]).toMatchObject({
+      canFetchMore: true,
+      data: [10, 11],
+      isFetching: false,
+      isFetchingMore: false,
+      isSuccess: true,
+    })
+  })
+
+  it('should keep fetching first page when not loaded yet and triggering fetch more', async () => {
+    const key = queryKey()
+    const states: InfiniteQueryResult<number>[] = []
+
+    function Page() {
+      const start = 10
+      const state = useInfiniteQuery(
+        key,
+        async (_key, page: number = start) => {
+          await sleep(50)
+          return page
+        },
+        {
+          getFetchMore: (lastPage, _pages) => lastPage + 1,
+        }
+      )
+
+      states.push(state)
+
+      const { refetch, fetchMore } = state
+
+      React.useEffect(() => {
+        setTimeout(() => {
+          fetchMore()
+        }, 10)
+      }, [fetchMore, refetch])
+
+      return null
+    }
+
+    render(<Page />)
+
+    await waitForMs(100)
+
+    expect(states.length).toBe(2)
+    expect(states[0]).toMatchObject({
+      canFetchMore: undefined,
+      data: undefined,
+      isFetching: true,
+      isFetchingMore: false,
+      isSuccess: false,
+    })
+    expect(states[1]).toMatchObject({
+      canFetchMore: true,
+      data: [10],
+      isFetching: false,
+      isFetchingMore: false,
+      isSuccess: true,
+    })
+  })
+
   it('should be able to override the cursor in the fetchMore callback', async () => {
     const key = queryKey()
     const states: InfiniteQueryResult<number>[] = []