diff --git a/packages/query-core/src/__tests__/queriesObserver.test.tsx b/packages/query-core/src/__tests__/queriesObserver.test.tsx index da81daa653..b8daa2c4ab 100644 --- a/packages/query-core/src/__tests__/queriesObserver.test.tsx +++ b/packages/query-core/src/__tests__/queriesObserver.test.tsx @@ -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> = [] + 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() + } + }) }) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 6458f208da..186067b02e 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -43,6 +43,8 @@ export class QueriesObserver< #combinedResult?: TCombinedResult #lastCombine?: CombineFn #lastResult?: Array + // Tracks the last input passed to #combineResult to detect query count changes in optimistic updates + #lastInput?: Array #observerMatches: Array = [] constructor( @@ -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), diff --git a/packages/react-query/src/__tests__/useQueries-combine.test.tsx b/packages/react-query/src/__tests__/useQueries-combine.test.tsx new file mode 100644 index 0000000000..f18b78ce62 --- /dev/null +++ b/packages/react-query/src/__tests__/useQueries-combine.test.tsx @@ -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 }) => ( + {children} + ) + + 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 }) => ( + {children} + ) + + 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 }) => ( + {children} + ) + + const stableCombine = (results: any) => results + + const { result, rerender } = renderHook( + ({ ids }: { ids: Array }) => { + const queries = useQueries({ + queries: ids.map((id) => ({ + queryKey: ['test', id], + queryFn: () => Promise.resolve(id), + })), + combine: stableCombine, + }) + return queries + }, + { + wrapper, + initialProps: { ids: [] } as { ids: Array }, + }, + ) + + 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 }) => ( + {children} + ) + + 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 }) => ( + {children} + ) + + 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 }) => ( + {children} + ) + + const stableCombine = (results: any) => results + + const { result, rerender } = renderHook( + ({ keys }: { keys: Array }) => { + 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 }) => ( + {children} + ) + + const stableCombine = (results: any) => results + + const { result, rerender } = renderHook( + ({ keys }: { keys: Array }) => { + 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') + }) + }) +})