Repository: tannerlinsley/react-query
Pull request #1751 (2021-02-05 08:27:58)

feat: run selector if reference changes to enable use of dependencies
Fixes https://github.com/tannerlinsley/react-query/issues/1639

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/gpl2v1h2b](https://vercel.com/tannerlinsley/react-query/gpl2v1h2b)  
✅ Preview: [https://react-query-git-feature-select-deps.tannerlinsley.vercel.app](https://react-query-git-feature-select-deps.tannerlinsley.vercel.app)



Patch:
diff --git a/src/core/onlineManager.ts b/src/core/onlineManager.ts
index 922cc3e69..fc0c8362e 100644
--- a/src/core/onlineManager.ts
+++ b/src/core/onlineManager.ts
@@ -45,7 +45,14 @@ class OnlineManager extends Subscribable {
       return this.online
     }
 
-    return navigator.onLine === undefined || navigator.onLine
+    if (
+      typeof navigator === 'undefined' ||
+      typeof navigator.onLine === 'undefined'
+    ) {
+      return true
+    }
+
+    return navigator.onLine
   }
 
   private setDefaultEventListener() {
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index 13938952c..4c3518d15 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -49,6 +49,12 @@ export class QueryObserver<
   private currentQuery!: Query<TQueryFnData, TError, TQueryData>
   private currentResult!: QueryObserverResult<TData, TError>
   private currentResultState?: QueryState<TQueryData, TError>
+  private previousOptions?: QueryObserverOptions<
+    TQueryFnData,
+    TError,
+    TData,
+    TQueryData
+  >
   private previousQueryResult?: QueryObserverResult<TData, TError>
   private initialDataUpdateCount: number
   private initialErrorUpdateCount: number
@@ -155,8 +161,7 @@ export class QueryObserver<
   setOptions(
     options?: QueryObserverOptions<TQueryFnData, TError, TData, TQueryData>
   ): void {
-    const prevOptions = this.options
-
+    this.previousOptions = this.options
     this.options = this.client.defaultQueryObserverOptions(options)
 
     if (
@@ -168,39 +173,49 @@ export class QueryObserver<
 
     // Keep previous query key if the user does not supply one
     if (!this.options.queryKey) {
-      this.options.queryKey = prevOptions.queryKey
+      this.options.queryKey = this.previousOptions.queryKey
     }
 
     const didUpdateQuery = this.updateQuery()
 
     let optionalFetch
+    let updateResult
     let updateStaleTimeout
     let updateRefetchInterval
 
-    // If we subscribed to a new query, optionally fetch and update intervals
+    // If we subscribed to a new query, optionally fetch and update result and timers
     if (didUpdateQuery) {
       optionalFetch = true
+      updateResult = true
       updateStaleTimeout = true
       updateRefetchInterval = true
     }
 
     // Optionally fetch if the query became enabled
-    if (this.options.enabled !== false && prevOptions.enabled === false) {
+    if (
+      this.options.enabled !== false &&
+      this.previousOptions.enabled === false
+    ) {
       optionalFetch = true
     }
 
+    // Update result if the select function changed
+    if (this.options.select !== this.previousOptions.select) {
+      updateResult = true
+    }
+
     // Update stale interval if needed
     if (
-      this.options.enabled !== prevOptions.enabled ||
-      this.options.staleTime !== prevOptions.staleTime
+      this.options.enabled !== this.previousOptions.enabled ||
+      this.options.staleTime !== this.previousOptions.staleTime
     ) {
       updateStaleTimeout = true
     }
 
     // Update refetch interval if needed
     if (
-      this.options.enabled !== prevOptions.enabled ||
-      this.options.refetchInterval !== prevOptions.refetchInterval
+      this.options.enabled !== this.previousOptions.enabled ||
+      this.options.refetchInterval !== this.previousOptions.refetchInterval
     ) {
       updateRefetchInterval = true
     }
@@ -212,8 +227,7 @@ export class QueryObserver<
       }
     }
 
-    // Update result when subscribing to a new query
-    if (didUpdateQuery) {
+    if (updateResult) {
       this.updateResult()
     }
 
@@ -399,8 +413,12 @@ export class QueryObserver<
     }
     // Select data if needed
     else if (this.options.select && typeof state.data !== 'undefined') {
-      // Use the previous select result if the query data did not change
-      if (this.currentResult && state.data === this.currentResultState?.data) {
+      // Use the previous select result if the query data and select function did not change
+      if (
+        this.currentResult &&
+        state.data === this.currentResultState?.data &&
+        this.options.select === this.previousOptions?.select
+      ) {
         data = this.currentResult.data
       } else {
         data = this.options.select(state.data)
diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts
index c75338b5a..b004a3687 100644
--- a/src/react/useBaseQuery.ts
+++ b/src/react/useBaseQuery.ts
@@ -1,15 +1,18 @@
 import React from 'react'
 
+import { QueryObserverResult } from '../core/types'
 import { notifyManager } from '../core/notifyManager'
 import { QueryObserver } from '../core/queryObserver'
 import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary'
 import { useQueryClient } from './QueryClientProvider'
 import { UseBaseQueryOptions } from './types'
+import { useIsMounted } from './useIsMounted'
 
 export function useBaseQuery<TQueryFnData, TError, TData, TQueryData>(
   options: UseBaseQueryOptions<TQueryFnData, TError, TData, TQueryData>,
   Observer: typeof QueryObserver
 ) {
+  const isMounted = useIsMounted()
   const queryClient = useQueryClient()
   const errorResetBoundary = useQueryErrorResetBoundary()
   const defaultedOptions = queryClient.defaultQueryObserverOptions(options)
@@ -63,8 +66,14 @@ export function useBaseQuery<TQueryFnData, TError, TData, TQueryData>(
   // Subscribe to the observer
   React.useEffect(() => {
     errorResetBoundary.clearReset()
-    return observer.subscribe(notifyManager.batchCalls(setCurrentResult))
-  }, [observer, errorResetBoundary])
+    return observer.subscribe(
+      notifyManager.batchCalls((result: QueryObserverResult) => {
+        if (isMounted()) {
+          setCurrentResult(result)
+        }
+      })
+    )
+  }, [observer, errorResetBoundary, isMounted])
 
   // Handle suspense
   if (observer.options.suspense || observer.options.useErrorBoundary) {
diff --git a/src/react/useIsFetching.ts b/src/react/useIsFetching.ts
index 1c6eb0c50..c7c919f14 100644
--- a/src/react/useIsFetching.ts
+++ b/src/react/useIsFetching.ts
@@ -4,6 +4,7 @@ import { notifyManager } from '../core/notifyManager'
 import { QueryKey } from '../core/types'
 import { parseFilterArgs, QueryFilters } from '../core/utils'
 import { useQueryClient } from './QueryClientProvider'
+import { useIsMounted } from './useIsMounted'
 
 export function useIsFetching(filters?: QueryFilters): number
 export function useIsFetching(
@@ -14,6 +15,7 @@ export function useIsFetching(
   arg1?: QueryKey | QueryFilters,
   arg2?: QueryFilters
 ): number {
+  const isMounted = useIsMounted()
   const queryClient = useQueryClient()
   const [filters] = parseFilterArgs(arg1, arg2)
   const [isFetching, setIsFetching] = React.useState(
@@ -29,13 +31,15 @@ export function useIsFetching(
     () =>
       queryClient.getQueryCache().subscribe(
         notifyManager.batchCalls(() => {
-          const newIsFetching = queryClient.isFetching(filtersRef.current)
-          if (isFetchingRef.current !== newIsFetching) {
-            setIsFetching(newIsFetching)
+          if (isMounted()) {
+            const newIsFetching = queryClient.isFetching(filtersRef.current)
+            if (isFetchingRef.current !== newIsFetching) {
+              setIsFetching(newIsFetching)
+            }
           }
         })
       ),
-    [queryClient]
+    [queryClient, isMounted]
   )
 
   return isFetching
diff --git a/src/react/useIsMounted.ts b/src/react/useIsMounted.ts
new file mode 100644
index 000000000..f1f620f72
--- /dev/null
+++ b/src/react/useIsMounted.ts
@@ -0,0 +1,17 @@
+import React from 'react'
+
+import { isServer } from '../core/utils'
+
+export function useIsMounted() {
+  const mountedRef = React.useRef(false)
+  const isMounted = React.useCallback(() => mountedRef.current, [])
+
+  React[isServer ? 'useEffect' : 'useLayoutEffect'](() => {
+    mountedRef.current = true
+    return () => {
+      mountedRef.current = false
+    }
+  }, [])
+
+  return isMounted
+}
diff --git a/src/react/useMutation.ts b/src/react/useMutation.ts
index 7e65706b7..d5fb6374e 100644
--- a/src/react/useMutation.ts
+++ b/src/react/useMutation.ts
@@ -14,6 +14,7 @@ import {
   MutationKey,
   MutationObserverResult,
 } from '../core/types'
+import { useIsMounted } from './useIsMounted'
 
 // HOOK
 
@@ -68,6 +69,7 @@ export function useMutation<
     | UseMutationOptions<TData, TError, TVariables, TContext>,
   arg3?: UseMutationOptions<TData, TError, TVariables, TContext>
 ): UseMutationResult<TData, TError, TVariables, TContext> {
+  const isMounted = useIsMounted()
   const options = parseMutationArgs(arg1, arg2, arg3)
   const queryClient = useQueryClient()
 
@@ -96,14 +98,13 @@ export function useMutation<
           (
             result: MutationObserverResult<TData, TError, TVariables, TContext>
           ) => {
-            // Check if the component is still mounted
-            if (observer.hasListeners()) {
+            if (isMounted()) {
               setCurrentResult(result)
             }
           }
         )
       ),
-    [observer]
+    [observer, isMounted]
   )
 
   const mutate = React.useCallback<
diff --git a/src/react/useQueries.ts b/src/react/useQueries.ts
index 81774b9ae..1a85857bd 100644
--- a/src/react/useQueries.ts
+++ b/src/react/useQueries.ts
@@ -1,11 +1,14 @@
 import React from 'react'
 
+import { QueryObserverResult } from '../core/types'
 import { notifyManager } from '../core/notifyManager'
 import { QueriesObserver } from '../core/queriesObserver'
 import { useQueryClient } from './QueryClientProvider'
 import { UseQueryOptions, UseQueryResult } from './types'
+import { useIsMounted } from './useIsMounted'
 
 export function useQueries(queries: UseQueryOptions[]): UseQueryResult[] {
+  const isMounted = useIsMounted()
   const queryClient = useQueryClient()
 
   // Create queries observer
@@ -25,8 +28,15 @@ export function useQueries(queries: UseQueryOptions[]): UseQueryResult[] {
 
   // Subscribe to the observer
   React.useEffect(
-    () => observer.subscribe(notifyManager.batchCalls(setCurrentResult)),
-    [observer]
+    () =>
+      observer.subscribe(
+        notifyManager.batchCalls((result: QueryObserverResult[]) => {
+          if (isMounted()) {
+            setCurrentResult(result)
+          }
+        })
+      ),
+    [observer, isMounted]
   )
 
   return currentResult


Test patch:
diff --git a/src/core/tests/queryObserver.test.tsx b/src/core/tests/queryObserver.test.tsx
index a540f98ea..8ffa73cb6 100644
--- a/src/core/tests/queryObserver.test.tsx
+++ b/src/core/tests/queryObserver.test.tsx
@@ -130,6 +130,71 @@ describe('queryObserver', () => {
     expect(observerResult2.data).toMatchObject({ myCount: 1 })
   })
 
+  test('should run the selector again if the selector changed', async () => {
+    const key = queryKey()
+    let count = 0
+    const results: QueryObserverResult[] = []
+    const queryFn = () => ({ count: 1 })
+    const select1 = (data: ReturnType<typeof queryFn>) => {
+      count++
+      return { myCount: data.count }
+    }
+    const select2 = (_data: ReturnType<typeof queryFn>) => {
+      count++
+      return { myCount: 99 }
+    }
+    const observer = new QueryObserver(queryClient, {
+      queryKey: key,
+      queryFn,
+      select: select1,
+    })
+    const unsubscribe = observer.subscribe(result => {
+      results.push(result)
+    })
+    await sleep(1)
+    observer.setOptions({
+      queryKey: key,
+      queryFn,
+      select: select2,
+    })
+    await sleep(1)
+    unsubscribe()
+    expect(count).toBe(2)
+    expect(results.length).toBe(2)
+    expect(results[0]).toMatchObject({ data: { myCount: 1 } })
+    expect(results[1]).toMatchObject({ data: { myCount: 99 } })
+  })
+
+  test('should not run the selector again if the data and selector did not change', async () => {
+    const key = queryKey()
+    let count = 0
+    const results: QueryObserverResult[] = []
+    const queryFn = () => ({ count: 1 })
+    const select = (data: ReturnType<typeof queryFn>) => {
+      count++
+      return { myCount: data.count }
+    }
+    const observer = new QueryObserver(queryClient, {
+      queryKey: key,
+      queryFn,
+      select,
+    })
+    const unsubscribe = observer.subscribe(result => {
+      results.push(result)
+    })
+    await sleep(1)
+    observer.setOptions({
+      queryKey: key,
+      queryFn,
+      select,
+    })
+    await sleep(1)
+    unsubscribe()
+    expect(count).toBe(1)
+    expect(results.length).toBe(1)
+    expect(results[0]).toMatchObject({ data: { myCount: 1 } })
+  })
+
   test('should not run the selector again if the data did not change', async () => {
     const key = queryKey()
     let count = 0
diff --git a/src/react/tests/useInfiniteQuery.test.tsx b/src/react/tests/useInfiniteQuery.test.tsx
index ddc1e25f2..246fe51f4 100644
--- a/src/react/tests/useInfiniteQuery.test.tsx
+++ b/src/react/tests/useInfiniteQuery.test.tsx
@@ -874,6 +874,71 @@ describe('useInfiniteQuery', () => {
     })
   })
 
+  it('should only refetch the first page when initialData is provided', async () => {
+    const key = queryKey()
+    const states: UseInfiniteQueryResult<number>[] = []
+
+    function Page() {
+      const state = useInfiniteQuery(
+        key,
+        async ({ pageParam }) => {
+          await sleep(10)
+          return pageParam
+        },
+        {
+          initialData: { pages: [1], pageParams: [1] },
+          getNextPageParam: lastPage => lastPage + 1,
+        }
+      )
+
+      states.push(state)
+
+      const { fetchNextPage } = state
+
+      React.useEffect(() => {
+        setActTimeout(() => {
+          fetchNextPage()
+        }, 20)
+      }, [fetchNextPage])
+
+      return null
+    }
+
+    renderWithClient(queryClient, <Page />)
+
+    await sleep(100)
+
+    expect(states.length).toBe(4)
+    expect(states[0]).toMatchObject({
+      data: { pages: [1] },
+      hasNextPage: true,
+      isFetching: true,
+      isFetchingNextPage: false,
+      isSuccess: true,
+    })
+    expect(states[1]).toMatchObject({
+      data: { pages: [1] },
+      hasNextPage: true,
+      isFetching: false,
+      isFetchingNextPage: false,
+      isSuccess: true,
+    })
+    expect(states[2]).toMatchObject({
+      data: { pages: [1] },
+      hasNextPage: true,
+      isFetching: true,
+      isFetchingNextPage: true,
+      isSuccess: true,
+    })
+    expect(states[3]).toMatchObject({
+      data: { pages: [1, 2] },
+      hasNextPage: true,
+      isFetching: false,
+      isFetchingNextPage: false,
+      isSuccess: true,
+    })
+  })
+
   it('should set hasNextPage to false if getNextPageParam returns undefined', async () => {
     const key = queryKey()
     const states: UseInfiniteQueryResult<number>[] = []