Repository: tannerlinsley/react-query
Pull request #584 (2020-06-14 21:39:08)

Cache data on the server with manually created caches
This is a breakout from #570 and replaces `makeServerQueryCache` in that PR. More background can be found there.

Before this PR, queries wont ever get cached in a server environment to avoid leaking sensitive information between requests (#70). This PR makes it so any cache that has been manually created via `makeQueryCache` will cache data, even on the server. 

This change wont improve SSR (much) by itself but is a prerequisite. I'm pretty sure this PR is the only breaking change needed and that de/rehydration can be achieved without breaking changes.

This also adds some extra guards for `scheduleStale` and `scheduleGarbage`, since I don't believe these should ever be called on the server. Lifetime on the server should be manually managed (usually lifetime should be a single request).

I saw `defaultConfig` was added to `makeQueryCache`. I needed an extra `frozen`-option that I don't think makes sense as a cache config, but rather an option to `makeQueryCache` itself, so I took the liberty of changing the signature into an `options`-object instead if that's ok?

I'm not sure if the `frozen`-option should be documented and public or not and it would also be trivial to add `.freeze()` and `.unfreeze()`-methods, but I'm not sure if I see any use-cases for exposing this?

Just a heads up: I'm AFK for about a week from tomorrow, so while I can read and respond, actually fixing comments will take some time. 😄  I'll tackle next steps after that.


Patch:
diff --git a/README.md b/README.md
index 8aa444501..9e209458e 100644
--- a/README.md
+++ b/README.md
@@ -916,7 +916,8 @@ function Todo({ todoId }) {
 
 When using SSR (server-side-rendering) with React Query there are a few things to note:
 
-- Query caches are not written to memory during SSR. This is outside of the scope of React Query and easily leads to out-of-sync data when used with frameworks like Next.js or other SSR strategies.
+- If you import and use the global `queryCache` directly, queries are not cached during SSR to avoid leaking sensitive information between requests.
+- If you create a `queryCache` manually with `makeQueryCache`, queries will be cached during SSR. Make sure you create a separate cache per request to avoid leaking data.
 - Queries rendered on the server will by default use the `initialData` of an unfetched query. This means that by default, `data` will be set to `undefined`. To get around this in SSR, you can either pre-seed a query's cache data using the `config.initialData` option:
 
 ```js
diff --git a/src/queryCache.js b/src/queryCache.js
index a4c699607..e484e4a3e 100644
--- a/src/queryCache.js
+++ b/src/queryCache.js
@@ -16,7 +16,7 @@ import {
 } from './utils'
 import { defaultConfigRef } from './config'
 
-export const queryCache = makeQueryCache()
+export const queryCache = makeQueryCache({ frozen: isServer })
 
 export const queryCacheContext = React.createContext(queryCache)
 
@@ -62,13 +62,16 @@ const actionSuccess = 'Success'
 const actionError = 'Error'
 const actionSetState = 'SetState'
 
-export function makeQueryCache(defaultConfig) {
+export function makeQueryCache(options = {}) {
   const listeners = []
 
-  const configRef = defaultConfig
-    ? { current: defaultConfig }
+  const configRef = options.defaultConfig
+    ? { current: options.defaultConfig }
     : defaultConfigRef
 
+  // A frozen cache does not add new queries to the cache
+  const frozen = options.frozen || false
+
   const queryCache = {
     queries: {},
     isFetching: 0,
@@ -188,14 +191,19 @@ export function makeQueryCache(defaultConfig) {
         query.scheduleGarbageCollection()
       }
 
-      if (!isServer) {
+      if (!frozen) {
         queryCache.queries[queryHash] = query
-        // Here, we setTimeout so as to not trigger
-        // any setState's in parent components in the
-        // middle of the render phase.
-        setTimeout(() => {
+
+        if (isServer) {
           notifyGlobalListeners()
-        })
+        } else {
+          // Here, we setTimeout so as to not trigger
+          // any setState's in parent components in the
+          // middle of the render phase.
+          setTimeout(() => {
+            notifyGlobalListeners()
+          })
+        }
       }
     }
 
@@ -408,8 +416,10 @@ export function makeQueryCache(defaultConfig) {
         if (!query.instances.length) {
           query.cancel()
 
-          // Schedule garbage collection
-          query.scheduleGarbageCollection()
+          if (!isServer) {
+            // Schedule garbage collection
+            query.scheduleGarbageCollection()
+          }
         }
       }
 
@@ -561,9 +571,11 @@ export function makeQueryCache(defaultConfig) {
       // Set data and mark it as cached
       dispatch({ type: actionSuccess, updater })
 
-      // Schedule a fresh invalidation!
-      clearTimeout(query.staleTimeout)
-      query.scheduleStaleTimeout()
+      if (!isServer) {
+        // Schedule a fresh invalidation!
+        clearTimeout(query.staleTimeout)
+        query.scheduleStaleTimeout()
+      }
     }
 
     query.clear = () => {


Test patch:
diff --git a/src/tests/ssr.test.js b/src/tests/ssr.test.js
new file mode 100644
index 000000000..17dffd381
--- /dev/null
+++ b/src/tests/ssr.test.js
@@ -0,0 +1,203 @@
+/**
+ * @jest-environment node
+ */
+
+import React from 'react'
+import { renderToString } from 'react-dom/server'
+import {
+  usePaginatedQuery,
+  ReactQueryCacheProvider,
+  queryCache,
+  makeQueryCache,
+  useQuery,
+} from '../index'
+import { sleep } from './utils'
+
+describe('Server Side Rendering', () => {
+  describe('global cache', () => {
+    afterEach(() => {
+      queryCache.clear()
+    })
+
+    it('should not trigger fetch', () => {
+      const queryFn = jest.fn()
+
+      function Page() {
+        const query = useQuery('test', queryFn)
+
+        const content = `status ${query.status}`
+
+        return (
+          <div>
+            <div>{content}</div>
+          </div>
+        )
+      }
+
+      const markup = renderToString(<Page />)
+
+      expect(markup).toContain('status loading')
+      expect(queryFn).toHaveBeenCalledTimes(0)
+    })
+
+    // See https://github.com/tannerlinsley/react-query/issues/70
+    it('should not add initialData to the cache', () => {
+      function Page() {
+        const [page, setPage] = React.useState(1)
+        const { resolvedData } = usePaginatedQuery(
+          ['data', page],
+          async (queryName, page) => {
+            return page
+          },
+          { initialData: '1' }
+        )
+
+        return (
+          <div>
+            <h1 data-testid="title">{resolvedData}</h1>
+            <button onClick={() => setPage(page + 1)}>next</button>
+          </div>
+        )
+      }
+
+      renderToString(<Page />)
+
+      expect(queryCache.queries).toEqual({})
+    })
+
+    it('should not add prefetched data to the cache', async () => {
+      const fetchFn = () => Promise.resolve('data')
+      const data = await queryCache.prefetchQuery('key', fetchFn)
+
+      expect(data).toBe('data')
+      expect(queryCache.getQuery('key')).toBeFalsy()
+    })
+  })
+
+  describe('manually created cache', () => {
+    it('should not trigger fetch', () => {
+      const queryCache = makeQueryCache()
+      const queryFn = jest.fn()
+
+      function Page() {
+        const query = useQuery('test', queryFn)
+
+        const content = `status ${query.status}`
+
+        return (
+          <div>
+            <div>{content}</div>
+          </div>
+        )
+      }
+
+      const markup = renderToString(
+        <ReactQueryCacheProvider queryCache={queryCache}>
+          <Page />
+        </ReactQueryCacheProvider>
+      )
+
+      expect(markup).toContain('status loading')
+      expect(queryFn).toHaveBeenCalledTimes(0)
+    })
+
+    it('should add prefetched data to cache', async () => {
+      const queryCache = makeQueryCache()
+      const fetchFn = () => Promise.resolve('data')
+      const data = await queryCache.prefetchQuery('key', fetchFn)
+
+      expect(data).toBe('data')
+      expect(queryCache.getQuery('key').state.data).toBe('data')
+    })
+
+    it('should return existing data from the cache', async () => {
+      const queryCache = makeQueryCache()
+      const queryFn = jest.fn(() => sleep(10))
+
+      function Page() {
+        const query = useQuery('test', queryFn)
+
+        const content = `status ${query.status}`
+
+        return (
+          <div>
+            <div>{content}</div>
+          </div>
+        )
+      }
+
+      await queryCache.prefetchQuery('test', queryFn)
+
+      const markup = renderToString(
+        <ReactQueryCacheProvider queryCache={queryCache}>
+          <Page />
+        </ReactQueryCacheProvider>
+      )
+
+      expect(markup).toContain('status success')
+      expect(queryFn).toHaveBeenCalledTimes(1)
+    })
+
+    it('should add initialData to the cache', () => {
+      const queryCache = makeQueryCache()
+      function Page() {
+        const [page, setPage] = React.useState(1)
+        const { resolvedData } = usePaginatedQuery(
+          ['data', page],
+          async (queryName, page) => {
+            return page
+          },
+          { initialData: '1' }
+        )
+
+        return (
+          <div>
+            <h1 data-testid="title">{resolvedData}</h1>
+            <button onClick={() => setPage(page + 1)}>next</button>
+          </div>
+        )
+      }
+
+      renderToString(
+        <ReactQueryCacheProvider queryCache={queryCache}>
+          <Page />
+        </ReactQueryCacheProvider>
+      )
+
+      expect(Object.keys(queryCache.queries)).toEqual(['["data",1]'])
+    })
+
+    it('should not call setTimeout', async () => {
+      jest.spyOn(global, 'setTimeout')
+
+      const queryCache = makeQueryCache()
+      const queryFn = jest.fn(() => Promise.resolve())
+
+      function Page() {
+        const query = useQuery('test', queryFn)
+
+        const content = `status ${query.status}`
+
+        return (
+          <div>
+            <div>{content}</div>
+          </div>
+        )
+      }
+
+      await queryCache.prefetchQuery('test', queryFn)
+
+      const markup = renderToString(
+        <ReactQueryCacheProvider queryCache={queryCache}>
+          <Page />
+        </ReactQueryCacheProvider>
+      )
+
+      expect(markup).toContain('status success')
+      expect(queryFn).toHaveBeenCalledTimes(1)
+      expect(global.setTimeout).toHaveBeenCalledTimes(0)
+
+      global.setTimeout.mockRestore()
+    })
+  })
+})
diff --git a/src/tests/useQuery-SSR.test.js b/src/tests/useQuery-SSR.test.js
deleted file mode 100644
index 346e92c87..000000000
--- a/src/tests/useQuery-SSR.test.js
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * @jest-environment node
- */
-
-import React from 'react'
-import { renderToString } from 'react-dom/server'
-import {
-  usePaginatedQuery,
-  ReactQueryCacheProvider,
-  makeQueryCache,
-} from '../index'
-
-describe('useQuery SSR', () => {
-  // See https://github.com/tannerlinsley/react-query/issues/70
-  it('should not cache queries on server', async () => {
-    const queryCache = makeQueryCache()
-    function Page() {
-      const [page, setPage] = React.useState(1)
-      const { resolvedData } = usePaginatedQuery(
-        ['data', page],
-        async (queryName, page) => {
-          return page
-        },
-        { initialData: '1' }
-      )
-
-      return (
-        <div>
-          <h1 data-testid="title">{resolvedData}</h1>
-          <button onClick={() => setPage(page + 1)}>next</button>
-        </div>
-      )
-    }
-
-    renderToString(
-      <ReactQueryCacheProvider queryCache={queryCache}>
-        <Page />
-      </ReactQueryCacheProvider>
-    )
-
-    expect(queryCache.queries).toEqual({})
-  })
-})