From 504f2e63a785cdd6035713ae17526a9b55f77b1d Mon Sep 17 00:00:00 2001 From: Ngoc Le Date: Sat, 22 Aug 2026 20:36:14 +0700 Subject: [PATCH 1/2] fix(solid-query): avoid redundant reconciliation --- .changeset/calm-wolves-reconcile.md | 5 +++ .../src/__tests__/useQuery.test.tsx | 41 +++++++++++++++++++ packages/solid-query/src/useBaseQuery.ts | 8 +++- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .changeset/calm-wolves-reconcile.md diff --git a/.changeset/calm-wolves-reconcile.md b/.changeset/calm-wolves-reconcile.md new file mode 100644 index 0000000000..66e2122ffa --- /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 f1ce24c425..4cc586a5e4 100644 --- a/packages/solid-query/src/__tests__/useQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test.tsx @@ -944,6 +944,47 @@ describe('useQuery', () => { return null }) + it('should only reconcile when query data changes', async () => { + const key = queryKey() + let count = 0 + const reconcileData = vi.fn( + (_oldData: { count: number } | undefined, newData: { count: number }) => + newData, + ) + + function Page() { + const state = useQuery(() => ({ + queryKey: key, + queryFn: () => sleep(10).then(() => ({ count: ++count })), + reconcile: reconcileData, + })) + + return ( +
+ + data: {state.data?.count} +
+ ) + } + + const rendered = renderWithClient(queryClient, () => ) + + expect(reconcileData).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: 1')).toBeInTheDocument() + expect(reconcileData).toHaveBeenCalledTimes(1) + expect(reconcileData).toHaveBeenLastCalledWith(undefined, { count: 1 }) + + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + expect(reconcileData).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: 2')).toBeInTheDocument() + expect(reconcileData).toHaveBeenCalledTimes(2) + expect(reconcileData).toHaveBeenLastCalledWith({ count: 1 }, { count: 2 }) + }) + 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 1cc45a420b..2cb3d47f18 100644 --- a/packages/solid-query/src/useBaseQuery.ts +++ b/packages/solid-query/src/useBaseQuery.ts @@ -33,7 +33,13 @@ function reconcileFn( | ((oldData: TData | undefined, newData: TData) => TData), queryHash?: string, ): QueryObserverResult { - if (reconcileOption === false) return result + if ( + reconcileOption === false || + result.data === undefined || + result.data === store.data + ) { + return result + } if (typeof reconcileOption === 'function') { const newData = reconcileOption(store.data, result.data as TData) return { ...result, data: newData } as typeof result From 154f80dcb8b57163d56c8ff07252a6bc32467d6a Mon Sep 17 00:00:00 2001 From: Ngoc Le Date: Sat, 22 Aug 2026 20:53:28 +0700 Subject: [PATCH 2/2] fix(solid-query): track observer data references --- .../src/__tests__/useQuery.test.tsx | 48 ++++++++++++++++--- packages/solid-query/src/useBaseQuery.ts | 29 ++++++++--- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/packages/solid-query/src/__tests__/useQuery.test.tsx b/packages/solid-query/src/__tests__/useQuery.test.tsx index 4cc586a5e4..fe494f3178 100644 --- a/packages/solid-query/src/__tests__/useQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test.tsx @@ -944,18 +944,25 @@ describe('useQuery', () => { return null }) - it('should only reconcile when query data changes', async () => { + it('should only reconcile when observer data changes', async () => { const key = queryKey() - let count = 0 + 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 }) => - newData, + (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(() => ({ count: ++count })), + queryFn: () => sleep(10).then(() => queryResults[fetchCount++]!), reconcile: reconcileData, })) @@ -963,6 +970,7 @@ describe('useQuery', () => {
data: {state.data?.count} + fetch status: {state.fetchStatus}
) } @@ -974,15 +982,41 @@ describe('useQuery', () => { await vi.advanceTimersByTimeAsync(10) expect(rendered.getByText('data: 1')).toBeInTheDocument() expect(reconcileData).toHaveBeenCalledTimes(1) - expect(reconcileData).toHaveBeenLastCalledWith(undefined, { count: 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) - expect(reconcileData).toHaveBeenLastCalledWith({ count: 1 }, { count: 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 () => { diff --git a/packages/solid-query/src/useBaseQuery.ts b/packages/solid-query/src/useBaseQuery.ts index 2cb3d47f18..2a7ff11e3f 100644 --- a/packages/solid-query/src/useBaseQuery.ts +++ b/packages/solid-query/src/useBaseQuery.ts @@ -31,15 +31,15 @@ function reconcileFn( | string | false | ((oldData: TData | undefined, newData: TData) => TData), + observerDataChanged: boolean, queryHash?: string, ): QueryObserverResult { - if ( - reconcileOption === false || - result.data === undefined || - result.data === store.data - ) { + 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 @@ -149,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) @@ -194,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 @@ -203,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 {