Repository: tannerlinsley/react-query
Pull request #954 (2020-09-02 15:50:02)

feat: add forceFetchOnMount flag

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



Patch:
diff --git a/docs/src/pages/docs/api.md b/docs/src/pages/docs/api.md
index ed71f8c25..40e17818a 100644
--- a/docs/src/pages/docs/api.md
+++ b/docs/src/pages/docs/api.md
@@ -22,6 +22,7 @@ const {
 } = useQuery(queryKey, queryFn?, {
   cacheTime,
   enabled,
+  forceFetchOnMount,
   initialData,
   initialStale,
   isDataEqual,
@@ -128,6 +129,10 @@ const queryInfo = useQuery({
   - Optional
   - Defaults to `false`
   - If set, any previous `data` will be kept when fetching new data because the query key changed.
+- `forceFetchOnMount: Boolean`
+  - Optional
+  - Defaults to `false`
+  - Set this to `true` to always fetch when the component mounts (regardless of staleness).
 - `refetchOnMount: Boolean`
   - Optional
   - Defaults to `true`
diff --git a/src/core/query.ts b/src/core/query.ts
index e731472aa..49b1a9d14 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -135,7 +135,7 @@ export class Query<TResult, TError> {
     this.state = queryReducer(this.state, action)
 
     this.observers.forEach(observer => {
-      observer.onQueryUpdate(this.state, action)
+      observer.onQueryUpdate(action)
     })
 
     this.notifyGlobalListeners(this)
@@ -157,20 +157,6 @@ export class Query<TResult, TError> {
     }, this.cacheTime)
   }
 
-  async refetch(
-    options?: RefetchOptions,
-    config?: QueryConfig<TResult, TError>
-  ): Promise<TResult | undefined> {
-    try {
-      return await this.fetch(undefined, config)
-    } catch (error) {
-      if (options?.throwOnError === true) {
-        throw error
-      }
-      return undefined
-    }
-  }
-
   cancel(): void {
     this.cancelFetch?.()
   }
@@ -243,7 +229,7 @@ export class Query<TResult, TError> {
     )
   }
 
-  onWindowFocus(): void {
+  async onWindowFocus(): Promise<void> {
     if (
       this.observers.some(
         observer =>
@@ -252,12 +238,17 @@ export class Query<TResult, TError> {
           observer.config.refetchOnWindowFocus
       )
     ) {
-      this.fetch()
+      try {
+        await this.fetch()
+      } catch {
+        // ignore
+      }
     }
+
     this.continue()
   }
 
-  onOnline(): void {
+  async onOnline(): Promise<void> {
     if (
       this.observers.some(
         observer =>
@@ -266,8 +257,13 @@ export class Query<TResult, TError> {
           observer.config.refetchOnReconnect
       )
     ) {
-      this.fetch()
+      try {
+        await this.fetch()
+      } catch {
+        // ignore
+      }
     }
+
     this.continue()
   }
 
@@ -306,6 +302,35 @@ export class Query<TResult, TError> {
     this.scheduleGc()
   }
 
+  async refetch(
+    options?: RefetchOptions,
+    config?: QueryConfig<TResult, TError>
+  ): Promise<TResult | undefined> {
+    try {
+      return await this.fetch(undefined, config)
+    } catch (error) {
+      if (options?.throwOnError === true) {
+        throw error
+      }
+    }
+  }
+
+  async fetchMore(
+    fetchMoreVariable?: unknown,
+    options?: FetchMoreOptions,
+    config?: QueryConfig<TResult, TError>
+  ): Promise<TResult | undefined> {
+    return this.fetch(
+      {
+        fetchMore: {
+          fetchMoreVariable,
+          previous: options?.previous || false,
+        },
+      },
+      config
+    )
+  }
+
   async fetch(
     options?: FetchOptions,
     config?: QueryConfig<TResult, TError>
@@ -548,22 +573,6 @@ export class Query<TResult, TError> {
       run()
     })
   }
-
-  fetchMore(
-    fetchMoreVariable?: unknown,
-    options?: FetchMoreOptions,
-    config?: QueryConfig<TResult, TError>
-  ): Promise<TResult | undefined> {
-    return this.fetch(
-      {
-        fetchMore: {
-          fetchMoreVariable,
-          previous: options?.previous || false,
-        },
-      },
-      config
-    )
-  }
 }
 
 function getLastPage<TResult>(pages: TResult[], previous?: boolean): TResult {
@@ -578,7 +587,6 @@ function hasMorePages<TResult, TError>(
   if (config.infinite && config.getFetchMore && Array.isArray(pages)) {
     return Boolean(config.getFetchMore(getLastPage(pages, previous), pages))
   }
-  return undefined
 }
 
 function getDefaultState<TResult, TError>(
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index e8cb6c5f4..ce9fe72c3 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -1,12 +1,6 @@
 import { getStatusProps, isServer, isDocumentVisible } from './utils'
 import type { QueryResult, QueryObserverConfig } from './types'
-import type {
-  Query,
-  QueryState,
-  Action,
-  FetchMoreOptions,
-  RefetchOptions,
-} from './query'
+import type { Query, Action, FetchMoreOptions, RefetchOptions } from './query'
 import type { QueryCache } from './queryCache'
 
 export type UpdateListener<TResult, TError> = (
@@ -19,7 +13,7 @@ export class QueryObserver<TResult, TError> {
   private queryCache: QueryCache
   private currentQuery!: Query<TResult, TError>
   private currentResult!: QueryResult<TResult, TError>
-  private previousResult?: QueryResult<TResult, TError>
+  private previousQueryResult?: QueryResult<TResult, TError>
   private updateListener?: UpdateListener<TResult, TError>
   private staleTimeoutId?: number
   private refetchIntervalId?: number
@@ -42,7 +36,13 @@ export class QueryObserver<TResult, TError> {
     this.started = true
     this.updateListener = listener
     this.currentQuery.subscribeObserver(this)
-    this.optionalFetch()
+
+    if (this.config.enabled && this.config.forceFetchOnMount) {
+      this.fetch()
+    } else {
+      this.optionalFetch()
+    }
+
     this.updateTimers()
     return this.unsubscribe.bind(this)
   }
@@ -122,8 +122,8 @@ export class QueryObserver<TResult, TError> {
   async fetch(): Promise<TResult | undefined> {
     try {
       return await this.currentQuery.fetch(undefined, this.config)
-    } catch (error) {
-      return undefined
+    } catch {
+      // ignore
     }
   }
 
@@ -141,11 +141,15 @@ export class QueryObserver<TResult, TError> {
   private updateIsStale(): void {
     const isStale = this.currentQuery.isStaleByTime(this.config.staleTime)
     if (isStale !== this.currentResult.isStale) {
-      this.currentResult = this.createResult()
-      this.updateListener?.(this.currentResult)
+      this.updateResult()
+      this.notify()
     }
   }
 
+  private notify(): void {
+    this.updateListener?.(this.currentResult)
+  }
+
   private updateStaleTimeout(): void {
     if (isServer) {
       return
@@ -216,8 +220,8 @@ export class QueryObserver<TResult, TError> {
     }
   }
 
-  private createResult(): QueryResult<TResult, TError> {
-    const { currentQuery, currentResult, previousResult, config } = this
+  private updateResult(): void {
+    const { currentQuery, currentResult, previousQueryResult, config } = this
     const { state } = currentQuery
     let { data, status, updatedAt } = state
 
@@ -225,30 +229,30 @@ export class QueryObserver<TResult, TError> {
     if (
       config.keepPreviousData &&
       state.isLoading &&
-      previousResult?.isSuccess
+      previousQueryResult?.isSuccess
     ) {
-      data = previousResult.data
-      updatedAt = previousResult.updatedAt
-      status = previousResult.status
+      data = previousQueryResult.data
+      updatedAt = previousQueryResult.updatedAt
+      status = previousQueryResult.status
     }
 
     let isStale = false
 
     // When the query has not been fetched yet and this is the initial render,
     // determine the staleness based on the initialStale or existence of initial data.
-    if (!currentResult && !currentQuery.state.isFetched) {
+    if (!currentResult && !state.isFetched) {
       if (typeof config.initialStale === 'function') {
         isStale = config.initialStale()
       } else if (typeof config.initialStale === 'boolean') {
         isStale = config.initialStale
       } else {
-        isStale = typeof currentQuery.state.data === 'undefined'
+        isStale = typeof state.data === 'undefined'
       }
     } else {
       isStale = currentQuery.isStaleByTime(config.staleTime)
     }
 
-    return {
+    this.currentResult = {
       ...getStatusProps(status),
       canFetchMore: state.canFetchMore,
       clear: this.clear,
@@ -282,9 +286,9 @@ export class QueryObserver<TResult, TError> {
       return false
     }
 
-    this.previousResult = this.currentResult
+    this.previousQueryResult = this.currentResult
     this.currentQuery = newQuery
-    this.currentResult = this.createResult()
+    this.updateResult()
 
     if (this.started) {
       prevQuery?.unsubscribeObserver(this)
@@ -294,39 +298,33 @@ export class QueryObserver<TResult, TError> {
     return true
   }
 
-  onQueryUpdate(
-    _state: QueryState<TResult, TError>,
-    action: Action<TResult, TError>
-  ): void {
-    const { config } = this
-
+  onQueryUpdate(action: Action<TResult, TError>): void {
     // Store current result and get new result
     const prevResult = this.currentResult
-    this.currentResult = this.createResult()
-    const result = this.currentResult
+    this.updateResult()
+
+    const { currentResult, config } = this
 
     // We need to check the action because the state could have
     // transitioned from success to success in case of `setQueryData`.
-    if (action.type === 'Success' && result.isSuccess) {
-      config.onSuccess?.(result.data!)
-      config.onSettled?.(result.data!, null)
+    if (action.type === 'Success' && currentResult.isSuccess) {
+      config.onSuccess?.(currentResult.data!)
+      config.onSettled?.(currentResult.data!, null)
       this.updateTimers()
-    } else if (action.type === 'Error' && result.isError) {
-      config.onError?.(result.error!)
-      config.onSettled?.(undefined, result.error!)
+    } else if (action.type === 'Error' && currentResult.isError) {
+      config.onError?.(currentResult.error!)
+      config.onSettled?.(undefined, currentResult.error!)
       this.updateTimers()
     }
 
-    // Decide if we need to notify the listener
-    const notify =
+    if (
       // Always notify on data or error change
-      result.data !== prevResult.data ||
-      result.error !== prevResult.error ||
+      currentResult.data !== prevResult.data ||
+      currentResult.error !== prevResult.error ||
       // Maybe notify on other changes
       config.notifyOnStatusChange
-
-    if (notify) {
-      this.updateListener?.(result)
+    ) {
+      this.notify()
     }
   }
 }
diff --git a/src/core/types.ts b/src/core/types.ts
index fccead863..1a8f16a9b 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -105,6 +105,11 @@ export interface QueryObserverConfig<
    * Defaults to `true`.
    */
   refetchOnMount?: boolean
+  /**
+   * Set this to `true` to always fetch when the component mounts (regardless of staleness).
+   * Defaults to `false`.
+   */
+  forceFetchOnMount?: boolean
   /**
    * Whether a change to the query status should re-render a component.
    * If set to `false`, the component will only re-render when the actual `data` or `error` changes.


Test patch:
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index abc2c9c41..cc1882e7e 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -357,7 +357,7 @@ describe('useQuery', () => {
     return null
   })
 
-  it.only('should update disabled query when updated with invalidateQueries', async () => {
+  it('should update disabled query when updated with invalidateQueries', async () => {
     const key = queryKey()
     const states: QueryResult<number>[] = []
     let count = 0
@@ -843,6 +843,78 @@ describe('useQuery', () => {
     consoleMock.mockRestore()
   })
 
+  it('should always fetch if forceFetchOnMount is set', async () => {
+    const key = queryKey()
+    const states: QueryResult<string>[] = []
+
+    await queryCache.prefetchQuery(key, () => 'prefetched')
+
+    function Page() {
+      const state = useQuery(key, () => 'data', {
+        forceFetchOnMount: true,
+        staleTime: 100,
+      })
+      states.push(state)
+      return null
+    }
+
+    render(<Page />)
+
+    await waitFor(() => expect(states.length).toBe(3))
+
+    expect(states).toMatchObject([
+      { data: 'prefetched', isStale: false, isFetching: false },
+      { data: 'prefetched', isStale: false, isFetching: true },
+      { data: 'data', isStale: false, isFetching: false },
+    ])
+  })
+
+  it('should not fetch if initial data is set', async () => {
+    const key = queryKey()
+    const states: QueryResult<string>[] = []
+
+    function Page() {
+      const state = useQuery(key, () => 'data', {
+        initialData: 'initial',
+      })
+      states.push(state)
+      return null
+    }
+
+    render(<Page />)
+
+    await waitFor(() => expect(states.length).toBe(2))
+
+    expect(states).toMatchObject([
+      { data: 'initial', isStale: false },
+      { data: 'initial', isStale: true },
+    ])
+  })
+
+  it('should fetch if initial data is set and initial stale is set to true', async () => {
+    const key = queryKey()
+    const states: QueryResult<string>[] = []
+
+    function Page() {
+      const state = useQuery(key, () => 'data', {
+        initialData: 'initial',
+        initialStale: true,
+      })
+      states.push(state)
+      return null
+    }
+
+    render(<Page />)
+
+    await waitFor(() => expect(states.length).toBe(3))
+
+    expect(states).toMatchObject([
+      { data: 'initial', isStale: true, isFetching: false },
+      { data: 'initial', isStale: true, isFetching: true },
+      { data: 'data', isStale: true, isFetching: false },
+    ])
+  })
+
   it('should keep initial stale and initial data when the query key changes', async () => {
     const key = queryKey()
     const states: QueryResult<{ count: number }>[] = []