Repository: tannerlinsley/react-query
Pull request #1760 (2021-02-07 09:57:06)

fix: handle select errors
Fixes https://github.com/tannerlinsley/react-query/issues/1540

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



Patch:
diff --git a/docs/src/pages/guides/ssr.md b/docs/src/pages/guides/ssr.md
index cfeeacc59..1e3ea5578 100644
--- a/docs/src/pages/guides/ssr.md
+++ b/docs/src/pages/guides/ssr.md
@@ -49,7 +49,7 @@ React Query supports prefetching multiple queries on the server in Next.js and t
 
 To support caching queries on the server and set up hydration:
 
-- Create a new `QueryClient` instance
+- Create a new `QueryClient` instance **inside of your app, and on an instance ref. This ensures that data is not shared between different users and requests.**
 - Wrap your app component with `<QueryClientProvider>` and pass it the client instance
 - Wrap your app component with `<Hydrate>` and pass it the `dehydratedState` prop from `pageProps`
 
@@ -58,11 +58,14 @@ To support caching queries on the server and set up hydration:
 import { QueryClient, QueryClientProvider } from 'react-query'
 import { Hydrate } from 'react-query/hydration'
 
-const queryClient = new QueryClient()
-
 export default function MyApp({ Component, pageProps }) {
+  const queryClientRef = React.useRef()
+  if (!queryClientRef.current) {
+    queryClientRef.current = new QueryClient()
+  }
+
   return (
-    <QueryClientProvider client={queryClient}>
+    <QueryClientProvider client={queryClientRef.current}>
       <Hydrate state={pageProps.dehydratedState}>
         <Component {...pageProps} />
       </Hydrate>
@@ -73,7 +76,7 @@ export default function MyApp({ Component, pageProps }) {
 
 Now you are ready to prefetch some data in your pages with either [`getStaticProps`](https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation) (for SSG) or [`getServerSideProps`](https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering) (for SSR). From React Query's perspective, these integrate in the same way, `getStaticProps` is shown below.
 
-- Create a new `QueryClient` instance for each page request
+- Create a new `QueryClient` instance **for each page request. This ensures that data is not shared between users and requests.**
 - Prefetch the data using the clients `prefetchQuery` method and wait for it to complete
 - Use `dehydrate` to dehydrate the query cache and pass it to the page via the `dehydratedState` prop. This is the same prop that the cache will be picked up from in your `_app.js`
 
@@ -117,7 +120,7 @@ This guide is at-best, a high level overview of how SSR with React Query should
 
 ### On the Server
 
-- Create a new `QueryClient` instance
+- Create a new `QueryClient` instance **inside of your request handler. This ensures that data is not shared between different users and requests.**
 - Using the client, prefetch any data you need
 - Dehydrate the client
 - Render your app with the client provider and also **using the dehydrated state. This is extremely important! You must render both server and client using the same dehydrated state to ensure hydration on the client produces the exact same markup as the server.**
@@ -129,28 +132,30 @@ This guide is at-best, a high level overview of how SSR with React Query should
 import { QueryClient, QueryClientProvider } from 'react-query'
 import { dehydrate, Hydrate } from 'react-query/hydration'
 
-const queryClient = new QueryClient()
-await queryClient.prefetchQuery('key', fn)
-const dehydratedState = dehydrate(queryClient)
+function handleRequest (req, res) {
+  const queryClient = new QueryClient()
+  await queryClient.prefetchQuery('key', fn)
+  const dehydratedState = dehydrate(queryClient)
 
-const html = ReactDOM.renderToString(
-  <QueryClientProvider client={queryClient}>
-    <Hydrate state={dehydratedState}>
-      <App />
-    </Hydrate>
-  </QueryClientProvider>
-)
+  const html = ReactDOM.renderToString(
+    <QueryClientProvider client={queryClient}>
+      <Hydrate state={dehydratedState}>
+        <App />
+      </Hydrate>
+    </QueryClientProvider>
+  )
 
-res.send(`
-  <html>
-    <body>
-      <div id="root">${html}</div>
-      <script>
-        window.__REACT_QUERY_STATE__ = ${JSON.stringify(dehydratedState)};
-      </script>
-    </body>
-  </html>
+  res.send(`
+    <html>
+      <body>
+        <div id="root">${html}</div>
+        <script>
+          window.__REACT_QUERY_STATE__ = ${JSON.stringify(dehydratedState)};
+        </script>
+      </body>
+    </html>
   `)
+}
 ```
 
 ### Client
diff --git a/examples/nextjs/pages/_app.js b/examples/nextjs/pages/_app.js
index cf2ca77ad..56b91717b 100644
--- a/examples/nextjs/pages/_app.js
+++ b/examples/nextjs/pages/_app.js
@@ -3,11 +3,14 @@ import { QueryClient, QueryClientProvider } from 'react-query'
 import { Hydrate } from 'react-query/hydration'
 import { ReactQueryDevtools } from 'react-query/devtools'
 
-const queryClient = new QueryClient()
-
 export default function MyApp({ Component, pageProps }) {
+  const queryClientRef = React.useRef()
+  if (!queryClientRef.current) {
+    queryClientRef.current = new QueryClient()
+  }
+
   return (
-    <QueryClientProvider client={queryClient}>
+    <QueryClientProvider client={queryClientRef.current}>
       <Hydrate state={pageProps.dehydratedState}>
         <Component {...pageProps} />
       </Hydrate>
diff --git a/src/core/infiniteQueryBehavior.ts b/src/core/infiniteQueryBehavior.ts
index 6bca69785..beed03d62 100644
--- a/src/core/infiniteQueryBehavior.ts
+++ b/src/core/infiniteQueryBehavior.ts
@@ -17,6 +17,7 @@ export function infiniteQueryBehavior<
         const oldPages = context.state.data?.pages || []
         const oldPageParams = context.state.data?.pageParams || []
         let newPageParams = oldPageParams
+        let cancelled = false
 
         // Get query function
         const queryFn =
@@ -29,6 +30,10 @@ export function infiniteQueryBehavior<
           param?: unknown,
           previous?: boolean
         ): Promise<unknown[]> => {
+          if (cancelled) {
+            return Promise.reject('Cancelled')
+          }
+
           if (typeof param === 'undefined' && !manual && pages.length) {
             return Promise.resolve(pages)
           }
@@ -38,11 +43,7 @@ export function infiniteQueryBehavior<
             pageParam: param,
           }
 
-          let cancelFn: undefined | (() => any)
           const queryFnResult = queryFn(queryFnContext)
-          if ((queryFnResult as any).cancel) {
-            cancelFn = (queryFnResult as any).cancel
-          }
 
           const promise = Promise.resolve(queryFnResult).then(page => {
             newPageParams = previous
@@ -51,15 +52,15 @@ export function infiniteQueryBehavior<
             return previous ? [page, ...pages] : [...pages, page]
           })
 
-          if (cancelFn) {
+          if (isCancelable(queryFnResult)) {
             const promiseAsAny = promise as any
-            promiseAsAny.cancel = cancelFn
+            promiseAsAny.cancel = queryFnResult.cancel
           }
 
           return promise
         }
 
-        let promise
+        let promise: Promise<unknown[]>
 
         // Fetch first page?
         if (!oldPages.length) {
@@ -109,9 +110,13 @@ export function infiniteQueryBehavior<
           pageParams: newPageParams,
         }))
 
-        if (isCancelable(promise)) {
-          const finalPromiseAsAny = finalPromise as any
-          finalPromiseAsAny.cancel = promise.cancel
+        const finalPromiseAsAny = finalPromise as any
+
+        finalPromiseAsAny.cancel = () => {
+          cancelled = true
+          if (isCancelable(promise)) {
+            promise.cancel()
+          }
         }
 
         return finalPromise
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index 4c3518d15..6cbd29c92 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -21,6 +21,7 @@ import type { Query, QueryState, Action, FetchOptions } from './query'
 import type { QueryClient } from './queryClient'
 import { focusManager } from './focusManager'
 import { Subscribable } from './subscribable'
+import { getLogger } from './logger'
 
 type QueryObserverListener<TData, TError> = (
   result: QueryObserverResult<TData, TError>
@@ -240,6 +241,9 @@ export class QueryObserver<
         this.updateRefetchInterval()
       }
     }
+
+    // Reset previous options after all code related to option changes has run
+    this.previousOptions = this.options
   }
 
   getCurrentResult(): QueryObserverResult<TData, TError> {
@@ -390,6 +394,8 @@ export class QueryObserver<
     let isPlaceholderData = false
     let data: TData | undefined
     let dataUpdatedAt = state.dataUpdatedAt
+    let error = state.error
+    let errorUpdatedAt = state.errorUpdatedAt
 
     // Optimistically set status to loading if we will start fetching
     if (!this.hasListeners() && this.willFetchOnMount()) {
@@ -421,9 +427,16 @@ export class QueryObserver<
       ) {
         data = this.currentResult.data
       } else {
-        data = this.options.select(state.data)
-        if (this.options.structuralSharing !== false) {
-          data = replaceEqualDeep(this.currentResult?.data, data)
+        try {
+          data = this.options.select(state.data)
+          if (this.options.structuralSharing !== false) {
+            data = replaceEqualDeep(this.currentResult?.data, data)
+          }
+        } catch (selectError) {
+          getLogger().error(selectError)
+          error = selectError
+          errorUpdatedAt = Date.now()
+          status = 'error'
         }
       }
     }
@@ -453,8 +466,8 @@ export class QueryObserver<
       ...getStatusProps(status),
       data,
       dataUpdatedAt,
-      error: state.error,
-      errorUpdatedAt: state.errorUpdatedAt,
+      error,
+      errorUpdatedAt,
       failureCount: state.fetchFailureCount,
       isFetched: state.dataUpdateCount > 0 || state.errorUpdateCount > 0,
       isFetchedAfterMount:


Test patch:
diff --git a/.github/workflows/test-and-publish.yml b/.github/workflows/test-and-publish.yml
index 958b97ced..d8ef950e2 100644
--- a/.github/workflows/test-and-publish.yml
+++ b/.github/workflows/test-and-publish.yml
@@ -4,6 +4,7 @@ on:
   push:
     branches:
       - 'master'
+      - 'next'
       - 'beta'
       - '1.x'
       - '2.x'
@@ -29,7 +30,7 @@ jobs:
     name: 'Publish Module to NPM'
     needs: test
     # publish only when merged in master on original repo, not on PR
-    if: github.repository == 'tannerlinsley/react-query' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/beta' || github.ref == 'refs/heads/1.x' || github.ref == 'refs/heads/2.x')
+    if: github.repository == 'tannerlinsley/react-query' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/next' || github.ref == 'refs/heads/beta' || github.ref == 'refs/heads/1.x' || github.ref == 'refs/heads/2.x')
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v2
diff --git a/src/core/tests/queryObserver.test.tsx b/src/core/tests/queryObserver.test.tsx
index 8ffa73cb6..91dcbc673 100644
--- a/src/core/tests/queryObserver.test.tsx
+++ b/src/core/tests/queryObserver.test.tsx
@@ -158,11 +158,30 @@ describe('queryObserver', () => {
       select: select2,
     })
     await sleep(1)
+    await observer.refetch()
     unsubscribe()
     expect(count).toBe(2)
-    expect(results.length).toBe(2)
-    expect(results[0]).toMatchObject({ data: { myCount: 1 } })
-    expect(results[1]).toMatchObject({ data: { myCount: 99 } })
+    expect(results.length).toBe(4)
+    expect(results[0]).toMatchObject({
+      status: 'success',
+      isFetching: false,
+      data: { myCount: 1 },
+    })
+    expect(results[1]).toMatchObject({
+      status: 'success',
+      isFetching: false,
+      data: { myCount: 99 },
+    })
+    expect(results[2]).toMatchObject({
+      status: 'success',
+      isFetching: true,
+      data: { myCount: 99 },
+    })
+    expect(results[3]).toMatchObject({
+      status: 'success',
+      isFetching: false,
+      data: { myCount: 99 },
+    })
   })
 
   test('should not run the selector again if the data and selector did not change', async () => {
@@ -189,10 +208,25 @@ describe('queryObserver', () => {
       select,
     })
     await sleep(1)
+    await observer.refetch()
     unsubscribe()
     expect(count).toBe(1)
-    expect(results.length).toBe(1)
-    expect(results[0]).toMatchObject({ data: { myCount: 1 } })
+    expect(results.length).toBe(3)
+    expect(results[0]).toMatchObject({
+      status: 'success',
+      isFetching: false,
+      data: { myCount: 1 },
+    })
+    expect(results[1]).toMatchObject({
+      status: 'success',
+      isFetching: true,
+      data: { myCount: 1 },
+    })
+    expect(results[2]).toMatchObject({
+      status: 'success',
+      isFetching: false,
+      data: { myCount: 1 },
+    })
   })
 
   test('should not run the selector again if the data did not change', async () => {
diff --git a/src/hydration/tests/ssr.test.tsx b/src/hydration/tests/ssr.test.tsx
index aff7e8642..4a764d59b 100644
--- a/src/hydration/tests/ssr.test.tsx
+++ b/src/hydration/tests/ssr.test.tsx
@@ -143,7 +143,7 @@ describe('Server side rendering with de/rehydration', () => {
     expect(consoleMock).toHaveBeenCalledTimes(1)
     expect(fetchDataError).toHaveBeenCalledTimes(1)
     expect(el.innerHTML).toBe(expectedMarkup)
-    await sleep(10)
+    await sleep(50)
     expect(fetchDataError).toHaveBeenCalledTimes(2)
     expect(el.innerHTML).toBe(
       'ErrorComponent - status:error fetching:false data:undefined'
@@ -208,7 +208,7 @@ describe('Server side rendering with de/rehydration', () => {
     expect(consoleMock).not.toHaveBeenCalled()
     expect(fetchDataSuccess).toHaveBeenCalledTimes(0)
     expect(el.innerHTML).toBe(expectedMarkup)
-    await sleep(10)
+    await sleep(50)
     expect(fetchDataSuccess).toHaveBeenCalledTimes(1)
     expect(el.innerHTML).toBe(
       'SuccessComponent - status:success fetching:false data:success!'
diff --git a/src/react/tests/useInfiniteQuery.test.tsx b/src/react/tests/useInfiniteQuery.test.tsx
index 246fe51f4..e22967bd7 100644
--- a/src/react/tests/useInfiniteQuery.test.tsx
+++ b/src/react/tests/useInfiniteQuery.test.tsx
@@ -15,6 +15,7 @@ import {
   QueryClient,
   QueryCache,
 } from '../..'
+import { CancelledError } from '../../core'
 
 interface Result {
   items: number[]
@@ -720,6 +721,54 @@ describe('useInfiniteQuery', () => {
     })
   })
 
+  it('should stop fetching additional pages when the component is unmounted', async () => {
+    const key = queryKey()
+    const states: UseInfiniteQueryResult<number>[] = []
+    let fetches = 0
+
+    function List() {
+      const state = useInfiniteQuery(
+        key,
+        async ({ pageParam }) => {
+          fetches++
+          await sleep(50)
+          return Number(pageParam)
+        },
+        {
+          initialData: { pages: [1, 2, 3, 4], pageParams: [1, 2, 3, 4] },
+          getNextPageParam: lastPage => lastPage + 1,
+        }
+      )
+
+      states.push(state)
+
+      return null
+    }
+
+    function Page() {
+      const [show, setShow] = React.useState(true)
+
+      React.useEffect(() => {
+        setActTimeout(() => {
+          setShow(false)
+        }, 75)
+      }, [])
+
+      return show ? <List /> : null
+    }
+
+    renderWithClient(queryClient, <Page />)
+
+    await sleep(300)
+
+    expect(states.length).toBe(1)
+    expect(fetches).toBe(2)
+    expect(queryClient.getQueryState(key)).toMatchObject({
+      status: 'error',
+      error: expect.any(CancelledError),
+    })
+  })
+
   it('should be able to override the cursor in the fetchNextPage callback', async () => {
     const key = queryKey()
     const states: UseInfiniteQueryResult<number>[] = []
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index 8ac67e533..9dac880bb 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -729,6 +729,35 @@ describe('useQuery', () => {
     expect(states[1]).toMatchObject({ data: 'test' })
   })
 
+  it('should throw an error when a selector throws', async () => {
+    const consoleMock = mockConsoleError()
+    const key = queryKey()
+    const states: UseQueryResult<string>[] = []
+    const error = new Error('Select Error')
+
+    function Page() {
+      const state = useQuery(key, () => ({ name: 'test' }), {
+        select: () => {
+          throw error
+        },
+      })
+      states.push(state)
+      return null
+    }
+
+    renderWithClient(queryClient, <Page />)
+
+    await sleep(10)
+
+    expect(consoleMock).toHaveBeenCalledWith(error)
+    expect(states.length).toBe(2)
+
+    expect(states[0]).toMatchObject({ status: 'loading', data: undefined })
+    expect(states[1]).toMatchObject({ status: 'error', error })
+
+    consoleMock.mockRestore()
+  })
+
   it('should re-render when dataUpdatedAt changes but data remains the same', async () => {
     const key = queryKey()
     const states: UseQueryResult<string>[] = []