diff --git a/.changeset/swift-brooms-teach.md b/.changeset/swift-brooms-teach.md new file mode 100644 index 0000000000..0e3314b638 --- /dev/null +++ b/.changeset/swift-brooms-teach.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +fix stable combine reference not updating when queries change dynamically diff --git a/packages/query-core/src/__tests__/queriesObserver.test.tsx b/packages/query-core/src/__tests__/queriesObserver.test.tsx index da81daa653..24b80aaf81 100644 --- a/packages/query-core/src/__tests__/queriesObserver.test.tsx +++ b/packages/query-core/src/__tests__/queriesObserver.test.tsx @@ -394,4 +394,120 @@ describe('queriesObserver', () => { { status: 'success', data: 102 }, ]) }) + + test('should update combined result when queries are added with stable combine reference', () => { + const combine = vi.fn((results: Array) => ({ + count: results.length, + results, + })) + + const key1 = queryKey() + const key2 = queryKey() + const queryFn1 = vi.fn().mockReturnValue(1) + const queryFn2 = vi.fn().mockReturnValue(2) + + const observer = new QueriesObserver<{ + count: number + results: Array + }>(queryClient, [{ queryKey: key1, queryFn: queryFn1 }], { combine }) + + const [initialRaw, getInitialCombined] = observer.getOptimisticResult( + [{ queryKey: key1, queryFn: queryFn1 }], + combine, + ) + const initialCombined = getInitialCombined(initialRaw) + + expect(initialCombined.count).toBe(1) + + const newQueries = [ + { queryKey: key1, queryFn: queryFn1 }, + { queryKey: key2, queryFn: queryFn2 }, + ] + const [newRaw, getNewCombined] = observer.getOptimisticResult( + newQueries, + combine, + ) + const newCombined = getNewCombined(newRaw) + + expect(newCombined.count).toBe(2) + }) + + test('should handle queries being removed with stable combine reference', () => { + const combine = vi.fn((results: Array) => ({ + count: results.length, + results, + })) + + const key1 = queryKey() + const key2 = queryKey() + const queryFn1 = vi.fn().mockReturnValue(1) + const queryFn2 = vi.fn().mockReturnValue(2) + + const observer = new QueriesObserver<{ + count: number + results: Array + }>( + queryClient, + [ + { queryKey: key1, queryFn: queryFn1 }, + { queryKey: key2, queryFn: queryFn2 }, + ], + { combine }, + ) + + const [initialRaw, getInitialCombined] = observer.getOptimisticResult( + [ + { queryKey: key1, queryFn: queryFn1 }, + { queryKey: key2, queryFn: queryFn2 }, + ], + combine, + ) + const initialCombined = getInitialCombined(initialRaw) + + expect(initialCombined.count).toBe(2) + + const newQueries = [{ queryKey: key1, queryFn: queryFn1 }] + const [newRaw, getNewCombined] = observer.getOptimisticResult( + newQueries, + combine, + ) + const newCombined = getNewCombined(newRaw) + + expect(newCombined.count).toBe(1) + }) + + test('should update combined result when queries are replaced with different ones (same length)', () => { + const combine = vi.fn((results: Array) => ({ + keys: results.map((r) => r.status), + results, + })) + + const key1 = queryKey() + const key2 = queryKey() + const queryFn1 = vi.fn().mockReturnValue(1) + const queryFn2 = vi.fn().mockReturnValue(2) + + queryClient.setQueryData(key1, 'cached-1') + + const observer = new QueriesObserver<{ + keys: Array + results: Array + }>(queryClient, [{ queryKey: key1, queryFn: queryFn1 }], { combine }) + + const [initialRaw, getInitialCombined] = observer.getOptimisticResult( + [{ queryKey: key1, queryFn: queryFn1 }], + combine, + ) + const initialCombined = getInitialCombined(initialRaw) + + expect(initialCombined.keys).toEqual(['success']) + + const [newRaw, getNewCombined] = observer.getOptimisticResult( + [{ queryKey: key2, queryFn: queryFn2 }], + combine, + ) + const newCombined = getNewCombined(newRaw) + + expect(newCombined.keys).toEqual(['pending']) + }) }) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 0590e1e995..1948e7ac83 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -43,6 +43,7 @@ export class QueriesObserver< #combinedResult?: TCombinedResult #lastCombine?: CombineFn #lastResult?: Array + #lastQueryHashes?: Array #observerMatches: Array = [] constructor( @@ -180,11 +181,14 @@ export class QueriesObserver< const result = matches.map((match) => match.observer.getOptimisticResult(match.defaultedQueryOptions), ) + const queryHashes = matches.map( + (match) => match.defaultedQueryOptions.queryHash, + ) return [ result, (r?: Array) => { - return this.#combineResult(r ?? result, combine) + return this.#combineResult(r ?? result, combine, queryHashes) }, () => { return this.#trackResult(result, matches) @@ -212,15 +216,28 @@ export class QueriesObserver< #combineResult( input: Array, combine: CombineFn | undefined, + queryHashes?: Array, ): TCombinedResult { if (combine) { + const lastHashes = this.#lastQueryHashes + const queryHashesChanged = + queryHashes !== undefined && + lastHashes !== undefined && + (lastHashes.length !== queryHashes.length || + queryHashes.some((hash, i) => hash !== lastHashes[i])) + if ( !this.#combinedResult || this.#result !== this.#lastResult || + queryHashesChanged || combine !== this.#lastCombine ) { this.#lastCombine = combine this.#lastResult = this.#result + + if (queryHashes !== undefined) { + this.#lastQueryHashes = queryHashes + } this.#combinedResult = replaceEqualDeep( this.#combinedResult, combine(input), diff --git a/packages/react-query/src/__tests__/useQueries.test.tsx b/packages/react-query/src/__tests__/useQueries.test.tsx index 19fbe1f7c3..a8d3787ea4 100644 --- a/packages/react-query/src/__tests__/useQueries.test.tsx +++ b/packages/react-query/src/__tests__/useQueries.test.tsx @@ -1811,4 +1811,56 @@ describe('useQueries', () => { expect(renderCount).toBeLessThan(10) expect(rendered.getByTestId('query-count').textContent).toBe('queries: 1') }) + + it('should return correct results when queries count changes with stable combine reference', async () => { + const combine = (results: Array) => results + + const results: Array<{ n: number; length: number }> = [] + + function Page() { + const [n, setN] = React.useState(0) + + const queries = useQueries( + { + queries: [...Array(n).keys()].map((i) => ({ + queryKey: ['dynamic', i], + queryFn: () => i, + })), + combine, + }, + queryClient, + ) + + results.push({ n, length: queries.length }) + + return ( +
+ {n} + {queries.length} + +
+ ) + } + + const rendered = render() + + expect(rendered.getByTestId('n').textContent).toBe('0') + expect(rendered.getByTestId('length').textContent).toBe('0') + + fireEvent.click(rendered.getByRole('button', { name: /increase/i })) + await vi.advanceTimersByTimeAsync(0) + + expect(rendered.getByTestId('n').textContent).toBe('1') + expect(rendered.getByTestId('length').textContent).toBe('1') + + fireEvent.click(rendered.getByRole('button', { name: /increase/i })) + await vi.advanceTimersByTimeAsync(0) + + expect(rendered.getByTestId('n').textContent).toBe('2') + expect(rendered.getByTestId('length').textContent).toBe('2') + + results.forEach((result) => { + expect(result.length).toBe(result.n) + }) + }) })