Skip to content
Closed
50 changes: 50 additions & 0 deletions packages/query-core/src/__tests__/queriesObserver.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,4 +394,54 @@ describe('queriesObserver', () => {
{ status: 'success', data: 102 },
])
})

test('combine with stable reference should invalidate cache when query count changes', async () => {
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)

const combine = vi.fn((results) => results)

const observer = new QueriesObserver(
queryClient,
[{ queryKey: key1, queryFn: queryFn1 }],
{ combine },
)

const results: Array<Array<QueryObserverResult>> = []
const unsubscribe = observer.subscribe((result) => {
results.push(result)
})

try {
await vi.advanceTimersByTimeAsync(0)

const initialCallCount = combine.mock.calls.length
const baselineResults = results.length

observer.setQueries(
[
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
],
{ combine },
)

await vi.advanceTimersByTimeAsync(0)

expect(combine.mock.calls.length).toBeGreaterThan(initialCallCount)

expect(
combine.mock.calls.some(
(call) => Array.isArray(call[0]) && call[0].length === 2,
),
).toBe(true)

expect(results.length).toBeGreaterThan(baselineResults)
expect(results[results.length - 1]).toHaveLength(2)
} finally {
unsubscribe()
}
})
})
5 changes: 5 additions & 0 deletions packages/query-core/src/queriesObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export class QueriesObserver<
#combinedResult?: TCombinedResult
#lastCombine?: CombineFn<TCombinedResult>
#lastResult?: Array<QueryObserverResult>
// Tracks the last input passed to #combineResult to detect query count changes in optimistic updates
#lastInput?: Array<QueryObserverResult>
#observerMatches: Array<QueryObserverMatch> = []

constructor(
Expand Down Expand Up @@ -216,10 +218,13 @@ export class QueriesObserver<
if (
!this.#combinedResult ||
this.#result !== this.#lastResult ||
// Compare input.length to handle optimistic updates where input differs from this.#result
input.length !== this.#lastInput?.length ||
Comment on lines +221 to +222
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think a length check is what we want here. What about situations where the length is the same, but data inside it changed?

I’m honestly not sure why input would change, but the check for this.#result !== this.#lastResult would still see things as being equal. That’s what we need to find out imo

Copy link
Contributor Author

@joseph0926 joseph0926 Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m honestly not sure why input would change, but the check for this.#result !== this.#lastResult would still see things as being equal. That’s what we need to find out imo

When getOptimisticResult is called, a new input is created—but this happens at render time.

// note: this must be called before useSyncExternalStore
const [optimisticResult, getCombinedResult, trackResult] =
observer.getOptimisticResult(
defaultedQueries,
(options as QueriesObserverOptions<TCombinedResult>).combine,
)

On the other hand, observer.setQueries is executed inside useEffect, which means it runs at commit time.

React.useEffect(() => {
observer.setQueries(
defaultedQueries,
options as QueriesObserverOptions<TCombinedResult>,
)
}, [defaultedQueries, options, observer])

In other words, at the moment getOptimisticResult is called, the input already reflects the new value, but this.#result still holds the previous value.

Because of this timing difference, the following sequence occurs

First render

  1. this.#result = [], this.#lastResult = undefinedgetOptimisticResult is called → input = [] is created
  2. #combineResult is called → since this.#lastResult = this.#result, the result becomes this.#result = [], this.#lastResult = []
  3. useEffect() { setQueries ... } runs

Second render
4. getOptimisticResult is called → input = [query1], while this.#result = [], this.#lastResult = []
5. #combineResult is called → still this.#result === this.#lastResult ([] === [])
6. useEffect() { setQueries ... } runs → this.#result = [query1]


For this reason, a discrepancy arises between the result and the input
From a semantic perspective, it would indeed be more accurate to add a comparison such as,
input !== this.#result && input !== this.#lastInput

However, while this approach handles optimistic updates well, I believe it could also cause excessive cache misses in regular updates. That is why I chose the alternative solution of comparing lengths instead

Regarding your concern about edge cases—such as when the arrays have the same length but different order—I confirmed through the additional tests I wrote ("should handle same length but different queries" and "should handle query order changes with same length") that no issues arise there. Based on that, I decided to adopt this solution as the final choice.

That said, as you pointed out, this method is ultimately more of a stopgap—it works well for optimistic updates while avoiding excessive cache misses in regular updates, but it remains a temporary measure.


Aside from this temporary workaround, I think a better solution would be to explicitly distinguish the case using a flag.

For example, something like this

#combineResult(
  input: Array<QueryObserverResult>,
  combine: CombineFn<TCombinedResult> | undefined,
  isOptimisticUpdate = false
): TCombinedResult {
  if (combine) {
    if (
      !this.#combinedResult ||
      this.#result !== this.#lastResult ||
      (isOptimisticUpdate && input !== this.#lastInput) ||
      combine !== this.#lastCombine
    ) {
      // ...
    }
  }
}

Perhaps an approach along these lines?

But come to think of it, this method would still cause additional cache misses,,

For example, the expected values for the three tests below seem likely to increase from 3 to 5.

useQueries > should optimize combine if it is a stable reference
useQueries > should re-run combine if the functional reference changes
useQueries > should not re-run stable combine on unrelated re-render

So my final thought is that comparing lengths seems like the best choice when not considering overall mechanism modifications. What do you think?

combine !== this.#lastCombine
) {
this.#lastCombine = combine
this.#lastResult = this.#result
this.#lastInput = input
this.#combinedResult = replaceEqualDeep(
this.#combinedResult,
combine(input),
Expand Down
319 changes: 319 additions & 0 deletions packages/react-query/src/__tests__/useQueries-combine.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
import { describe, expect, test } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import React from 'react'
import {
QueryClient,
QueryClientProvider,
useQueries,
} from '@tanstack/react-query'

describe('useQueries combine memoization in React', () => {
test('stable reference combine should update immediately when queries change', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})

const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)

const stableCombine = (results: any) => results

const { result, rerender } = renderHook(
({ n }: { n: number }) => {
const queries = useQueries({
queries: [...Array(n).keys()].map((i) => ({
queryKey: ['stable', i],
queryFn: () => Promise.resolve(i + 100),
})),
combine: stableCombine,
})

return queries
},
{
wrapper,
initialProps: { n: 0 },
},
)

expect(result.current.length).toBe(0)

rerender({ n: 1 })
expect(result.current.length).toBe(1)

rerender({ n: 2 })
expect(result.current.length).toBe(2)
})

test('inline combine should update immediately when queries change', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})

const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)

const { result, rerender } = renderHook(
({ n }: { n: number }) => {
const queries = useQueries({
queries: [...Array(n).keys()].map((i) => ({
queryKey: ['inline', i],
queryFn: () => Promise.resolve(i + 100),
})),
combine: (results) => results,
})

return queries
},
{
wrapper,
initialProps: { n: 0 },
},
)

expect(result.current.length).toBe(0)

rerender({ n: 1 })
expect(result.current.length).toBe(1)

rerender({ n: 2 })
expect(result.current.length).toBe(2)
})
})

describe('useQueries combine memoization edge cases', () => {
test('should handle dynamic query array changes correctly', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})

const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)

const stableCombine = (results: any) => results

const { result, rerender } = renderHook(
({ ids }: { ids: Array<number> }) => {
const queries = useQueries({
queries: ids.map((id) => ({
queryKey: ['test', id],
queryFn: () => Promise.resolve(id),
})),
combine: stableCombine,
})
return queries
},
{
wrapper,
initialProps: { ids: [] } as { ids: Array<number> },
},
)

expect(result.current.length).toBe(0)

rerender({ ids: [1, 2, 3] })
expect(result.current.length).toBe(3)

rerender({ ids: [2, 3] })
expect(result.current.length).toBe(2)

rerender({ ids: [2, 3, 4, 5] })
expect(result.current.length).toBe(4)

rerender({ ids: [] })
expect(result.current.length).toBe(0)
})

test('should handle combine function that transforms data', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})

const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)

const transformCombine = (results: any) => ({
count: results.length,
data: results,
})

const { result, rerender } = renderHook(
({ n }: { n: number }) => {
const queries = useQueries({
queries: [...Array(n).keys()].map((i) => ({
queryKey: ['transform', i],
queryFn: () => Promise.resolve(i),
})),
combine: transformCombine,
})
return queries
},
{
wrapper,
initialProps: { n: 0 },
},
)

expect(result.current.count).toBe(0)

rerender({ n: 2 })
expect(result.current.count).toBe(2)
expect(result.current.data.length).toBe(2)

rerender({ n: 5 })
expect(result.current.count).toBe(5)
expect(result.current.data.length).toBe(5)
})

test('should not break when switching between stable and inline combine', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})

const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)

const stableCombine = (results: any) => results

const { result, rerender } = renderHook(
({ useStable }: { useStable: boolean }) => {
const queries = useQueries({
queries: [
{
queryKey: ['switch', 1],
queryFn: () => Promise.resolve(1),
},
],
combine: useStable ? stableCombine : (results) => results,
})
return queries
},
{
wrapper,
initialProps: { useStable: true },
},
)

expect(result.current.length).toBe(1)

rerender({ useStable: false })
expect(result.current.length).toBe(1)

rerender({ useStable: true })
expect(result.current.length).toBe(1)
})

test('should handle same length but different queries', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})

const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)

const stableCombine = (results: any) => results

const { result, rerender } = renderHook(
({ keys }: { keys: Array<string> }) => {
const queries = useQueries({
queries: keys.map((key) => ({
queryKey: [key],
queryFn: () => Promise.resolve(key),
})),
combine: stableCombine,
})
return queries
},
{
wrapper,
initialProps: { keys: ['a', 'b'] },
},
)

expect(result.current.length).toBe(2)

rerender({ keys: ['c', 'd'] })
expect(result.current.length).toBe(2)

// Note: Same-length changes may use cached result for one render cycle,
// but data will be correct after setQueries updates this.#result
await waitFor(() => {
expect(result.current[0]?.data).toBe('c')
expect(result.current[1]?.data).toBe('d')
})
})

test('should handle query order changes with same length', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})

const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)

const stableCombine = (results: any) => results

const { result, rerender } = renderHook(
({ keys }: { keys: Array<string> }) => {
const queries = useQueries({
queries: keys.map((key) => ({
queryKey: [key],
queryFn: () => Promise.resolve(key),
})),
combine: stableCombine,
})
return queries
},
{
wrapper,
initialProps: { keys: ['x', 'y', 'z'] },
},
)

expect(result.current.length).toBe(3)

rerender({ keys: ['z', 'x', 'y'] })
expect(result.current.length).toBe(3)

await waitFor(() => {
expect(result.current[0]?.data).toBe('z')
expect(result.current[1]?.data).toBe('x')
expect(result.current[2]?.data).toBe('y')
})
})
})
Loading