Repository: tannerlinsley/react-query
Pull request #1006 (2020-09-13 09:28:39)

fix: should be able to invalidate queries

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


tannerlinsley: Looks good!

Patch:
diff --git a/src/core/query.ts b/src/core/query.ts
index a5f255198..87191f446 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -34,6 +34,7 @@ export interface QueryState<TResult, TError> {
   isFetching: boolean
   isFetchingMore: IsFetchingMoreValue
   isInitialData: boolean
+  isInvalidated: boolean
   status: QueryStatus
   throwInErrorBoundary?: boolean
   updateCount: number
@@ -58,6 +59,7 @@ const enum ActionType {
   Fetch,
   Success,
   Error,
+  Invalidate,
 }
 
 interface SetDataOptions {
@@ -85,10 +87,15 @@ interface ErrorAction<TError> {
   error: TError
 }
 
+interface InvalidateAction {
+  type: ActionType.Invalidate
+}
+
 export type Action<TResult, TError> =
   | ErrorAction<TError>
   | FailedAction
   | FetchAction
+  | InvalidateAction
   | SuccessAction<TResult>
 
 // CLASS
@@ -226,16 +233,21 @@ export class Query<TResult, TError> {
     this.cancel()
   }
 
-  isEnabled(): boolean {
+  isActive(): boolean {
     return this.observers.some(observer => observer.config.enabled)
   }
 
   isStale(): boolean {
-    return this.observers.some(observer => observer.getCurrentResult().isStale)
+    return (
+      this.state.isInvalidated ||
+      this.state.status !== QueryStatus.Success ||
+      this.observers.some(observer => observer.getCurrentResult().isStale)
+    )
   }
 
   isStaleByTime(staleTime = 0): boolean {
     return (
+      this.state.isInvalidated ||
       this.state.status !== QueryStatus.Success ||
       this.state.updatedAt + staleTime <= Date.now()
     )
@@ -295,6 +307,12 @@ export class Query<TResult, TError> {
     }
   }
 
+  invalidate(): void {
+    if (!this.state.isInvalidated) {
+      this.dispatch({ type: ActionType.Invalidate })
+    }
+  }
+
   async refetch(
     options?: RefetchOptions,
     config?: ResolvedQueryConfig<TResult, TError>
@@ -610,6 +628,7 @@ function getDefaultState<TResult, TError>(
     isFetching: status === QueryStatus.Loading,
     isFetchingMore: false,
     isInitialData: true,
+    isInvalidated: false,
     status,
     updateCount: 0,
     updatedAt: Date.now(),
@@ -647,6 +666,7 @@ export function queryReducer<TResult, TError>(
         isFetching: false,
         isFetchingMore: false,
         isInitialData: false,
+        isInvalidated: false,
         status: QueryStatus.Success,
         updateCount: state.updateCount + 1,
         updatedAt: action.updatedAt ?? Date.now(),
@@ -662,6 +682,11 @@ export function queryReducer<TResult, TError>(
         throwInErrorBoundary: true,
         updateCount: state.updateCount + 1,
       }
+    case ActionType.Invalidate:
+      return {
+        ...state,
+        isInvalidated: true,
+      }
     default:
       return state
   }
diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts
index b93341df6..8853efc91 100644
--- a/src/core/queryCache.ts
+++ b/src/core/queryCache.ts
@@ -3,8 +3,8 @@ import {
   deepIncludes,
   getQueryArgs,
   isDocumentVisible,
-  isPlainObject,
   isOnline,
+  isPlainObject,
   isServer,
 } from './utils'
 import { getResolvedQueryConfig } from './config'
@@ -35,14 +35,19 @@ interface PrefetchQueryOptions {
   throwOnError?: boolean
 }
 
-interface InvalidateQueriesOptions extends QueryPredicateOptions {
+interface RefetchQueriesOptions extends QueryPredicateOptions {
+  throwOnError?: boolean
+}
+
+interface InvalidateQueriesOptions extends RefetchQueriesOptions {
   refetchActive?: boolean
   refetchInactive?: boolean
-  throwOnError?: boolean
 }
 
 interface QueryPredicateOptions {
+  active?: boolean
   exact?: boolean
+  stale?: boolean
 }
 
 type QueryPredicate = QueryKey | QueryPredicateFn | true
@@ -123,7 +128,7 @@ export class QueryCache {
     predicate?: QueryPredicate,
     options?: QueryPredicateOptions
   ): Query<TResult, TError>[] {
-    if (predicate === true || typeof predicate === 'undefined') {
+    if (!options && (predicate === true || typeof predicate === 'undefined')) {
       return this.queriesArray
     }
 
@@ -134,10 +139,19 @@ export class QueryCache {
     } else {
       const resolvedConfig = this.getResolvedQueryConfig(predicate)
 
-      predicateFn = d =>
-        options?.exact
-          ? d.queryHash === resolvedConfig.queryHash
-          : deepIncludes(d.queryKey, resolvedConfig.queryKey)
+      predicateFn = query => {
+        if (
+          options &&
+          ((options.exact && query.queryHash !== resolvedConfig.queryHash) ||
+            (typeof options.active === 'boolean' &&
+              query.isActive() !== options.active) ||
+            (typeof options.stale === 'boolean' &&
+              query.isStale() !== options.stale))
+        ) {
+          return false
+        }
+        return deepIncludes(query.queryKey, resolvedConfig.queryKey)
+      }
     }
 
     return this.queriesArray.filter(predicateFn)
@@ -186,30 +200,62 @@ export class QueryCache {
     })
   }
 
-  async invalidateQueries(
+  /**
+   * @return Promise resolving to an array with the invalidated queries.
+   */
+  invalidateQueries(
     predicate?: QueryPredicate,
     options?: InvalidateQueriesOptions
-  ): Promise<void> {
-    const { refetchActive = true, refetchInactive = false, throwOnError } =
-      options || {}
+  ): Promise<Query<unknown, unknown>[]> {
+    const queries = this.getQueries(predicate, options)
 
-    try {
-      await Promise.all(
-        this.getQueries(predicate, options).map(query => {
-          const enabled = query.isEnabled()
-
-          if ((enabled && refetchActive) || (!enabled && refetchInactive)) {
-            return query.fetch()
-          }
-
-          return undefined
-        })
-      )
-    } catch (err) {
-      if (throwOnError) {
-        throw err
-      }
+    queries.forEach(query => {
+      query.invalidate()
+    })
+
+    const { refetchActive = true, refetchInactive = false } = options || {}
+
+    if (!refetchInactive && !refetchActive) {
+      return Promise.resolve(queries)
+    }
+
+    const refetchOptions: RefetchQueriesOptions = { ...options }
+
+    if (refetchActive && !refetchInactive) {
+      refetchOptions.active = true
+    } else if (refetchInactive && !refetchActive) {
+      refetchOptions.active = false
     }
+
+    let promise = this.refetchQueries(predicate, refetchOptions)
+
+    if (!options?.throwOnError) {
+      promise = promise.catch(() => queries)
+    }
+
+    return promise.then(() => queries)
+  }
+
+  /**
+   * @return Promise resolving to an array with the refetched queries.
+   */
+  refetchQueries(
+    predicate?: QueryPredicate,
+    options?: RefetchQueriesOptions
+  ): Promise<Query<unknown, unknown>[]> {
+    const promises: Promise<Query<unknown, unknown>>[] = []
+
+    this.getQueries(predicate, options).forEach(query => {
+      let promise = query.fetch().then(() => query)
+
+      if (!options?.throwOnError) {
+        promise = promise.catch(() => query)
+      }
+
+      promises.push(promise)
+    })
+
+    return Promise.all(promises)
   }
 
   resetErrorBoundaries(): void {
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index b48d8d48f..e88b650b7 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -306,8 +306,8 @@ export class QueryObserver<TResult, TError> {
     const { config } = this
     const { type } = action
 
-    // Update stale state on success or error
-    if (type === 2 || type === 3) {
+    // Update stale state on success, error or invalidation
+    if (type === 2 || type === 3 || type === 4) {
       this.isStale = this.currentQuery.isStaleByTime(config.staleTime)
     }
 
@@ -316,15 +316,23 @@ export class QueryObserver<TResult, TError> {
     this.updateResult()
     const currentResult = this.currentResult
 
-    // Trigger callbacks and timers on success or error
+    // Update timers on success, error or invalidation
+    if (type === 2 || type === 3 || type === 4) {
+      this.updateTimers()
+    }
+
+    // Trigger callbacks on success or error
     if (type === 2) {
       config.onSuccess?.(currentResult.data!)
       config.onSettled?.(currentResult.data!, null)
-      this.updateTimers()
     } else if (type === 3) {
       config.onError?.(currentResult.error!)
       config.onSettled?.(undefined, currentResult.error!)
-      this.updateTimers()
+    }
+
+    // Do not notify if the query was invalidated but the stale state did not changed
+    if (type === 4 && currentResult.isStale === prevResult.isStale) {
+      return
     }
 
     if (


Test patch:
diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx
index 61b551c1e..e0837cfc8 100644
--- a/src/core/tests/queryCache.test.tsx
+++ b/src/core/tests/queryCache.test.tsx
@@ -257,6 +257,37 @@ describe('queryCache', () => {
     expect(data).toEqual(['data1', 'data2'])
   })
 
+  test('getQueries should filter correctly', async () => {
+    const key1 = queryKey()
+    const key2 = queryKey()
+    const cache = defaultQueryCache
+
+    await cache.prefetchQuery(key1, () => 'data1')
+    await cache.prefetchQuery(key2, () => 'data2')
+    await cache.invalidateQueries(key2)
+    const query1 = cache.getQuery(key1)!
+    const query2 = cache.getQuery(key2)!
+
+    expect(cache.getQueries(key1)).toEqual([query1])
+    expect(cache.getQueries(key1, {})).toEqual([query1])
+    expect(cache.getQueries(key1, { active: false })).toEqual([query1])
+    expect(cache.getQueries(key1, { active: true })).toEqual([])
+    expect(cache.getQueries(key1, { stale: true })).toEqual([])
+    expect(cache.getQueries(key1, { stale: false })).toEqual([query1])
+    expect(cache.getQueries(key1, { stale: false, active: true })).toEqual([])
+    expect(cache.getQueries(key1, { stale: false, active: false })).toEqual([
+      query1,
+    ])
+    expect(
+      cache.getQueries(key1, { stale: false, active: false, exact: true })
+    ).toEqual([query1])
+
+    expect(cache.getQueries(key2)).toEqual([query2])
+    expect(cache.getQueries(key2, { stale: undefined })).toEqual([query2])
+    expect(cache.getQueries(key2, { stale: true })).toEqual([query2])
+    expect(cache.getQueries(key2, { stale: false })).toEqual([])
+  })
+
   test('query interval is cleared when unsubscribed to a refetchInterval query', async () => {
     const key = queryKey()
 
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index 17179d6f8..2a1f6b1a7 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -487,6 +487,52 @@ describe('useQuery', () => {
     return null
   })
 
+  it('should update query stale state when invalidated with invalidateQueries', async () => {
+    const key = queryKey()
+    const states: QueryResult<string>[] = []
+
+    function Page() {
+      const state = useQuery(key, () => 'data', { staleTime: Infinity })
+
+      states.push(state)
+
+      React.useEffect(() => {
+        setTimeout(() => {
+          queryCache.invalidateQueries(key, {
+            refetchActive: false,
+            refetchInactive: false,
+          })
+        }, 10)
+      }, [])
+
+      return null
+    }
+
+    render(<Page />)
+
+    await waitForMs(100)
+
+    expect(states.length).toBe(3)
+    expect(states[0]).toMatchObject({
+      data: undefined,
+      isFetching: true,
+      isSuccess: false,
+      isStale: true,
+    })
+    expect(states[1]).toMatchObject({
+      data: 'data',
+      isFetching: false,
+      isSuccess: true,
+      isStale: false,
+    })
+    expect(states[2]).toMatchObject({
+      data: 'data',
+      isFetching: false,
+      isSuccess: true,
+      isStale: true,
+    })
+  })
+
   it('should update disabled query when updated with invalidateQueries', async () => {
     const key = queryKey()
     const states: QueryResult<number>[] = []
@@ -516,25 +562,27 @@ describe('useQuery', () => {
 
     render(<Page />)
 
-    await waitFor(() => expect(states.length).toBe(3))
+    await waitForMs(100)
 
-    expect(states).toMatchObject([
-      {
-        data: undefined,
-        isFetching: false,
-        isSuccess: false,
-      },
-      {
-        data: undefined,
-        isFetching: true,
-        isSuccess: false,
-      },
-      {
-        data: 1,
-        isFetching: false,
-        isSuccess: true,
-      },
-    ])
+    expect(states.length).toBe(3)
+    expect(states[0]).toMatchObject({
+      data: undefined,
+      isFetching: false,
+      isSuccess: false,
+      isStale: true,
+    })
+    expect(states[1]).toMatchObject({
+      data: undefined,
+      isFetching: true,
+      isSuccess: false,
+      isStale: true,
+    })
+    expect(states[2]).toMatchObject({
+      data: 1,
+      isFetching: false,
+      isSuccess: true,
+      isStale: true,
+    })
   })
 
   it('should keep the previous data when keepPreviousData is set', async () => {
@@ -910,13 +958,11 @@ describe('useQuery', () => {
 
     render(<Page />)
 
-    await waitFor(() =>
-      expect(states).toMatchObject([
-        { isStale: true },
-        { isStale: false },
-        { isStale: true },
-      ])
-    )
+    await waitForMs(100)
+
+    expect(states[0]).toMatchObject({ isStale: true })
+    expect(states[1]).toMatchObject({ isStale: false })
+    expect(states[2]).toMatchObject({ isStale: true })
   })
 
   it('should notify query cache when a query becomes stale', async () => {