Repository: tannerlinsley/react-query
Pull request #1010 (2020-09-13 16:57:30)

fix: make sure initial data always uses initial stale

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



Patch:
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index ff821a696..d46c2a4ae 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -5,7 +5,7 @@ import {
   isValidTimeout,
   noop,
 } from './utils'
-import { QueryResult, ResolvedQueryConfig, QueryStatus } from './types'
+import type { QueryResult, ResolvedQueryConfig } from './types'
 import type { Query, Action, FetchMoreOptions, RefetchOptions } from './query'
 
 export type UpdateListener<TResult, TError> = (
@@ -19,12 +19,14 @@ export class QueryObserver<TResult, TError> {
   private currentResult!: QueryResult<TResult, TError>
   private previousQueryResult?: QueryResult<TResult, TError>
   private listener?: UpdateListener<TResult, TError>
+  private isStale: boolean
   private initialUpdateCount: number
   private staleTimeoutId?: number
   private refetchIntervalId?: number
 
   constructor(config: ResolvedQueryConfig<TResult, TError>) {
     this.config = config
+    this.isStale = true
     this.initialUpdateCount = 0
 
     // Bind exposed methods
@@ -128,9 +130,9 @@ export class QueryObserver<TResult, TError> {
 
   private optionalFetch(): void {
     if (
-      this.config.enabled && // Don't auto refetch if disabled
+      this.config.enabled && // Only fetch if enabled
+      this.isStale && // Only fetch if stale
       !(this.config.suspense && this.currentResult.isFetched) && // Don't refetch if in suspense mode and the data is already fetched
-      this.currentResult.isStale && // Only refetch if stale
       (this.config.refetchOnMount || this.currentQuery.observers.length === 1)
     ) {
       this.fetch()
@@ -148,7 +150,7 @@ export class QueryObserver<TResult, TError> {
 
     this.clearStaleTimeout()
 
-    if (this.currentResult.isStale || !isValidTimeout(this.config.staleTime)) {
+    if (this.isStale || !isValidTimeout(this.config.staleTime)) {
       return
     }
 
@@ -157,8 +159,9 @@ export class QueryObserver<TResult, TError> {
     const timeout = Math.max(timeUntilStale, 0)
 
     this.staleTimeoutId = setTimeout(() => {
-      if (!this.currentResult.isStale) {
-        this.currentResult = { ...this.currentResult, isStale: true }
+      if (!this.isStale) {
+        this.isStale = true
+        this.updateResult()
         this.notify()
         this.config.queryCache.notifyGlobalListeners(this.currentQuery)
       }
@@ -208,7 +211,7 @@ export class QueryObserver<TResult, TError> {
   }
 
   private updateResult(): void {
-    const { currentQuery, currentResult, previousQueryResult, config } = this
+    const { currentQuery, previousQueryResult, config } = this
     const { state } = currentQuery
     let { data, status, updatedAt } = state
     let isPreviousData = false
@@ -216,9 +219,8 @@ export class QueryObserver<TResult, TError> {
     // Keep previous data if needed
     if (
       config.keepPreviousData &&
-      (state.status === QueryStatus.Idle ||
-        state.status === QueryStatus.Loading) &&
-      previousQueryResult?.status === QueryStatus.Success
+      state.isInitialData &&
+      previousQueryResult?.isSuccess
     ) {
       data = previousQueryResult.data
       updatedAt = previousQueryResult.updatedAt
@@ -226,22 +228,6 @@ export class QueryObserver<TResult, TError> {
       isPreviousData = true
     }
 
-    let isStale
-
-    // 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 && state.isInitialData) {
-      if (typeof config.initialStale === 'function') {
-        isStale = config.initialStale()
-      } else if (typeof config.initialStale === 'boolean') {
-        isStale = config.initialStale
-      } else {
-        isStale = typeof state.data === 'undefined'
-      }
-    } else {
-      isStale = currentQuery.isStaleByTime(config.staleTime)
-    }
-
     this.currentResult = {
       ...getStatusProps(status),
       canFetchMore: state.canFetchMore,
@@ -256,22 +242,16 @@ export class QueryObserver<TResult, TError> {
       isFetchingMore: state.isFetchingMore,
       isInitialData: state.isInitialData,
       isPreviousData,
-      isStale,
+      isStale: this.isStale,
       refetch: this.refetch,
       updatedAt,
     }
   }
 
   private updateQuery(): void {
+    const config = this.config
     const prevQuery = this.currentQuery
 
-    // Remove the initial data when there is an existing query
-    // because this data should not be used for a new query
-    const config =
-      this.config.keepPreviousData && prevQuery
-        ? { ...this.config, initialData: undefined }
-        : this.config
-
     let query = config.queryCache.getQueryByHash<TResult, TError>(
       config.queryHash
     )
@@ -287,6 +267,22 @@ export class QueryObserver<TResult, TError> {
     this.previousQueryResult = this.currentResult
     this.currentQuery = query
     this.initialUpdateCount = query.state.updateCount
+
+    // Update stale state on query switch
+    if (query.state.isInitialData) {
+      if (config.keepPreviousData && prevQuery) {
+        this.isStale = true
+      } else if (typeof config.initialStale === 'function') {
+        this.isStale = config.initialStale()
+      } else if (typeof config.initialStale === 'boolean') {
+        this.isStale = config.initialStale
+      } else {
+        this.isStale = typeof query.state.data === 'undefined'
+      }
+    } else {
+      this.isStale = query.isStaleByTime(config.staleTime)
+    }
+
     this.updateResult()
 
     if (this.listener) {
@@ -296,16 +292,20 @@ export class QueryObserver<TResult, TError> {
   }
 
   onQueryUpdate(action: Action<TResult, TError>): void {
+    const { config } = this
     const { type } = action
 
+    // Update stale state on success or error
+    if (type === 2 || type === 3) {
+      this.isStale = this.currentQuery.isStaleByTime(config.staleTime)
+    }
+
     // Store current result and get new result
     const prevResult = this.currentResult
     this.updateResult()
+    const currentResult = this.currentResult
 
-    const { currentResult, config } = this
-
-    // We need to check the action because the state could have
-    // transitioned from success to success in case of `setQueryData`.
+    // Trigger callbacks and timers on success or error
     if (type === 2) {
       config.onSuccess?.(currentResult.data!)
       config.onSettled?.(currentResult.data!, null)


Test patch:
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index 15dcc1d2a..323839962 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -1198,12 +1198,11 @@ describe('useQuery', () => {
 
     render(<Page />)
 
-    await waitFor(() => expect(states.length).toBe(2))
+    await waitForMs(10)
 
-    expect(states).toMatchObject([
-      { data: 'initial', isStale: false },
-      { data: 'initial', isStale: true },
-    ])
+    expect(states.length).toBe(2)
+    expect(states[0]).toMatchObject({ data: 'initial', isStale: false })
+    expect(states[1]).toMatchObject({ data: 'initial', isStale: true })
   })
 
   it('should fetch if initial data is set and initial stale is set to true', async () => {
@@ -1230,6 +1229,47 @@ describe('useQuery', () => {
     ])
   })
 
+  it('should fetch if initial data is set and initial stale is set to true with stale time', async () => {
+    const key = queryKey()
+    const states: QueryResult<string>[] = []
+
+    function Page() {
+      const state = useQuery(key, () => 'data', {
+        staleTime: 50,
+        initialData: 'initial',
+        initialStale: true,
+      })
+      states.push(state)
+      return null
+    }
+
+    render(<Page />)
+
+    await waitForMs(100)
+
+    expect(states.length).toBe(4)
+    expect(states[0]).toMatchObject({
+      data: 'initial',
+      isStale: true,
+      isFetching: false,
+    })
+    expect(states[1]).toMatchObject({
+      data: 'initial',
+      isStale: true,
+      isFetching: true,
+    })
+    expect(states[2]).toMatchObject({
+      data: 'data',
+      isStale: false,
+      isFetching: false,
+    })
+    expect(states[3]).toMatchObject({
+      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 }>[] = []
@@ -1251,15 +1291,13 @@ describe('useQuery', () => {
 
     render(<Page />)
 
-    await waitFor(() => expect(states.length).toBe(5))
+    await waitForMs(100)
 
-    expect(states).toMatchObject([
-      { data: { count: 0 } },
-      { data: { count: 0 } },
-      { data: { count: 1 } },
-      { data: { count: 1 } },
-      { data: { count: 10 } },
-    ])
+    expect(states.length).toBe(4)
+    expect(states[0]).toMatchObject({ data: { count: 0 } })
+    expect(states[1]).toMatchObject({ data: { count: 0 } })
+    expect(states[2]).toMatchObject({ data: { count: 1 } })
+    expect(states[3]).toMatchObject({ data: { count: 1 } })
   })
 
   it('should retry specified number of times', async () => {