Repository: tannerlinsley/react-query (ts)

Issue:
select runs twice
### Describe the bug

a memoized select function runs twice under some circumstances, even though it should only run once.

circumstances:

- query key stays the same
- but select becomes a new value

### Your Example Website or App

https://codesandbox.io/s/select-runs-twice-bf3pp

### Steps to reproduce

1. click the button
2. observe the console.log - it should only execute select once.

### Expected behavior

select should only be executed once per input

### How often does this bug happen?

Every time

### Screenshots or Videos

_No response_

### Platform

all


### Additional context

if the query key changes with the input, select is correctly only executed once.

in this sandbox, I've only added `input` to the query key and things work:

https://codesandbox.io/s/select-runs-twice-forked-qrtqm?file=/src/App.tsx

Pull request description:
fix(queryObserver): make sure that memoized select function only runs once per input
closes #3095 

we compute the result optimistically, and then one more time when calling setOptions. Both times run the select function because only setOptions will update the previous result and previous options. storing the "previous" selectFn separately on the observer whenever it was invoked (independent of the fact from where it was called) side-steps the issue

Patch:
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index 0e412f632..7dd759727 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -69,6 +69,7 @@ export class QueryObserver<
   >
   private previousQueryResult?: QueryObserverResult<TData, TError>
   private previousSelectError: Error | null
+  private previousSelectFn?: (data: TQueryData) => TData
   private staleTimeoutId?: number
   private refetchIntervalId?: number
   private currentRefetchInterval?: number | false
@@ -496,12 +497,13 @@ export class QueryObserver<
       if (
         prevResult &&
         state.data === prevResultState?.data &&
-        options.select === prevResultOptions?.select &&
+        options.select === this.previousSelectFn &&
         !this.previousSelectError
       ) {
         data = prevResult.data
       } else {
         try {
+          this.previousSelectFn = options.select
           data = options.select(state.data)
           if (options.structuralSharing !== false) {
             data = replaceEqualDeep(prevResult?.data, data)


Test patch:
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index 54b1f40a1..7400ebee0 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -3951,6 +3951,53 @@ describe('useQuery', () => {
     expect(placeholderFunctionRunCount).toEqual(1)
   })
 
+  it('select should only run when dependencies change if memoized', async () => {
+    const key1 = queryKey()
+
+    let selectRun = 0
+
+    function Page() {
+      const [count, inc] = React.useReducer(prev => prev + 1, 2)
+
+      const state = useQuery(
+        key1,
+        async () => {
+          await sleep(10)
+          return 0
+        },
+        {
+          select: React.useCallback(
+            (data: number) => {
+              selectRun++
+              return `selected ${data + count}`
+            },
+            [count]
+          ),
+          placeholderData: 99,
+        }
+      )
+
+      return (
+        <div>
+          <h2>Data: {state.data}</h2>
+          <button onClick={inc}>inc: {count}</button>
+        </div>
+      )
+    }
+
+    const rendered = renderWithClient(queryClient, <Page />)
+    await waitFor(() => rendered.getByText('Data: selected 101')) // 99 + 2
+    expect(selectRun).toBe(1)
+
+    await waitFor(() => rendered.getByText('Data: selected 2')) // 0 + 2
+    expect(selectRun).toBe(2)
+
+    rendered.getByRole('button', { name: /inc/i }).click()
+
+    await waitFor(() => rendered.getByText('Data: selected 3')) // 0 + 3
+    expect(selectRun).toBe(3)
+  })
+
   it('should cancel the query function when there are no more subscriptions', async () => {
     const key = queryKey()
     let cancelFn: jest.Mock = jest.fn()