Repository: tannerlinsley/react-query (ts)

Issue:
invalidateQueries with refetchActive: false on inactive query refetches it
**Describe the bug**
Calling `queryClient.invalidateQueries` with `{ refetchActive: false }` on inactive query actually refetches it.

**To Reproduce**
Codesandbox: https://codesandbox.io/s/react-query-refetchactive-bug-n3kwc

With the following query cache state:
```
['queryA'] // inactive
['queryB'] // active
```

When I call 
```
// ❌ Incorrect, refetches 😵
queryClient.invalidateQueries(['queryA'], {
  refetchActive: false
});

// ✅ Correct, doesn't refetch
queryClient.invalidateQueries(['queryB'], {
  refetchActive: false
});
```

**Expected behavior**
Option `{ refetchActive: false }` on inactive query shouldn't trigger refetch

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Desktop (please complete the following information):**
 - OS: macOS
 - Browser: chrome
 - Version: 90

**Additional context**
N.A


Pull request description:
fix: update matchQuery logic to cater for all permutations
Fixes #2271

Patch:
diff --git a/src/core/queryClient.ts b/src/core/queryClient.ts
index 4bed72139..81cd2f9ea 100644
--- a/src/core/queryClient.ts
+++ b/src/core/queryClient.ts
@@ -238,8 +238,10 @@ export class QueryClient {
 
     const refetchFilters: QueryFilters = {
       ...filters,
-      active: filters.refetchActive ?? true,
-      inactive: filters.refetchInactive,
+      // if filters.refetchActive is not provided and filters.active is explicitly false,
+      // e.g. invalidateQueries({ active: false }), we don't want to refetch active queries
+      active: filters.refetchActive ?? filters.active ?? true,
+      inactive: filters.refetchInactive ?? false,
     }
 
     return notifyManager.batch(() => {
diff --git a/src/core/utils.ts b/src/core/utils.ts
index 35b1a85db..49b310ed5 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -68,6 +68,8 @@ export type Updater<TInput, TOutput> =
   | TOutput
   | DataUpdateFunction<TInput, TOutput>
 
+export type QueryStatusFilter = 'all' | 'active' | 'inactive' | 'none'
+
 // UTILS
 
 export const isServer = typeof window === 'undefined'
@@ -164,6 +166,25 @@ export function parseFilterArgs<
     : [arg1 || {}, arg2]) as [TFilters, TOptions]
 }
 
+export function mapQueryStatusFilter(
+  active?: boolean,
+  inactive?: boolean
+): QueryStatusFilter {
+  if (
+    (active === true && inactive === true) ||
+    (active == null && inactive == null)
+  ) {
+    return 'all'
+  } else if (active === false && inactive === false) {
+    return 'none'
+  } else {
+    // At this point, active|inactive can only be true|false or false|true
+    // so, when only one value is provided, the missing one has to be the negated value
+    const isActive = active ?? !inactive
+    return isActive ? 'active' : 'inactive'
+  }
+}
+
 export function matchQuery(
   filters: QueryFilters,
   query: Query<any, any, any, any>
@@ -188,16 +209,18 @@ export function matchQuery(
     }
   }
 
-  let isActive
+  const queryStatusFilter = mapQueryStatusFilter(active, inactive)
 
-  if (inactive === false || (active && !inactive)) {
-    isActive = true
-  } else if (active === false || (inactive && !active)) {
-    isActive = false
-  }
-
-  if (typeof isActive === 'boolean' && query.isActive() !== isActive) {
+  if (queryStatusFilter === 'none') {
     return false
+  } else if (queryStatusFilter !== 'all') {
+    const isActive = query.isActive()
+    if (queryStatusFilter === 'active' && !isActive) {
+      return false
+    }
+    if (queryStatusFilter === 'inactive' && isActive) {
+      return false
+    }
   }
 
   if (typeof stale === 'boolean' && query.isStale() !== stale) {


Test patch:
diff --git a/src/core/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx
index 0a62a0148..86adb2c61 100644
--- a/src/core/tests/queryClient.test.tsx
+++ b/src/core/tests/queryClient.test.tsx
@@ -649,9 +649,85 @@ describe('queryClient', () => {
       expect(queryFn1).toHaveBeenCalledTimes(2)
       expect(queryFn2).toHaveBeenCalledTimes(2)
     })
+
+    test('should be able to refetch only active queries', async () => {
+      const key1 = queryKey()
+      const key2 = queryKey()
+      const queryFn1 = jest.fn()
+      const queryFn2 = jest.fn()
+      await queryClient.fetchQuery(key1, queryFn1)
+      await queryClient.fetchQuery(key2, queryFn2)
+      const observer = new QueryObserver(queryClient, {
+        queryKey: key1,
+        queryFn: queryFn1,
+        staleTime: Infinity,
+      })
+      const unsubscribe = observer.subscribe()
+      await queryClient.refetchQueries({ active: true })
+      unsubscribe()
+      expect(queryFn1).toHaveBeenCalledTimes(2)
+      expect(queryFn2).toHaveBeenCalledTimes(1)
+    })
+
+    test('should be able to refetch only inactive queries', async () => {
+      const key1 = queryKey()
+      const key2 = queryKey()
+      const queryFn1 = jest.fn()
+      const queryFn2 = jest.fn()
+      await queryClient.fetchQuery(key1, queryFn1)
+      await queryClient.fetchQuery(key2, queryFn2)
+      const observer = new QueryObserver(queryClient, {
+        queryKey: key1,
+        queryFn: queryFn1,
+        staleTime: Infinity,
+      })
+      const unsubscribe = observer.subscribe()
+      await queryClient.refetchQueries({ inactive: true })
+      unsubscribe()
+      expect(queryFn1).toHaveBeenCalledTimes(1)
+      expect(queryFn2).toHaveBeenCalledTimes(2)
+    })
+
+    test('should skip refetch for all active and inactive queries', async () => {
+      const key1 = queryKey()
+      const key2 = queryKey()
+      const queryFn1 = jest.fn()
+      const queryFn2 = jest.fn()
+      await queryClient.fetchQuery(key1, queryFn1)
+      await queryClient.fetchQuery(key2, queryFn2)
+      const observer = new QueryObserver(queryClient, {
+        queryKey: key1,
+        queryFn: queryFn1,
+        staleTime: Infinity,
+      })
+      const unsubscribe = observer.subscribe()
+      await queryClient.refetchQueries({ active: false, inactive: false })
+      unsubscribe()
+      expect(queryFn1).toHaveBeenCalledTimes(1)
+      expect(queryFn2).toHaveBeenCalledTimes(1)
+    })
   })
 
   describe('invalidateQueries', () => {
+    test('should refetch active queries by default', async () => {
+      const key1 = queryKey()
+      const key2 = queryKey()
+      const queryFn1 = jest.fn()
+      const queryFn2 = jest.fn()
+      await queryClient.fetchQuery(key1, queryFn1)
+      await queryClient.fetchQuery(key2, queryFn2)
+      const observer = new QueryObserver(queryClient, {
+        queryKey: key1,
+        queryFn: queryFn1,
+        staleTime: Infinity,
+      })
+      const unsubscribe = observer.subscribe()
+      queryClient.invalidateQueries(key1)
+      unsubscribe()
+      expect(queryFn1).toHaveBeenCalledTimes(2)
+      expect(queryFn2).toHaveBeenCalledTimes(1)
+    })
+
     test('should not refetch inactive queries by default', async () => {
       const key1 = queryKey()
       const key2 = queryKey()
@@ -673,10 +749,14 @@ describe('queryClient', () => {
 
     test('should not refetch active queries when "refetchActive" is false', async () => {
       const key1 = queryKey()
+      const key2 = queryKey()
       const queryFn1 = jest.fn()
+      const queryFn2 = jest.fn()
       await queryClient.fetchQuery(key1, queryFn1)
+      await queryClient.fetchQuery(key2, queryFn2)
       const observer = new QueryObserver(queryClient, {
         queryKey: key1,
+        queryFn: queryFn1,
         staleTime: Infinity,
       })
       const unsubscribe = observer.subscribe()
@@ -685,6 +765,50 @@ describe('queryClient', () => {
       })
       unsubscribe()
       expect(queryFn1).toHaveBeenCalledTimes(1)
+      expect(queryFn2).toHaveBeenCalledTimes(1)
+    })
+
+    test('should refetch inactive queries when "refetchInactive" is true', async () => {
+      const key1 = queryKey()
+      const key2 = queryKey()
+      const queryFn1 = jest.fn()
+      const queryFn2 = jest.fn()
+      await queryClient.fetchQuery(key1, queryFn1)
+      await queryClient.fetchQuery(key2, queryFn2)
+      const observer = new QueryObserver(queryClient, {
+        queryKey: key1,
+        queryFn: queryFn1,
+        staleTime: Infinity,
+        enabled: false,
+      })
+      const unsubscribe = observer.subscribe()
+      queryClient.invalidateQueries(key1, {
+        refetchInactive: true,
+      })
+      unsubscribe()
+      expect(queryFn1).toHaveBeenCalledTimes(2)
+      expect(queryFn2).toHaveBeenCalledTimes(1)
+    })
+
+    test('should not refetch active queries when "refetchActive" is not provided and "active" is false', async () => {
+      const key1 = queryKey()
+      const key2 = queryKey()
+      const queryFn1 = jest.fn()
+      const queryFn2 = jest.fn()
+      await queryClient.fetchQuery(key1, queryFn1)
+      await queryClient.fetchQuery(key2, queryFn2)
+      const observer = new QueryObserver(queryClient, {
+        queryKey: key1,
+        queryFn: queryFn1,
+        staleTime: Infinity,
+      })
+      const unsubscribe = observer.subscribe()
+      queryClient.invalidateQueries(key1, {
+        active: false,
+      })
+      unsubscribe()
+      expect(queryFn1).toHaveBeenCalledTimes(1)
+      expect(queryFn2).toHaveBeenCalledTimes(1)
     })
   })
 
diff --git a/src/core/tests/utils.test.tsx b/src/core/tests/utils.test.tsx
index 189f339ea..cacc8f634 100644
--- a/src/core/tests/utils.test.tsx
+++ b/src/core/tests/utils.test.tsx
@@ -1,4 +1,9 @@
-import { replaceEqualDeep, partialDeepEqual, isPlainObject } from '../utils'
+import {
+  replaceEqualDeep,
+  partialDeepEqual,
+  isPlainObject,
+  mapQueryStatusFilter,
+} from '../utils'
 import { QueryClient, QueryCache, setLogger, Logger } from '../..'
 import { queryKey } from '../../react/tests/utils'
 
@@ -302,4 +307,24 @@ describe('core/utils', () => {
       expect(result.todos[1]).toBe(prev.todos[1])
     })
   })
+
+  describe('mapQueryStatusFilter', () => {
+    it.each`
+      active       | inactive     | statusFilter
+      ${true}      | ${true}      | ${'all'}
+      ${undefined} | ${undefined} | ${'all'}
+      ${false}     | ${false}     | ${'none'}
+      ${true}      | ${false}     | ${'active'}
+      ${true}      | ${undefined} | ${'active'}
+      ${undefined} | ${false}     | ${'active'}
+      ${false}     | ${true}      | ${'inactive'}
+      ${undefined} | ${true}      | ${'inactive'}
+      ${false}     | ${undefined} | ${'inactive'}
+    `(
+      'returns "$statusFilter" when active is $active, and inactive is $inactive',
+      ({ active, inactive, statusFilter }) => {
+        expect(mapQueryStatusFilter(active, inactive)).toBe(statusFilter)
+      }
+    )
+  })
 })