diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 123996e572..1a14679a19 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -240,7 +240,28 @@ export class QueryObserver< ): QueryObserverResult { const query = this.client.getQueryCache().build(this.client, options) - return this.createResult(query, options) + const result = this.createResult(query, options); + + if (!options.keepPreviousData) { + // this assigns the optimistic result to the current Observer + // because if the query function changes, useQuery will be performing + // an effect where it would fetch again. + // When the fetch finishes, we perform a deep data cloning in order + // to reuse objects references. This deep data clone is performed against + // the `observer.currentResult.data` property + // When QueryKey changes, we refresh the query and get new `optimistic` + // result, while we leave the `observer.currentResult`, so when new data + // arrives, it finds the old `observer.currentResult` which is related + // to the old QueryKey. Which means that currentResult and selectData are + // out of sync already. + // To solve this, we move the cursor of the currentResult everytime + // an observer reads an optimistic value. + + // When keeping the previous data, the result doesn't change until new + // data arrives. + this.currentResult = result + } + return result } getCurrentResult(): QueryObserverResult { diff --git a/packages/react-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx b/packages/react-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx index 3aa840221f..bce3257568 100644 --- a/packages/react-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx +++ b/packages/react-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx @@ -325,7 +325,7 @@ describe('PersistQueryClientProvider', () => { await waitFor(() => rendered.getByText('data: null')) await waitFor(() => rendered.getByText('data: hydrated')) - expect(states).toHaveLength(3) + expect(states).toHaveLength(2) expect(fetched).toBe(false) @@ -340,9 +340,6 @@ describe('PersistQueryClientProvider', () => { fetchStatus: 'idle', data: 'hydrated', }) - - // #5443 seems like we get an extra render now ... - expect(states[1]).toStrictEqual(states[2]) }) test('should call onSuccess after successful restoring', async () => { diff --git a/packages/react-query/src/__tests__/useQuery.test.tsx b/packages/react-query/src/__tests__/useQuery.test.tsx index 2fb62e5b45..588942a9a2 100644 --- a/packages/react-query/src/__tests__/useQuery.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.test.tsx @@ -914,7 +914,7 @@ describe('useQuery', () => { // required to make sure no additional renders are happening after data is successfully fetched for the second time await sleep(100) - expect(states.length).toBe(5) + expect(states.length).toBe(4) // First load expect(states[0]).toMatchObject({ isLoading: true, isSuccess: false }) // First success @@ -922,9 +922,11 @@ describe('useQuery', () => { // Remove expect(states[2]).toMatchObject({ isLoading: true, isSuccess: false }) // Hook state update - expect(states[3]).toMatchObject({ isLoading: true, isSuccess: false }) + // this update is now skipped ! because nothing changed + // see github issue: https://github.com/TanStack/query/issues/5538 + // expect(states[3]).toMatchObject({ isLoading: true, isSuccess: false }) // Second success - expect(states[4]).toMatchObject({ isLoading: false, isSuccess: true }) + expect(states[3]).toMatchObject({ isLoading: false, isSuccess: true }) }) it('should fetch when refetchOnMount is false and nothing has been fetched yet', async () => { @@ -3650,6 +3652,7 @@ describe('useQuery', () => { ) act(() => setPrefetched(true)) } + prefetch() }, []) @@ -5879,6 +5882,7 @@ describe('useQuery', () => { ) } + const rendered = renderWithClient(queryClient, ) const fetchBtn = rendered.getByRole('button', { name: 'refetch' }) await waitFor(() => rendered.getByText('data: 1')) @@ -5916,8 +5920,84 @@ describe('useQuery', () => { ) } + const rendered = renderWithClient(queryClient, ) await waitFor(() => rendered.getByText('status: success')) await waitFor(() => rendered.getByText('data: 1')) }) + it('should reuse same data object reference when queryKey changes back to some cached data', async () => { + jest.useFakeTimers() + const spy = jest.fn() + + function fetchNumber(id: number) { + return new Promise((resolve) => { + setTimeout( + () => + resolve({ + numbers: { + current: { + id, + }, + }, + }), + 1000, + ) + }) + } + function Test() { + const [id, setId] = React.useState(1) + + const { data } = useQuery({ + select: selector, + queryKey: ['user', id], + queryFn: () => fetchNumber(id), + }) + + React.useEffect(() => { + spy(data) + }, [data]) + + return ( +
+ + + Rendered Id: {data?.id} +
+ ) + } + + function selector(data: any) { + return data.numbers.current + } + + const rendered = renderWithClient(queryClient, ) + expect(spy).toHaveBeenCalledTimes(1) + spy.mockClear() + jest.advanceTimersByTime(1000) + await waitFor(() => rendered.getByText('Rendered Id: 1')) + expect(spy).toHaveBeenCalledTimes(1) + spy.mockClear() + + fireEvent.click(rendered.getByRole('button', { name: /2/ })) + jest.advanceTimersByTime(1000) + await waitFor(() => rendered.getByText('Rendered Id: 2')) + expect(spy).toHaveBeenCalledTimes(2) // called with undefined because id changed + spy.mockClear() + + fireEvent.click(rendered.getByRole('button', { name: /1/ })) + jest.advanceTimersByTime(1000) + await waitFor(() => rendered.getByText('Rendered Id: 1')) + expect(spy).toHaveBeenCalledTimes(1) + spy.mockClear() + + fireEvent.click(rendered.getByRole('button', { name: /2/ })) + jest.advanceTimersByTime(1000) + await waitFor(() => rendered.getByText('Rendered Id: 2')) + expect(spy).toHaveBeenCalledTimes(1) + spy.mockClear() + }) })