Repository: tannerlinsley/react-query
Pull request #1058 (2020-09-18 18:24:25)

feat: add QueryCache.watchQuery

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



Patch:
diff --git a/docs/src/pages/docs/comparison.md b/docs/src/pages/docs/comparison.md
index a6f89f4f8..8ca87dab1 100644
--- a/docs/src/pages/docs/comparison.md
+++ b/docs/src/pages/docs/comparison.md
@@ -42,9 +42,10 @@ Feature/Capability Key:
 | Stale Time Configuration                     | ✅                                     | 🛑                         | 🛑                                    |
 | Window Focus Refetching                      | ✅                                     | ✅                         | 🛑                                    |
 | Network Status Refetching                    | ✅                                     | ✅                         | ✅                                    |
-| Automatic Refetch after Mutation<sup>4</sup> | 🔶                                     | 🔶                         | ✅                                    |
 | Cache Dehydration/Rehydration                | ✅                                     | 🛑                         | ✅                                    |
 | React Suspense (Experimental)                | ✅                                     | ✅                         | 🛑                                    |
+| Automatic Refetch after Mutation<sup>4</sup> | 🔶                                     | 🔶                         | ✅                                    |
+| Normalized Caching<sup>5</sup>               | 🛑                                     | 🛑                         | ✅                                    |
 
 ### Notes
 
@@ -56,6 +57,8 @@ Feature/Capability Key:
 
 > **<sup>4</sup> Automatic Refetch after Mutation** - For truly automatic refetching to happen after a mutation occurs, a schema is necessary (like the one graphQL provides) along with heuristics that help the library know how to identify individual entities and entities types in that schema.
 
+> **<sup>5</sup> Normalized Caching** - React Query and SWR do not currently support automatic-normalized caching which describes storing entities in a flat architecture to avoid some high-level data duplication.
+
 [swr]: https://github.com/vercel/swr
 [apollo]: https://github.com/apollographql/apollo-client
 [bp-react-query]: https://badgen.net/bundlephobia/minzip/react-query?label=%20
diff --git a/docs/src/pages/index.js b/docs/src/pages/index.js
index 6b8add22c..d9a6c2ebd 100644
--- a/docs/src/pages/index.js
+++ b/docs/src/pages/index.js
@@ -13,6 +13,7 @@ const supporters = [
   ['Kent C. Dodds (kentcdodds.com)', 'https://kentcdodds.com/'],
   ['@bgazzera', 'https://github.com/bgazzera'],
   ['Gabe Ragland', 'https://github.com/gragland'],
+  ['Matt Wood', 'https://github.com/mattwoodnyc'],
 ]
 
 const fans = [
@@ -25,6 +26,11 @@ const fans = [
   'Christian Rudh (@crudh)',
   'Vianney Carel (@vcarel)',
   'Freddie (@fdjones)',
+  'Johnny Bell (@johnnyxbell)',
+  'Garrett Fritz (@gfritzdev)',
+  'Juliano Farias (@frontendwizard)',
+  'Kristóf Poduszló (@kripod)',
+  'Peter Pistorius (@peterp)',
 ]
 
 const Home = () => {
diff --git a/src/core/config.ts b/src/core/config.ts
index 9780685bd..182b164f7 100644
--- a/src/core/config.ts
+++ b/src/core/config.ts
@@ -119,6 +119,12 @@ export function getResolvedQueryConfig<TResult, TError>(
   return resolvedConfig
 }
 
+export function isResolvedQueryConfig<TResult, TError>(
+  config: any
+): config is ResolvedQueryConfig<TResult, TError> {
+  return Boolean(config.queryHash)
+}
+
 export function getResolvedMutationConfig<
   TResult,
   TError,
diff --git a/src/core/query.ts b/src/core/query.ts
index 0e5173e56..3b461cf7d 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -284,6 +284,9 @@ export class Query<TResult, TError> {
     this.continue()
   }
 
+  /**
+   * @deprectated
+   */
   subscribe(
     listener?: UpdateListener<TResult, TError>
   ): QueryObserver<TResult, TError> {
@@ -319,6 +322,9 @@ export class Query<TResult, TError> {
     }
   }
 
+  /**
+   * @deprectated
+   */
   refetch(
     options?: RefetchOptions,
     config?: ResolvedQueryConfig<TResult, TError>
@@ -332,6 +338,9 @@ export class Query<TResult, TError> {
     return promise
   }
 
+  /**
+   * @deprectated
+   */
   fetchMore(
     fetchMoreVariable?: unknown,
     options?: FetchMoreOptions,
diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts
index b3be3f9e5..9e553d3e5 100644
--- a/src/core/queryCache.ts
+++ b/src/core/queryCache.ts
@@ -20,6 +20,7 @@ import {
   ResolvedQueryConfig,
 } from './types'
 import { notifyManager } from './notifyManager'
+import { QueryObserver } from './queryObserver'
 
 // TYPES
 
@@ -455,6 +456,36 @@ export class QueryCache {
     return promise
   }
 
+  // Parameter syntax
+  watchQuery<TResult = unknown, TError = unknown>(
+    queryKey: QueryKey,
+    queryConfig?: QueryConfig<TResult, TError>
+  ): QueryObserver<TResult, TError>
+
+  // Parameter syntax with query function
+  watchQuery<TResult, TError, TArgs extends TypedQueryFunctionArgs>(
+    queryKey: QueryKey,
+    queryFn: TypedQueryFunction<TResult, TArgs>,
+    queryConfig?: QueryConfig<TResult, TError>
+  ): QueryObserver<TResult, TError>
+
+  watchQuery<TResult = unknown, TError = unknown>(
+    queryKey: QueryKey,
+    queryFn: QueryFunction<TResult>,
+    queryConfig?: QueryConfig<TResult, TError>
+  ): QueryObserver<TResult, TError>
+
+  // Implementation
+  watchQuery<TResult, TError>(
+    arg1: any,
+    arg2?: any,
+    arg3?: any
+  ): QueryObserver<TResult, TError> {
+    const [queryKey, config] = getQueryArgs<TResult, TError>(arg1, arg2, arg3)
+    const resolvedConfig = this.getResolvedQueryConfig(queryKey, config)
+    return new QueryObserver(resolvedConfig)
+  }
+
   setQueryData<TResult, TError = unknown>(
     queryKey: QueryKey,
     updater: Updater<TResult | undefined, TResult>,
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index 0fb8333cc..775111521 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -6,8 +6,9 @@ import {
   noop,
 } from './utils'
 import { notifyManager } from './notifyManager'
-import type { QueryResult, ResolvedQueryConfig } from './types'
+import type { QueryConfig, QueryResult, ResolvedQueryConfig } from './types'
 import type { Query, Action, FetchMoreOptions, RefetchOptions } from './query'
+import { DEFAULT_CONFIG, isResolvedQueryConfig } from './config'
 
 export type UpdateListener<TResult, TError> = (
   result: QueryResult<TResult, TError>
@@ -41,13 +42,14 @@ export class QueryObserver<TResult, TError> {
     this.remove = this.remove.bind(this)
     this.refetch = this.refetch.bind(this)
     this.fetchMore = this.fetchMore.bind(this)
+    this.unsubscribe = this.unsubscribe.bind(this)
 
     // Subscribe to the query
     this.updateQuery()
   }
 
   subscribe(listener?: UpdateListener<TResult, TError>): () => void {
-    this.listener = listener
+    this.listener = listener || noop
     this.currentQuery.subscribeObserver(this)
 
     if (
@@ -60,7 +62,8 @@ export class QueryObserver<TResult, TError> {
     }
 
     this.updateTimers()
-    return this.unsubscribe.bind(this)
+
+    return this.unsubscribe
   }
 
   unsubscribe(): void {
@@ -69,11 +72,19 @@ export class QueryObserver<TResult, TError> {
     this.currentQuery.unsubscribeObserver(this)
   }
 
-  updateConfig(config: ResolvedQueryConfig<TResult, TError>): void {
+  updateConfig(
+    config: QueryConfig<TResult, TError> | ResolvedQueryConfig<TResult, TError>
+  ): void {
     const prevConfig = this.config
     const prevQuery = this.currentQuery
 
-    this.config = config
+    this.config = isResolvedQueryConfig(config)
+      ? config
+      : this.config.queryCache.getResolvedQueryConfig(
+          this.config.queryKey,
+          config
+        )
+
     this.updateQuery()
 
     // Take no further actions if there is no subscriber
@@ -143,6 +154,11 @@ export class QueryObserver<TResult, TError> {
   }
 
   fetch(): Promise<TResult | undefined> {
+    // Never try to fetch if no query function has been set
+    if (this.config.queryFn === DEFAULT_CONFIG.queries?.queryFn) {
+      return Promise.resolve(this.currentResult.data)
+    }
+
     return this.currentQuery.fetch(undefined, this.config).catch(noop)
   }
 
@@ -157,50 +173,6 @@ export class QueryObserver<TResult, TError> {
     }
   }
 
-  private notify(options: NotifyOptions): void {
-    const { config, currentResult, currentQuery, listener } = this
-    const { onSuccess, onSettled, onError } = config
-
-    notifyManager.batch(() => {
-      // First trigger the configuration callbacks
-      if (options.onSuccess) {
-        if (onSuccess) {
-          notifyManager.schedule(() => {
-            onSuccess(currentResult.data!)
-          })
-        }
-        if (onSettled) {
-          notifyManager.schedule(() => {
-            onSettled(currentResult.data!, null)
-          })
-        }
-      } else if (options.onError) {
-        if (onError) {
-          notifyManager.schedule(() => {
-            onError(currentResult.error!)
-          })
-        }
-        if (onSettled) {
-          notifyManager.schedule(() => {
-            onSettled(undefined, currentResult.error!)
-          })
-        }
-      }
-
-      // Then trigger the listener
-      if (options.listener && listener) {
-        notifyManager.schedule(() => {
-          listener(currentResult)
-        })
-      }
-
-      // Then the global listeners
-      if (options.globalListeners) {
-        config.queryCache.notifyGlobalListeners(currentQuery)
-      }
-    })
-  }
-
   private updateStaleTimeout(): void {
     if (isServer) {
       return
@@ -393,4 +365,48 @@ export class QueryObserver<TResult, TError> {
 
     this.notify(notifyOptions)
   }
+
+  private notify(options: NotifyOptions): void {
+    const { config, currentResult, currentQuery, listener } = this
+    const { onSuccess, onSettled, onError } = config
+
+    notifyManager.batch(() => {
+      // First trigger the configuration callbacks
+      if (options.onSuccess) {
+        if (onSuccess) {
+          notifyManager.schedule(() => {
+            onSuccess(currentResult.data!)
+          })
+        }
+        if (onSettled) {
+          notifyManager.schedule(() => {
+            onSettled(currentResult.data!, null)
+          })
+        }
+      } else if (options.onError) {
+        if (onError) {
+          notifyManager.schedule(() => {
+            onError(currentResult.error!)
+          })
+        }
+        if (onSettled) {
+          notifyManager.schedule(() => {
+            onSettled(undefined, currentResult.error!)
+          })
+        }
+      }
+
+      // Then trigger the listener
+      if (options.listener && listener) {
+        notifyManager.schedule(() => {
+          listener(currentResult)
+        })
+      }
+
+      // Then the global listeners
+      if (options.globalListeners) {
+        config.queryCache.notifyGlobalListeners(currentQuery)
+      }
+    })
+  }
 }


Test patch:
diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx
index 76de2d128..32b91e674 100644
--- a/src/core/tests/queryCache.test.tsx
+++ b/src/core/tests/queryCache.test.tsx
@@ -7,7 +7,7 @@ import {
 } from '../../react/tests/utils'
 import { QueryCache, queryCache as defaultQueryCache } from '../..'
 import { isCancelledError, isError } from '../utils'
-import { QueryObserver } from '../queryObserver'
+import { QueryResult } from '../types'
 
 describe('queryCache', () => {
   test('setQueryData does not crash if query could not be found', () => {
@@ -231,6 +231,74 @@ describe('queryCache', () => {
     )
   })
 
+  test('watchQuery should trigger a fetch when subscribed', async () => {
+    const key = queryKey()
+    const queryFn = jest.fn()
+    const cache = new QueryCache()
+    const observer = cache.watchQuery(key, queryFn)
+    observer.subscribe()
+    await sleep(1)
+    observer.unsubscribe()
+    cache.clear()
+    expect(queryFn).toHaveBeenCalledTimes(1)
+  })
+
+  test('watchQuery should not trigger a fetch when subscribed and disabled', async () => {
+    const key = queryKey()
+    const queryFn = jest.fn()
+    const cache = new QueryCache()
+    const observer = cache.watchQuery(key, queryFn, { enabled: false })
+    observer.subscribe()
+    await sleep(1)
+    observer.unsubscribe()
+    cache.clear()
+    expect(queryFn).toHaveBeenCalledTimes(0)
+  })
+
+  test('watchQuery should not trigger a fetch when not subscribed', async () => {
+    const key = queryKey()
+    const queryFn = jest.fn()
+    const cache = new QueryCache()
+    cache.watchQuery(key, queryFn)
+    await sleep(1)
+    cache.clear()
+    expect(queryFn).toHaveBeenCalledTimes(0)
+  })
+
+  test('watchQuery should be able to watch a query without defining a query function', async () => {
+    const key = queryKey()
+    const queryFn = jest.fn()
+    const callback = jest.fn()
+    const cache = new QueryCache()
+    const observer = cache.watchQuery(key)
+    observer.subscribe(callback)
+    await cache.fetchQuery(key, queryFn)
+    observer.unsubscribe()
+    cache.clear()
+    expect(queryFn).toHaveBeenCalledTimes(1)
+    expect(callback).toHaveBeenCalledTimes(1)
+  })
+
+  test('watchQuery should accept unresolved query config in update function', async () => {
+    const key = queryKey()
+    const queryFn = jest.fn()
+    const cache = new QueryCache()
+    const observer = cache.watchQuery(key)
+    const results: QueryResult<unknown>[] = []
+    observer.subscribe(x => {
+      results.push(x)
+    })
+    observer.updateConfig({ staleTime: 10 })
+    await cache.fetchQuery(key, queryFn)
+    await sleep(100)
+    observer.unsubscribe()
+    cache.clear()
+    expect(queryFn).toHaveBeenCalledTimes(1)
+    expect(results.length).toBe(2)
+    expect(results[0]).toMatchObject({ isStale: false })
+    expect(results[1]).toMatchObject({ isStale: true })
+  })
+
   test('refetchQueries should refetch all queries when no arguments are given', async () => {
     const key1 = queryKey()
     const key2 = queryKey()
@@ -282,9 +350,9 @@ describe('queryCache', () => {
     const cache = new QueryCache()
     await cache.fetchQuery(key1, queryFn1)
     await cache.fetchQuery(key2, queryFn2)
-    const query1 = cache.getQuery(key1)!
-    query1.invalidate()
-    const observer = query1.subscribe()
+    await cache.invalidateQueries(key1)
+    const observer = cache.watchQuery(key1)
+    observer.subscribe()
     await cache.refetchQueries([], { active: true, stale: true })
     observer.unsubscribe()
     cache.clear()
@@ -300,15 +368,11 @@ describe('queryCache', () => {
     const cache = new QueryCache()
     await cache.fetchQuery(key1, queryFn1)
     await cache.fetchQuery(key2, queryFn2)
-    const query1 = cache.getQuery(key1)!
-    const observer = new QueryObserver({
-      ...query1.config,
-      staleTime: Infinity,
-    })
-    const unsubscribe = observer.subscribe()
+    const observer = cache.watchQuery(key1, { staleTime: Infinity })
+    observer.subscribe()
     await cache.refetchQueries([], { active: false })
     expect(queryFn1).toHaveBeenCalledTimes(1)
-    unsubscribe()
+    observer.unsubscribe()
     cache.clear()
     expect(queryFn1).toHaveBeenCalledTimes(1)
     expect(queryFn2).toHaveBeenCalledTimes(2)
@@ -322,9 +386,7 @@ describe('queryCache', () => {
     const cache = new QueryCache()
     await cache.fetchQuery(key1, queryFn1)
     await cache.fetchQuery(key2, queryFn2)
-    const query1 = cache.getQuery(key1)!
-    const observer = new QueryObserver({
-      ...query1.config,
+    const observer = cache.watchQuery(key1, {
       enabled: false,
       staleTime: Infinity,
     })
@@ -386,46 +448,43 @@ describe('queryCache', () => {
     const key = queryKey()
 
     const fetchData = () => Promise.resolve('data')
-    await defaultQueryCache.prefetchQuery(key, fetchData, {
+    const observer = defaultQueryCache.watchQuery(key, fetchData, {
       cacheTime: 0,
       refetchInterval: 1,
     })
-    const query = defaultQueryCache.getQuery(key)!
-    const instance = query.subscribe()
+    observer.subscribe()
     // @ts-expect-error
-    expect(instance.refetchIntervalId).not.toBeUndefined()
-    instance.unsubscribe()
+    expect(observer.refetchIntervalId).not.toBeUndefined()
+    observer.unsubscribe()
     // @ts-expect-error
-    expect(instance.refetchIntervalId).toBeUndefined()
+    expect(observer.refetchIntervalId).toBeUndefined()
     await sleep(10)
     expect(defaultQueryCache.getQuery(key)).toBeUndefined()
   })
 
   test('query is garbage collected when unsubscribed to', async () => {
     const key = queryKey()
-
-    const fetchData = () => Promise.resolve('data')
-    await defaultQueryCache.prefetchQuery(key, fetchData, { cacheTime: 0 })
+    const observer = defaultQueryCache.watchQuery(key, async () => 'data', {
+      cacheTime: 0,
+    })
     expect(defaultQueryCache.getQuery(key)).toBeDefined()
-    const query = defaultQueryCache.getQuery(key)!
-    const instance = query.subscribe()
-    instance.unsubscribe()
+    observer.subscribe()
+    observer.unsubscribe()
     expect(defaultQueryCache.getQuery(key)).toBeDefined()
     await sleep(100)
     expect(defaultQueryCache.getQuery(key)).toBeUndefined()
   })
 
-  test('query is not garbage collected unless markedForGarbageCollection is true', async () => {
+  test('query is not garbage collected unless there are no subscribers', async () => {
     const key = queryKey()
-
-    const fetchData = () => Promise.resolve(undefined)
-    await defaultQueryCache.prefetchQuery(key, fetchData, { cacheTime: 0 })
+    const observer = defaultQueryCache.watchQuery(key, async () => 'data', {
+      cacheTime: 0,
+    })
     expect(defaultQueryCache.getQuery(key)).toBeDefined()
-    const query = defaultQueryCache.getQuery(key)!
-    const instance = query.subscribe()
+    observer.subscribe()
     await sleep(100)
     expect(defaultQueryCache.getQuery(key)).toBeDefined()
-    instance.unsubscribe()
+    observer.unsubscribe()
     await sleep(100)
     expect(defaultQueryCache.getQuery(key)).toBeUndefined()
     defaultQueryCache.setQueryData(key, 'data')
@@ -656,14 +715,15 @@ describe('queryCache', () => {
 
       await sleep(10)
 
-      const query = defaultQueryCache.getQuery(key)!
-
       // Subscribe and unsubscribe to simulate cancellation because the last observer unsubscribed
-      const observer = query.subscribe()
+      const observer = defaultQueryCache.watchQuery(key)
+      observer.subscribe()
       observer.unsubscribe()
 
       await sleep(100)
 
+      const query = defaultQueryCache.getQuery(key)!
+
       expect(query.state).toMatchObject({
         data: 'data',
         status: 'success',
@@ -689,14 +749,15 @@ describe('queryCache', () => {
 
       await sleep(10)
 
-      const query = defaultQueryCache.getQuery(key)!
-
       // Subscribe and unsubscribe to simulate cancellation because the last observer unsubscribed
-      const observer = query.subscribe()
+      const observer = defaultQueryCache.watchQuery(key)
+      observer.subscribe()
       observer.unsubscribe()
 
       await sleep(100)
 
+      const query = defaultQueryCache.getQuery(key)!
+
       expect(cancel).toHaveBeenCalled()
       expect(query.state).toMatchObject({
         data: undefined,