diff --git a/.changeset/calm-wolves-reconcile.md b/.changeset/calm-wolves-reconcile.md new file mode 100644 index 00000000000..66e2122ffac --- /dev/null +++ b/.changeset/calm-wolves-reconcile.md @@ -0,0 +1,5 @@ +--- +'@tanstack/solid-query': patch +--- + +Avoid calling custom reconciliation when query data is missing or unchanged. diff --git a/packages/solid-query/src/__tests__/useQuery.test.tsx b/packages/solid-query/src/__tests__/useQuery.test.tsx index f1ce24c4256..fe494f31785 100644 --- a/packages/solid-query/src/__tests__/useQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test.tsx @@ -944,6 +944,81 @@ describe('useQuery', () => { return null }) + it('should only reconcile when observer data changes', async () => { + const key = queryKey() + const firstData = { count: 1 } + const secondData = { count: 2 } + const thirdData = { count: 3 } + const queryResults = [firstData, secondData, secondData, thirdData] + const reconciliationInputs: Array<[number | undefined, number]> = [] + let fetchCount = 0 + const reconcileData = vi.fn( + (oldData: { count: number } | undefined, newData: { count: number }) => { + reconciliationInputs.push([oldData?.count, newData.count]) + return reconcile(newData)(oldData) + }, + ) + + function Page() { + const state = useQuery(() => ({ + queryKey: key, + queryFn: () => sleep(10).then(() => queryResults[fetchCount++]!), + reconcile: reconcileData, + })) + + return ( +
+ + data: {state.data?.count} + fetch status: {state.fetchStatus} +
+ ) + } + + const rendered = renderWithClient(queryClient, () => ) + + expect(reconcileData).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: 1')).toBeInTheDocument() + expect(reconcileData).toHaveBeenCalledTimes(1) + + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('fetch status: fetching')).toBeInTheDocument() + expect(reconcileData).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: 2')).toBeInTheDocument() + expect(rendered.getByText('fetch status: idle')).toBeInTheDocument() + expect(reconcileData).toHaveBeenCalledTimes(2) + + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('fetch status: fetching')).toBeInTheDocument() + expect(reconcileData).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: 2')).toBeInTheDocument() + expect(rendered.getByText('fetch status: idle')).toBeInTheDocument() + expect(reconcileData).toHaveBeenCalledTimes(2) + + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('fetch status: fetching')).toBeInTheDocument() + expect(reconcileData).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: 3')).toBeInTheDocument() + expect(rendered.getByText('fetch status: idle')).toBeInTheDocument() + expect(reconcileData).toHaveBeenCalledTimes(3) + expect(reconciliationInputs).toEqual([ + [undefined, 1], + [1, 2], + [2, 3], + ]) + }) + it('should use query function from hook when the existing query does not have a query function', async () => { const key = queryKey() const results: Array> = [] diff --git a/packages/solid-query/src/useBaseQuery.ts b/packages/solid-query/src/useBaseQuery.ts index 1cc45a420ba..2a7ff11e3f8 100644 --- a/packages/solid-query/src/useBaseQuery.ts +++ b/packages/solid-query/src/useBaseQuery.ts @@ -31,9 +31,15 @@ function reconcileFn( | string | false | ((oldData: TData | undefined, newData: TData) => TData), + observerDataChanged: boolean, queryHash?: string, ): QueryObserverResult { - if (reconcileOption === false) return result + if (reconcileOption === false || result.data === undefined) { + return result + } + if (!observerDataChanged) { + return { ...result, data: store.data } as typeof result + } if (typeof reconcileOption === 'function') { const newData = reconcileOption(store.data, result.data as TData) return { ...result, data: newData } as typeof result @@ -143,6 +149,11 @@ export function useBaseQuery< ) let observerResult = observer().getOptimisticResult(defaultedOptions()) + // Reconciliation can retain the store's data reference after the observer + // moves to new data, so status-only updates must compare observer references. + let lastDataObserver = observer() + let lastDataQueryHash = lastDataObserver.getCurrentQuery().queryHash + let lastObserverData = observerResult.data const [state, setState] = createStore>(observerResult) @@ -188,7 +199,13 @@ export function useBaseQuery< } function setStateWithReconciliation(res: typeof observerResult) { - const opts = observer().options + const currentObserver = observer() + const opts = currentObserver.options + const queryHash = currentObserver.getCurrentQuery().queryHash + const observerDataChanged = + currentObserver !== lastDataObserver || + queryHash !== lastDataQueryHash || + res.data !== lastObserverData // @ts-expect-error - Reconcile option is not correctly typed internally const reconcileOptions = opts.reconcile @@ -197,9 +214,13 @@ export function useBaseQuery< store, res, reconcileOptions === undefined ? false : reconcileOptions, - opts.queryHash, + observerDataChanged, + queryHash, ) }) + lastDataObserver = currentObserver + lastDataQueryHash = queryHash + lastObserverData = res.data } function createDeepSignal(): Signal {