Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-wolves-reconcile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/solid-query': patch
---

Avoid calling custom reconciliation when query data is missing or unchanged.
75 changes: 75 additions & 0 deletions packages/solid-query/src/__tests__/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<button onClick={() => state.refetch()}>refetch</button>
<span>data: {state.data?.count}</span>
<span>fetch status: {state.fetchStatus}</span>
</div>
)
}

const rendered = renderWithClient(queryClient, () => <Page />)

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<UseQueryResult<string>> = []
Expand Down
27 changes: 24 additions & 3 deletions packages/solid-query/src/useBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,15 @@ function reconcileFn<TData, TError>(
| string
| false
| ((oldData: TData | undefined, newData: TData) => TData),
observerDataChanged: boolean,
queryHash?: string,
): QueryObserverResult<TData, TError> {
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
Expand Down Expand Up @@ -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<QueryObserverResult<TData, TError>>(observerResult)

Expand Down Expand Up @@ -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

Expand All @@ -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<T>(): Signal<T> {
Expand Down