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 ||
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