Repository: tannerlinsley/react-query
Pull request #1608 (2021-01-09 12:51:22)

fix: prevent missing renders
Reverts the render optimization from https://github.com/tannerlinsley/react-query/pull/1487 because it is not guaranteed React will actually commit the render when a hook is called (renders might be discarded). Added a test to cover this behavior.

Fixes https://github.com/tannerlinsley/react-query/issues/1535

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/ihhgk7puz](https://vercel.com/tannerlinsley/react-query/ihhgk7puz)  
✅ Preview: [https://react-query-git-feature-fix-missing-renders.tannerlinsley.vercel.app](https://react-query-git-feature-fix-missing-renders.tannerlinsley.vercel.app)



Patch:
diff --git a/docs/src/pages/guides/mutations.md b/docs/src/pages/guides/mutations.md
index f593d82f8..769e7f806 100644
--- a/docs/src/pages/guides/mutations.md
+++ b/docs/src/pages/guides/mutations.md
@@ -5,7 +5,7 @@ title: Mutations
 
 Unlike queries, mutations are typically used to create/update/delete data or perform server side-effects. For this purpose, React Query exports a `useMutation` hook.
 
-Here's an example of a mutation that adds a new todo the server:
+Here's an example of a mutation that adds a new todo to the server:
 
 ```js
 function App() {
diff --git a/docs/src/pages/guides/query-invalidation.md b/docs/src/pages/guides/query-invalidation.md
index 3f342c077..af2d31959 100644
--- a/docs/src/pages/guides/query-invalidation.md
+++ b/docs/src/pages/guides/query-invalidation.md
@@ -17,7 +17,7 @@ queryClient.invalidateQueries('todos')
 When a query is invalidated with `invalidateQueries`, two things happen:
 
 - It is marked as stale. This stale state overrides any `staleTime` configurations being used in `useQuery` or related hooks
-- If the query is currently being rendered via `useQuery` or related hooks), it will also be refetched in the background
+- If the query is currently being rendered via `useQuery` or related hooks, it will also be refetched in the background
 
 ## Query Matching with `invalidateQueries`
 
diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts
index efa0e7742..05c7becdd 100644
--- a/src/react/useBaseQuery.ts
+++ b/src/react/useBaseQuery.ts
@@ -2,7 +2,6 @@ import React from 'react'
 
 import { notifyManager } from '../core/notifyManager'
 import { QueryObserver } from '../core/queryObserver'
-import { QueryObserverResult } from '../core/types'
 import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary'
 import { useQueryClient } from './QueryClientProvider'
 import { UseBaseQueryOptions } from './types'
@@ -59,23 +58,12 @@ export function useBaseQuery<TQueryFnData, TError, TData, TQueryData>(
   }
 
   const currentResult = observer.getCurrentResult()
-
-  // Remember latest result to prevent redundant renders
-  const latestResultRef = React.useRef(currentResult)
-  latestResultRef.current = currentResult
-
-  const [, rerender] = React.useState({})
+  const [, setCurrentResult] = React.useState(currentResult)
 
   // Subscribe to the observer
   React.useEffect(() => {
     errorResetBoundary.clearReset()
-    return observer.subscribe(
-      notifyManager.batchCalls((result: QueryObserverResult) => {
-        if (result !== latestResultRef.current) {
-          rerender({})
-        }
-      })
-    )
+    return observer.subscribe(notifyManager.batchCalls(setCurrentResult))
   }, [observer, errorResetBoundary])
 
   // Handle suspense


Test patch:
diff --git a/src/react/tests/suspense.test.tsx b/src/react/tests/suspense.test.tsx
index a8534bc88..da6638e94 100644
--- a/src/react/tests/suspense.test.tsx
+++ b/src/react/tests/suspense.test.tsx
@@ -59,7 +59,7 @@ describe("useQuery's in Suspense mode", () => {
 
     await sleep(20)
 
-    expect(renders).toBe(4)
+    expect(renders).toBe(5)
     expect(states.length).toBe(2)
     expect(states[0]).toMatchObject({ data: 1, status: 'success' })
     expect(states[1]).toMatchObject({ data: 2, status: 'success' })
diff --git a/src/react/tests/useInfiniteQuery.test.tsx b/src/react/tests/useInfiniteQuery.test.tsx
index f9d8333e8..ddc1e25f2 100644
--- a/src/react/tests/useInfiniteQuery.test.tsx
+++ b/src/react/tests/useInfiniteQuery.test.tsx
@@ -206,7 +206,7 @@ describe('useInfiniteQuery', () => {
 
     await sleep(300)
 
-    expect(states.length).toBe(6)
+    expect(states.length).toBe(7)
     expect(states[0]).toMatchObject({
       data: undefined,
       isFetching: true,
@@ -243,7 +243,15 @@ describe('useInfiniteQuery', () => {
       isSuccess: true,
       isPreviousData: true,
     })
+    // Hook state update
     expect(states[5]).toMatchObject({
+      data: { pages: ['0-desc', '1-desc'] },
+      isFetching: true,
+      isFetchingNextPage: false,
+      isSuccess: true,
+      isPreviousData: true,
+    })
+    expect(states[6]).toMatchObject({
       data: { pages: ['0-asc'] },
       isFetching: false,
       isFetchingNextPage: false,
@@ -816,7 +824,7 @@ describe('useInfiniteQuery', () => {
 
     await sleep(100)
 
-    expect(states.length).toBe(5)
+    expect(states.length).toBe(6)
     expect(states[0]).toMatchObject({
       hasNextPage: undefined,
       data: undefined,
@@ -840,8 +848,16 @@ describe('useInfiniteQuery', () => {
       isFetchingNextPage: false,
       isSuccess: true,
     })
-    // Refetch
+    // Hook state update
     expect(states[3]).toMatchObject({
+      hasNextPage: true,
+      data: { pages: [7, 8] },
+      isFetching: false,
+      isFetchingNextPage: false,
+      isSuccess: true,
+    })
+    // Refetch
+    expect(states[4]).toMatchObject({
       hasNextPage: true,
       data: { pages: [7, 8] },
       isFetching: true,
@@ -849,7 +865,7 @@ describe('useInfiniteQuery', () => {
       isSuccess: true,
     })
     // Refetch done
-    expect(states[4]).toMatchObject({
+    expect(states[5]).toMatchObject({
       hasNextPage: true,
       data: { pages: [7, 8] },
       isFetching: false,
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index cd879164c..3848e5a87 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -568,15 +568,17 @@ describe('useQuery', () => {
 
     await sleep(100)
 
-    expect(states.length).toBe(4)
+    expect(states.length).toBe(5)
     // First load
     expect(states[0]).toMatchObject({ isLoading: true, isSuccess: false })
     // First success
     expect(states[1]).toMatchObject({ isLoading: false, isSuccess: true })
-    // Second load
+    // Remove
     expect(states[2]).toMatchObject({ isLoading: true, isSuccess: false })
+    // Hook state update
+    expect(states[3]).toMatchObject({ isLoading: true, isSuccess: false })
     // Second success
-    expect(states[3]).toMatchObject({ isLoading: false, isSuccess: true })
+    expect(states[4]).toMatchObject({ isLoading: false, isSuccess: true })
   })
 
   it('should fetch when refetchOnMount is false and nothing has been fetched yet', async () => {
@@ -766,15 +768,17 @@ describe('useQuery', () => {
 
     await sleep(20)
 
-    expect(states.length).toBe(4)
+    expect(states.length).toBe(5)
     // Initial
     expect(states[0]).toMatchObject({ data: undefined })
     // Fetched
     expect(states[1]).toMatchObject({ data: 1 })
-    // Switch
+    // Remove
     expect(states[2]).toMatchObject({ data: undefined })
+    // Hook state update
+    expect(states[3]).toMatchObject({ data: undefined })
     // Fetched
-    expect(states[3]).toMatchObject({ data: 2 })
+    expect(states[4]).toMatchObject({ data: 2 })
   })
 
   it('should be create a new query when refetching a removed query', async () => {
@@ -1080,7 +1084,7 @@ describe('useQuery', () => {
 
     renderWithClient(queryClient, <Page />)
 
-    await waitFor(() => expect(states.length).toBe(4))
+    await waitFor(() => expect(states.length).toBe(5))
 
     // Initial
     expect(states[0]).toMatchObject({
@@ -1103,8 +1107,15 @@ describe('useQuery', () => {
       isSuccess: true,
       isPreviousData: true,
     })
-    // New data
+    // Hook state update
     expect(states[3]).toMatchObject({
+      data: 0,
+      isFetching: true,
+      isSuccess: true,
+      isPreviousData: true,
+    })
+    // New data
+    expect(states[4]).toMatchObject({
       data: 1,
       isFetching: false,
       isSuccess: true,
@@ -1141,7 +1152,7 @@ describe('useQuery', () => {
 
     renderWithClient(queryClient, <Page />)
 
-    await waitFor(() => expect(states.length).toBe(4))
+    await waitFor(() => expect(states.length).toBe(5))
 
     // Initial
     expect(states[0]).toMatchObject({
@@ -1164,8 +1175,15 @@ describe('useQuery', () => {
       isSuccess: true,
       isPreviousData: true,
     })
-    // New data
+    // Hook state update
     expect(states[3]).toMatchObject({
+      data: 0,
+      isFetching: true,
+      isSuccess: true,
+      isPreviousData: true,
+    })
+    // New data
+    expect(states[4]).toMatchObject({
       data: 1,
       isFetching: false,
       isSuccess: true,
@@ -1210,7 +1228,7 @@ describe('useQuery', () => {
 
     renderWithClient(queryClient, <Page />)
 
-    await waitFor(() => expect(states.length).toBe(6))
+    await waitFor(() => expect(states.length).toBe(7))
 
     // Disabled query
     expect(states[0]).toMatchObject({
@@ -1240,15 +1258,22 @@ describe('useQuery', () => {
       isSuccess: true,
       isPreviousData: true,
     })
-    // Fetching new query
+    // Hook state update
     expect(states[4]).toMatchObject({
+      data: 0,
+      isFetching: false,
+      isSuccess: true,
+      isPreviousData: true,
+    })
+    // Fetching new query
+    expect(states[5]).toMatchObject({
       data: 0,
       isFetching: true,
       isSuccess: true,
       isPreviousData: true,
     })
     // Fetched new query
-    expect(states[5]).toMatchObject({
+    expect(states[6]).toMatchObject({
       data: 1,
       isFetching: false,
       isSuccess: true,
@@ -1299,7 +1324,7 @@ describe('useQuery', () => {
 
     await sleep(100)
 
-    expect(states.length).toBe(5)
+    expect(states.length).toBe(6)
 
     // Disabled query
     expect(states[0]).toMatchObject({
@@ -1322,15 +1347,22 @@ describe('useQuery', () => {
       isSuccess: true,
       isPreviousData: true,
     })
-    // Switched query key
+    // Hook state update
     expect(states[3]).toMatchObject({
+      data: 10,
+      isFetching: false,
+      isSuccess: true,
+      isPreviousData: true,
+    })
+    // Refetch
+    expect(states[4]).toMatchObject({
       data: 10,
       isFetching: true,
       isSuccess: true,
       isPreviousData: true,
     })
     // Refetch done
-    expect(states[4]).toMatchObject({
+    expect(states[5]).toMatchObject({
       data: 12,
       isFetching: false,
       isSuccess: true,
@@ -1690,6 +1722,27 @@ describe('useQuery', () => {
     expect(renderedCount).toBe(2)
   })
 
+  it('should render latest data even if react has discarded certain renders', async () => {
+    const key = queryKey()
+
+    function Page() {
+      const [, setNewState] = React.useState('state')
+      const state = useQuery(key, () => 'data')
+      React.useEffect(() => {
+        setActTimeout(() => {
+          queryClient.setQueryData(key, 'new')
+          // Update with same state to make react discard the next render
+          setNewState('state')
+        }, 10)
+      }, [])
+      return <div>{state.data}</div>
+    }
+
+    const rendered = renderWithClient(queryClient, <Page />)
+
+    await waitFor(() => rendered.getByText('new'))
+  })
+
   // See https://github.com/tannerlinsley/react-query/issues/170
   it('should start with status idle if enabled is false', async () => {
     const key1 = queryKey()
@@ -2163,11 +2216,13 @@ describe('useQuery', () => {
 
     await sleep(100)
 
-    expect(states.length).toBe(2)
+    expect(states.length).toBe(3)
     // Initial
     expect(states[0]).toMatchObject({ data: { count: 0 } })
     // Set state
     expect(states[1]).toMatchObject({ data: { count: 1 } })
+    // Hook state update
+    expect(states[2]).toMatchObject({ data: { count: 1 } })
   })
 
   it('should retry specified number of times', async () => {