Repository: tannerlinsley/react-query
Pull request #914 (2020-08-23 10:14:18)

refactor: reduce infinite query renders and bundle size
This MR addresses the following things:
- Fixes bug where previously fetched pages were still remembered after setting new data with `setQueryData`.
- Prevent unnecessary renders while performing infinite queries.
- Reduce bundle size by around 10%.
- Also update `canFetchMore` when setting data with `setQueryData`.


Patch:
diff --git a/.eslintrc b/.eslintrc
index 71ebf1b4d..9fcf330fa 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -19,6 +19,12 @@
     "@typescript-eslint/explicit-module-boundary-types": "off",
     "@typescript-eslint/no-empty-interface": "off",
     "@typescript-eslint/no-explicit-any": "off",
-    "@typescript-eslint/no-non-null-assertion": "off"
+    "@typescript-eslint/no-non-null-assertion": "off",
+    "@typescript-eslint/no-inferrable-types": [
+      "error",
+      {
+        "ignoreParameters": true
+      }
+    ]
   }
 }
diff --git a/src/core/query.ts b/src/core/query.ts
index 33a62d5e8..9da09f017 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -13,7 +13,6 @@ import {
 } from './utils'
 import {
   ArrayQueryKey,
-  InfiniteQueryConfig,
   InitialDataFunction,
   IsFetchingMoreValue,
   QueryConfig,
@@ -70,7 +69,6 @@ export enum ActionType {
   Fetch = 'Fetch',
   Success = 'Success',
   Error = 'Error',
-  SetState = 'SetState',
 }
 
 interface FailedAction {
@@ -83,11 +81,13 @@ interface MarkStaleAction {
 
 interface FetchAction {
   type: ActionType.Fetch
+  isFetchingMore?: IsFetchingMoreValue
 }
 
 interface SuccessAction<TResult> {
   type: ActionType.Success
   data: TResult | undefined
+  canFetchMore?: boolean
   isStale: boolean
 }
 
@@ -96,17 +96,11 @@ interface ErrorAction<TError> {
   error: TError
 }
 
-interface SetStateAction<TResult, TError> {
-  type: ActionType.SetState
-  updater: Updater<QueryState<TResult, TError>, QueryState<TResult, TError>>
-}
-
 export type Action<TResult, TError> =
   | ErrorAction<TError>
   | FailedAction
   | FetchAction
   | MarkStaleAction
-  | SetStateAction<TResult, TError>
   | SuccessAction<TResult>
 
 // CLASS
@@ -120,8 +114,6 @@ export class Query<TResult, TError> {
 
   private queryCache: QueryCache
   private promise?: Promise<TResult | undefined>
-  private fetchMoreVariable?: unknown
-  private pageVariables?: ArrayQueryKey[]
   private cacheTimeout?: number
   private staleTimeout?: number
   private cancelFetch?: () => void
@@ -138,24 +130,6 @@ export class Query<TResult, TError> {
     this.observers = []
     this.state = getDefaultState(init.config)
 
-    if (init.config.infinite) {
-      const infiniteConfig = init.config as InfiniteQueryConfig<TResult, TError>
-      const infiniteData = (this.state.data as unknown) as TResult[] | undefined
-
-      if (typeof infiniteData !== 'undefined') {
-        this.fetchMoreVariable = infiniteConfig.getFetchMore(
-          infiniteData[infiniteData.length - 1],
-          infiniteData
-        )
-        this.state.canFetchMore = Boolean(this.fetchMoreVariable)
-      }
-
-      // Here we seed the pageVariables for the query
-      if (!this.pageVariables) {
-        this.pageVariables = [[...this.queryKey]]
-      }
-    }
-
     // If the query started with data, schedule
     // a stale timeout
     if (!isServer && this.state.data) {
@@ -258,12 +232,6 @@ export class Query<TResult, TError> {
     }
   }
 
-  private setState(
-    updater: Updater<QueryState<TResult, TError>, QueryState<TResult, TError>>
-  ): void {
-    this.dispatch({ type: ActionType.SetState, updater })
-  }
-
   setData(updater: Updater<TResult | undefined, TResult>): void {
     const prevData = this.state.data
 
@@ -282,11 +250,15 @@ export class Query<TResult, TError> {
 
     const isStale = this.config.staleTime === 0
 
+    // Try to determine if more data can be fetched
+    const canFetchMore = hasMorePages(this.config, data)
+
     // Set data and mark it as cached
     this.dispatch({
       type: ActionType.Success,
       data,
       isStale,
+      canFetchMore,
     })
 
     if (!isStale) {
@@ -337,10 +309,7 @@ export class Query<TResult, TError> {
     this.clearCacheTimeout()
   }
 
-  unsubscribeObserver(
-    observer: QueryObserver<TResult, TError>,
-    preventGC?: boolean
-  ): void {
+  unsubscribeObserver(observer: QueryObserver<TResult, TError>): void {
     this.observers = this.observers.filter(x => x !== observer)
 
     if (!this.observers.length) {
@@ -350,19 +319,16 @@ export class Query<TResult, TError> {
         this.cancel()
       }
 
-      if (!preventGC) {
-        // Schedule garbage collection
-        this.scheduleCacheTimeout()
-      }
+      // Schedule garbage collection
+      this.scheduleCacheTimeout()
     }
   }
 
-  // Set up the core fetcher function
-  private async fetchData(
-    fn: QueryFunction<TResult>,
-    args: ArrayQueryKey
-  ): Promise<TResult> {
-    return new Promise<TResult>((outerResolve, outerReject) => {
+  private async tryFetchData<T>(
+    config: QueryConfig<TResult, TError>,
+    fn: QueryFunction<T>
+  ): Promise<T> {
+    return new Promise<T>((outerResolve, outerReject) => {
       let resolved = false
       let continueLoop: () => void
       let cancelTransport: () => void
@@ -401,15 +367,11 @@ export class Query<TResult, TError> {
         continueLoop?.()
       }
 
-      // 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)
+          const promiseOrValue = fn()
 
           // Check if the transport layer support cancellation
           if (isCancelable(promiseOrValue)) {
@@ -429,7 +391,7 @@ export class Query<TResult, TError> {
 
           // Do we need to retry the request?
           const { failureCount } = this.state
-          const { retry, retryDelay } = this.config
+          const { retry, retryDelay } = config
 
           const shouldRetry =
             retry === true ||
@@ -473,120 +435,27 @@ export class Query<TResult, TError> {
       return this.promise
     }
 
-    let queryFn = this.config.queryFn
+    // Store reference to the config that initiated this fetch
+    const config = this.config
 
-    if (!queryFn) {
+    // Check if there is a query function
+    if (!config.queryFn) {
       return
     }
 
-    if (this.config.infinite) {
-      const infiniteConfig = this.config as InfiniteQueryConfig<TResult, TError>
-      const infiniteData = (this.state.data as unknown) as TResult[] | undefined
-      const fetchMore = options?.fetchMore
-
-      const originalQueryFn = queryFn
-
-      queryFn = async () => {
-        const data: TResult[] = []
-        const pageVariables = this.pageVariables ? [...this.pageVariables] : []
-        const rebuiltPageVariables: ArrayQueryKey[] = []
-
-        do {
-          const args = pageVariables.shift()!
-
-          if (!data.length) {
-            // the first page query doesn't need to be rebuilt
-            data.push(await originalQueryFn(...args))
-            rebuiltPageVariables.push(args)
-          } else {
-            // get an up-to-date cursor based on the previous data set
-
-            const nextCursor = infiniteConfig.getFetchMore(
-              data[data.length - 1],
-              data
-            )
-
-            // break early if there's no next cursor
-            // otherwise we'll start from the beginning
-            // which will cause unwanted duplication
-            if (!nextCursor) {
-              break
-            }
-
-            const pageArgs = [
-              // remove the last argument (the previously saved cursor)
-              ...args.slice(0, -1),
-              nextCursor,
-            ] as ArrayQueryKey
-
-            data.push(await originalQueryFn(...pageArgs))
-            rebuiltPageVariables.push(pageArgs)
-          }
-        } while (pageVariables.length)
-
-        this.fetchMoreVariable = infiniteConfig.getFetchMore(
-          data[data.length - 1],
-          data
-        )
-        this.state.canFetchMore = Boolean(this.fetchMoreVariable)
-        this.pageVariables = rebuiltPageVariables
-
-        return (data as unknown) as TResult
-      }
-
-      if (fetchMore) {
-        queryFn = async (...args: ArrayQueryKey) => {
-          try {
-            const { fetchMoreVariable, previous } = fetchMore
-
-            this.setState(old => ({
-              ...old,
-              isFetchingMore: previous ? 'previous' : 'next',
-            }))
-
-            const newArgs = [...args, fetchMoreVariable] as ArrayQueryKey
-
-            if (this.pageVariables) {
-              this.pageVariables[previous ? 'unshift' : 'push'](newArgs)
-            } else {
-              this.pageVariables = [newArgs]
-            }
-
-            const newData = await originalQueryFn(...newArgs)
-
-            let data
-
-            if (!infiniteData) {
-              data = [newData]
-            } else if (previous) {
-              data = [newData, ...infiniteData]
-            } else {
-              data = [...infiniteData, newData]
-            }
-
-            this.fetchMoreVariable = infiniteConfig.getFetchMore(newData, data)
-            this.state.canFetchMore = Boolean(this.fetchMoreVariable)
-
-            return (data as unknown) as TResult
-          } finally {
-            this.setState(old => ({
-              ...old,
-              isFetchingMore: false,
-            }))
-          }
-        }
-      }
-    }
+    // Get the query function params
+    const filter = config.queryFnParamsFilter
+    const params = filter ? filter(this.queryKey) : this.queryKey
 
     this.promise = (async () => {
       try {
-        // Set to fetching state if not already in it
-        if (!this.state.isFetching) {
-          this.dispatch({ type: ActionType.Fetch })
-        }
+        let data: any
 
-        // Try to get the data
-        const data = await this.fetchData(queryFn, this.queryKey)
+        if (config.infinite) {
+          data = await this.startInfiniteFetch(config, params, options)
+        } else {
+          data = await this.startFetch(config, params, options)
+        }
 
         // Set success state
         this.setData(data)
@@ -619,19 +488,106 @@ export class Query<TResult, TError> {
     return this.promise
   }
 
+  private async startFetch(
+    config: QueryConfig<TResult, TError>,
+    params: unknown[],
+    _options?: FetchOptions
+  ): Promise<TResult> {
+    // Create function to fetch the data
+    const fetchData = () => config.queryFn!(...params)
+
+    // Set to fetching state if not already in it
+    if (!this.state.isFetching) {
+      this.dispatch({ type: ActionType.Fetch })
+    }
+
+    // Try to fetch the data
+    return this.tryFetchData(config, fetchData)
+  }
+
+  private async startInfiniteFetch(
+    config: QueryConfig<TResult, TError>,
+    params: unknown[],
+    options?: FetchOptions
+  ): Promise<TResult[]> {
+    const fetchMore = options?.fetchMore
+    const { previous, fetchMoreVariable } = fetchMore || {}
+    const isFetchingMore = fetchMore ? (previous ? 'previous' : 'next') : false
+    const prevPages: TResult[] = (this.state.data as any) || []
+
+    // Create function to fetch a page
+    const fetchPage = async (
+      pages: TResult[],
+      prepend?: boolean,
+      cursor?: unknown
+    ) => {
+      const lastPage = getLastPage(pages, prepend)
+
+      if (
+        typeof cursor === 'undefined' &&
+        typeof lastPage !== 'undefined' &&
+        config.getFetchMore
+      ) {
+        cursor = config.getFetchMore(lastPage, pages)
+      }
+
+      const page = await config.queryFn!(...params, cursor)
+
+      return prepend ? [page, ...pages] : [...pages, page]
+    }
+
+    // Create function to fetch the data
+    const fetchData = () => {
+      if (isFetchingMore) {
+        return fetchPage(prevPages, previous, fetchMoreVariable)
+      } else if (!prevPages.length) {
+        return fetchPage([])
+      } else {
+        let promise = fetchPage([])
+        for (let i = 1; i < prevPages.length; i++) {
+          promise = promise.then(fetchPage)
+        }
+        return promise
+      }
+    }
+
+    // Set to fetching state if not already in it
+    if (!this.state.isFetching) {
+      this.dispatch({ type: ActionType.Fetch, isFetchingMore })
+    }
+
+    // Try to get the data
+    return this.tryFetchData(config, fetchData)
+  }
+
   fetchMore(
     fetchMoreVariable?: unknown,
     options?: FetchMoreOptions
   ): Promise<TResult | undefined> {
     return this.fetch({
       fetchMore: {
-        fetchMoreVariable: fetchMoreVariable ?? this.fetchMoreVariable,
+        fetchMoreVariable,
         previous: options?.previous || false,
       },
     })
   }
 }
 
+function getLastPage<TResult>(pages: TResult[], previous?: boolean): TResult {
+  return previous ? pages[0] : pages[pages.length - 1]
+}
+
+function hasMorePages<TResult, TError>(
+  config: QueryConfig<TResult, TError>,
+  pages: unknown,
+  previous?: boolean
+): boolean | undefined {
+  if (config.infinite && config.getFetchMore && Array.isArray(pages)) {
+    return Boolean(config.getFetchMore(getLastPage(pages, previous), pages))
+  }
+  return undefined
+}
+
 function getDefaultState<TResult, TError>(
   config: QueryConfig<TResult, TError>
 ): QueryState<TResult, TError> {
@@ -664,6 +620,7 @@ function getDefaultState<TResult, TError>(
     isStale,
     data: initialData,
     updatedAt: hasInitialData ? Date.now() : 0,
+    canFetchMore: hasMorePages(config, initialData),
   }
 }
 
@@ -691,6 +648,7 @@ export function queryReducer<TResult, TError>(
         ...state,
         ...getStatusProps(status),
         isFetching: true,
+        isFetchingMore: action.isFetchingMore || false,
         failureCount: 0,
       }
     case ActionType.Success:
@@ -702,6 +660,8 @@ export function queryReducer<TResult, TError>(
         isStale: action.isStale,
         isFetched: true,
         isFetching: false,
+        isFetchingMore: false,
+        canFetchMore: action.canFetchMore,
         updatedAt: Date.now(),
         failureCount: 0,
       }
@@ -712,12 +672,11 @@ export function queryReducer<TResult, TError>(
         error: action.error,
         isFetched: true,
         isFetching: false,
+        isFetchingMore: false,
         isStale: true,
         failureCount: state.failureCount + 1,
         throwInErrorBoundary: true,
       }
-    case ActionType.SetState:
-      return functionalUpdate(action.updater, state)
     default:
       return state
   }
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index 7fed7256e..5bfad4174 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -1,6 +1,12 @@
 import { getStatusProps, isServer, isDocumentVisible } from './utils'
 import type { QueryResult, QueryObserverConfig } from './types'
-import type { Query, QueryState, Action, FetchMoreOptions, RefetchOptions } from './query'
+import type {
+  Query,
+  QueryState,
+  Action,
+  FetchMoreOptions,
+  RefetchOptions,
+} from './query'
 
 export type UpdateListener<TResult, TError> = (
   result: QueryResult<TResult, TError>
@@ -37,11 +43,11 @@ export class QueryObserver<TResult, TError> {
     return this.unsubscribe.bind(this)
   }
 
-  unsubscribe(preventGC?: boolean): void {
+  unsubscribe(): void {
     this.started = false
     this.updateListener = undefined
     this.clearRefetchInterval()
-    this.currentQuery.unsubscribeObserver(this, preventGC)
+    this.currentQuery.unsubscribeObserver(this)
   }
 
   updateConfig(config: QueryObserverConfig<TResult, TError>): void {
diff --git a/src/core/types.ts b/src/core/types.ts
index f10766322..1e8a7168e 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -33,7 +33,7 @@ export type QueryKeySerializerFunction = (
   queryKey: QueryKey
 ) => [string, QueryKey[]]
 
-export interface BaseQueryConfig<TResult, TError = unknown> {
+export interface BaseQueryConfig<TResult, TError = unknown, TData = TResult> {
   /**
    * Set this to `false` to disable automatic refetching when the query mounts or changes query keys.
    * To refetch the query, use the `refetch` method returned from the `useQuery` instance.
@@ -50,7 +50,7 @@ export interface BaseQueryConfig<TResult, TError = unknown> {
   staleTime?: number
   cacheTime?: number
   isDataEqual?: (oldData: unknown, newData: unknown) => boolean
-  queryFn?: QueryFunction<TResult>
+  queryFn?: QueryFunction<TData>
   queryKey?: QueryKey
   queryKeySerializerFn?: QueryKeySerializerFunction
   queryFnParamsFilter?: (args: ArrayQueryKey) => ArrayQueryKey
@@ -62,10 +62,18 @@ export interface BaseQueryConfig<TResult, TError = unknown> {
    * Defaults to `true`.
    */
   structuralSharing?: boolean
+  /**
+   * This function can be set to automatically get the next cursor for infinite queries.
+   * The result will also be used to determine the value of `canFetchMore`.
+   */
+  getFetchMore?: (lastPage: TData, allPages: TData[]) => unknown
 }
 
-export interface QueryObserverConfig<TResult, TError = unknown>
-  extends BaseQueryConfig<TResult, TError> {
+export interface QueryObserverConfig<
+  TResult,
+  TError = unknown,
+  TData = TResult
+> extends BaseQueryConfig<TResult, TError, TData> {
   /**
    * Set this to `false` to disable automatic refetching when the query mounts or changes query keys.
    * To refetch the query, use the `refetch` method returned from the `useQuery` instance.
@@ -133,9 +141,7 @@ export interface PaginatedQueryConfig<TResult, TError = unknown>
   extends QueryObserverConfig<TResult, TError> {}
 
 export interface InfiniteQueryConfig<TResult, TError = unknown>
-  extends QueryObserverConfig<TResult[], TError> {
-  getFetchMore: (lastPage: TResult, allPages: TResult[]) => unknown
-}
+  extends QueryObserverConfig<TResult[], TError, TResult> {}
 
 export type IsFetchingMoreValue = 'previous' | 'next' | false
 
diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts
index 5991d9cbf..f5c76f50d 100644
--- a/src/react/useBaseQuery.ts
+++ b/src/react/useBaseQuery.ts
@@ -42,10 +42,8 @@ export function useBaseQuery<TResult, TError>(
     }
 
     if (config.enabled && config.suspense && !result.isSuccess) {
-      observer.subscribe()
-      throw observer.fetch().finally(() => {
-        observer.unsubscribe(true)
-      })
+      const unsubscribe = observer.subscribe()
+      throw observer.fetch().finally(unsubscribe)
     }
   }
 


Test patch:
diff --git a/src/react/tests/useInfiniteQuery.test.tsx b/src/react/tests/useInfiniteQuery.test.tsx
index 503a154f4..b5d3552cb 100644
--- a/src/react/tests/useInfiniteQuery.test.tsx
+++ b/src/react/tests/useInfiniteQuery.test.tsx
@@ -40,9 +40,9 @@ describe('useInfiniteQuery', () => {
     function Page() {
       const state = useInfiniteQuery(
         key,
-        (_key: string, nextId?: number) => fetchItems(nextId || 0, count++),
+        (_key: string, nextId: number = 0) => fetchItems(nextId, count++),
         {
-          getFetchMore: (lastGroup, _allGroups) => Boolean(lastGroup.nextId),
+          getFetchMore: (lastGroup, _allGroups) => lastGroup.nextId,
         }
       )
 
@@ -145,7 +145,7 @@ describe('useInfiniteQuery', () => {
 
     render(<Page />)
 
-    await waitFor(() => expect(states.length).toBe(8))
+    await waitFor(() => expect(states.length).toBe(6))
 
     expect(states[0]).toMatchObject({
       data: undefined,
@@ -166,31 +166,236 @@ describe('useInfiniteQuery', () => {
       isSuccess: true,
     })
     expect(states[3]).toMatchObject({
-      data: ['0-desc'],
-      isFetching: true,
-      isFetchingMore: 'next',
+      data: ['0-desc', '1-desc'],
+      isFetching: false,
+      isFetchingMore: false,
       isSuccess: true,
     })
     expect(states[4]).toMatchObject({
-      data: ['0-desc'],
+      data: ['0-desc', '1-desc'],
       isFetching: true,
       isFetchingMore: false,
       isSuccess: true,
     })
     expect(states[5]).toMatchObject({
-      data: ['0-desc', '1-desc'],
+      data: ['0-asc'],
       isFetching: false,
       isFetchingMore: false,
       isSuccess: true,
     })
-    expect(states[6]).toMatchObject({
-      data: ['0-desc', '1-desc'],
+  })
+
+  it('should prepend pages when the previous option is set to true', async () => {
+    const key = queryKey()
+    const states: InfiniteQueryResult<number>[] = []
+
+    function Page() {
+      const start = 10
+      const state = useInfiniteQuery(
+        key,
+        async (_key, page: number = start) => {
+          await sleep(10)
+          return page
+        },
+        {
+          getFetchMore: (lastPage, _pages) => lastPage - 1,
+        }
+      )
+
+      states.push(state)
+
+      const { fetchMore } = state
+
+      React.useEffect(() => {
+        setTimeout(() => {
+          fetchMore(undefined, { previous: true })
+        }, 20)
+      }, [fetchMore])
+
+      return null
+    }
+
+    render(<Page />)
+
+    await waitFor(() => expect(states.length).toBe(4))
+
+    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: 'previous',
+      isSuccess: true,
+    })
+    expect(states[3]).toMatchObject({
+      canFetchMore: true,
+      data: [9, 10],
+      isFetching: false,
       isFetchingMore: false,
       isSuccess: true,
     })
-    expect(states[7]).toMatchObject({
-      data: ['0-asc'],
+  })
+
+  it('should be able to override the cursor in the fetchMore callback', async () => {
+    const key = queryKey()
+    const states: InfiniteQueryResult<number>[] = []
+
+    function Page() {
+      const state = useInfiniteQuery(
+        key,
+        async (_key, page: number = 0) => {
+          await sleep(10)
+          return page
+        },
+        {
+          getFetchMore: (lastPage, _pages) => lastPage + 1,
+        }
+      )
+
+      states.push(state)
+
+      const { fetchMore } = state
+
+      React.useEffect(() => {
+        setTimeout(() => {
+          fetchMore(5)
+        }, 20)
+      }, [fetchMore])
+
+      return null
+    }
+
+    render(<Page />)
+
+    await waitFor(() => expect(states.length).toBe(4))
+
+    expect(states[0]).toMatchObject({
+      canFetchMore: undefined,
+      data: undefined,
+      isFetching: true,
+      isFetchingMore: false,
+      isSuccess: false,
+    })
+    expect(states[1]).toMatchObject({
+      canFetchMore: true,
+      data: [0],
+      isFetching: false,
+      isFetchingMore: false,
+      isSuccess: true,
+    })
+    expect(states[2]).toMatchObject({
+      canFetchMore: true,
+      data: [0],
+      isFetching: true,
+      isFetchingMore: 'next',
+      isSuccess: true,
+    })
+    expect(states[3]).toMatchObject({
+      canFetchMore: true,
+      data: [0, 5],
+      isFetching: false,
+      isFetchingMore: false,
+      isSuccess: true,
+    })
+  })
+
+  it('should be able to set new pages with the query cache', async () => {
+    const key = queryKey()
+    const states: InfiniteQueryResult<number>[] = []
+
+    function Page() {
+      const cache = useQueryCache()
+      const [firstPage, setFirstPage] = React.useState(0)
+
+      const state = useInfiniteQuery(
+        key,
+        async (_key, page: number = firstPage) => {
+          await sleep(10)
+          return page
+        },
+        {
+          getFetchMore: (lastPage, _pages) => lastPage + 1,
+        }
+      )
+
+      states.push(state)
+
+      const { refetch } = state
+
+      React.useEffect(() => {
+        setTimeout(() => {
+          cache.setQueryData(key, [7, 8])
+          setFirstPage(7)
+        }, 20)
+
+        setTimeout(() => {
+          refetch()
+        }, 50)
+      }, [cache, refetch])
+
+      return null
+    }
+
+    render(<Page />)
+
+    await waitFor(() => expect(states.length).toBe(6))
+
+    expect(states[0]).toMatchObject({
+      canFetchMore: undefined,
+      data: undefined,
+      isFetching: true,
+      isFetchingMore: false,
+      isSuccess: false,
+    })
+    // After first fetch
+    expect(states[1]).toMatchObject({
+      canFetchMore: true,
+      data: [0],
+      isFetching: false,
+      isFetchingMore: false,
+      isSuccess: true,
+    })
+    // Update cache
+    expect(states[2]).toMatchObject({
+      canFetchMore: true,
+      data: [7, 8],
+      isFetching: false,
+      isFetchingMore: false,
+      isSuccess: true,
+    })
+    // Set state
+    expect(states[3]).toMatchObject({
+      canFetchMore: true,
+      data: [7, 8],
+      isFetching: false,
+      isFetchingMore: false,
+      isSuccess: true,
+    })
+    // Refetch
+    expect(states[4]).toMatchObject({
+      canFetchMore: true,
+      data: [7, 8],
+      isFetching: true,
+      isFetchingMore: false,
+      isSuccess: true,
+    })
+    // Refetch done
+    expect(states[5]).toMatchObject({
+      canFetchMore: true,
+      data: [7, 8],
       isFetching: false,
       isFetchingMore: false,
       isSuccess: true,