Skip to content

Commit 53fc74e

Browse files
minseong0324TkDodo
andauthored
fix(query-core): fix combine not updating when queries change with stable reference (#9954)
* test(query-core, react-query): add tests for stable combine reference with dynamic queries * fix(query-core): fix combine cache invalidation with stable reference * chore: add changesets --------- Co-authored-by: Dominik Dorfmeister <[email protected]>
1 parent 0d07172 commit 53fc74e

File tree

4 files changed

+191
-1
lines changed

4 files changed

+191
-1
lines changed

.changeset/swift-brooms-teach.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/query-core': patch
3+
---
4+
5+
fix stable combine reference not updating when queries change dynamically

packages/query-core/src/__tests__/queriesObserver.test.tsx

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,4 +394,120 @@ describe('queriesObserver', () => {
394394
{ status: 'success', data: 102 },
395395
])
396396
})
397+
398+
test('should update combined result when queries are added with stable combine reference', () => {
399+
const combine = vi.fn((results: Array<QueryObserverResult>) => ({
400+
count: results.length,
401+
results,
402+
}))
403+
404+
const key1 = queryKey()
405+
const key2 = queryKey()
406+
const queryFn1 = vi.fn().mockReturnValue(1)
407+
const queryFn2 = vi.fn().mockReturnValue(2)
408+
409+
const observer = new QueriesObserver<{
410+
count: number
411+
results: Array<QueryObserverResult>
412+
}>(queryClient, [{ queryKey: key1, queryFn: queryFn1 }], { combine })
413+
414+
const [initialRaw, getInitialCombined] = observer.getOptimisticResult(
415+
[{ queryKey: key1, queryFn: queryFn1 }],
416+
combine,
417+
)
418+
const initialCombined = getInitialCombined(initialRaw)
419+
420+
expect(initialCombined.count).toBe(1)
421+
422+
const newQueries = [
423+
{ queryKey: key1, queryFn: queryFn1 },
424+
{ queryKey: key2, queryFn: queryFn2 },
425+
]
426+
const [newRaw, getNewCombined] = observer.getOptimisticResult(
427+
newQueries,
428+
combine,
429+
)
430+
const newCombined = getNewCombined(newRaw)
431+
432+
expect(newCombined.count).toBe(2)
433+
})
434+
435+
test('should handle queries being removed with stable combine reference', () => {
436+
const combine = vi.fn((results: Array<QueryObserverResult>) => ({
437+
count: results.length,
438+
results,
439+
}))
440+
441+
const key1 = queryKey()
442+
const key2 = queryKey()
443+
const queryFn1 = vi.fn().mockReturnValue(1)
444+
const queryFn2 = vi.fn().mockReturnValue(2)
445+
446+
const observer = new QueriesObserver<{
447+
count: number
448+
results: Array<QueryObserverResult>
449+
}>(
450+
queryClient,
451+
[
452+
{ queryKey: key1, queryFn: queryFn1 },
453+
{ queryKey: key2, queryFn: queryFn2 },
454+
],
455+
{ combine },
456+
)
457+
458+
const [initialRaw, getInitialCombined] = observer.getOptimisticResult(
459+
[
460+
{ queryKey: key1, queryFn: queryFn1 },
461+
{ queryKey: key2, queryFn: queryFn2 },
462+
],
463+
combine,
464+
)
465+
const initialCombined = getInitialCombined(initialRaw)
466+
467+
expect(initialCombined.count).toBe(2)
468+
469+
const newQueries = [{ queryKey: key1, queryFn: queryFn1 }]
470+
const [newRaw, getNewCombined] = observer.getOptimisticResult(
471+
newQueries,
472+
combine,
473+
)
474+
const newCombined = getNewCombined(newRaw)
475+
476+
expect(newCombined.count).toBe(1)
477+
})
478+
479+
test('should update combined result when queries are replaced with different ones (same length)', () => {
480+
const combine = vi.fn((results: Array<QueryObserverResult>) => ({
481+
keys: results.map((r) => r.status),
482+
results,
483+
}))
484+
485+
const key1 = queryKey()
486+
const key2 = queryKey()
487+
const queryFn1 = vi.fn().mockReturnValue(1)
488+
const queryFn2 = vi.fn().mockReturnValue(2)
489+
490+
queryClient.setQueryData(key1, 'cached-1')
491+
492+
const observer = new QueriesObserver<{
493+
keys: Array<string>
494+
results: Array<QueryObserverResult>
495+
}>(queryClient, [{ queryKey: key1, queryFn: queryFn1 }], { combine })
496+
497+
const [initialRaw, getInitialCombined] = observer.getOptimisticResult(
498+
[{ queryKey: key1, queryFn: queryFn1 }],
499+
combine,
500+
)
501+
const initialCombined = getInitialCombined(initialRaw)
502+
503+
expect(initialCombined.keys).toEqual(['success'])
504+
505+
const [newRaw, getNewCombined] = observer.getOptimisticResult(
506+
[{ queryKey: key2, queryFn: queryFn2 }],
507+
combine,
508+
)
509+
const newCombined = getNewCombined(newRaw)
510+
511+
expect(newCombined.keys).toEqual(['pending'])
512+
})
397513
})

packages/query-core/src/queriesObserver.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export class QueriesObserver<
4343
#combinedResult?: TCombinedResult
4444
#lastCombine?: CombineFn<TCombinedResult>
4545
#lastResult?: Array<QueryObserverResult>
46+
#lastQueryHashes?: Array<string>
4647
#observerMatches: Array<QueryObserverMatch> = []
4748

4849
constructor(
@@ -180,11 +181,14 @@ export class QueriesObserver<
180181
const result = matches.map((match) =>
181182
match.observer.getOptimisticResult(match.defaultedQueryOptions),
182183
)
184+
const queryHashes = matches.map(
185+
(match) => match.defaultedQueryOptions.queryHash,
186+
)
183187

184188
return [
185189
result,
186190
(r?: Array<QueryObserverResult>) => {
187-
return this.#combineResult(r ?? result, combine)
191+
return this.#combineResult(r ?? result, combine, queryHashes)
188192
},
189193
() => {
190194
return this.#trackResult(result, matches)
@@ -212,15 +216,28 @@ export class QueriesObserver<
212216
#combineResult(
213217
input: Array<QueryObserverResult>,
214218
combine: CombineFn<TCombinedResult> | undefined,
219+
queryHashes?: Array<string>,
215220
): TCombinedResult {
216221
if (combine) {
222+
const lastHashes = this.#lastQueryHashes
223+
const queryHashesChanged =
224+
queryHashes !== undefined &&
225+
lastHashes !== undefined &&
226+
(lastHashes.length !== queryHashes.length ||
227+
queryHashes.some((hash, i) => hash !== lastHashes[i]))
228+
217229
if (
218230
!this.#combinedResult ||
219231
this.#result !== this.#lastResult ||
232+
queryHashesChanged ||
220233
combine !== this.#lastCombine
221234
) {
222235
this.#lastCombine = combine
223236
this.#lastResult = this.#result
237+
238+
if (queryHashes !== undefined) {
239+
this.#lastQueryHashes = queryHashes
240+
}
224241
this.#combinedResult = replaceEqualDeep(
225242
this.#combinedResult,
226243
combine(input),

packages/react-query/src/__tests__/useQueries.test.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1811,4 +1811,56 @@ describe('useQueries', () => {
18111811
expect(renderCount).toBeLessThan(10)
18121812
expect(rendered.getByTestId('query-count').textContent).toBe('queries: 1')
18131813
})
1814+
1815+
it('should return correct results when queries count changes with stable combine reference', async () => {
1816+
const combine = (results: Array<QueryObserverResult>) => results
1817+
1818+
const results: Array<{ n: number; length: number }> = []
1819+
1820+
function Page() {
1821+
const [n, setN] = React.useState(0)
1822+
1823+
const queries = useQueries(
1824+
{
1825+
queries: [...Array(n).keys()].map((i) => ({
1826+
queryKey: ['dynamic', i],
1827+
queryFn: () => i,
1828+
})),
1829+
combine,
1830+
},
1831+
queryClient,
1832+
)
1833+
1834+
results.push({ n, length: queries.length })
1835+
1836+
return (
1837+
<div>
1838+
<span data-testid="n">{n}</span>
1839+
<span data-testid="length">{queries.length}</span>
1840+
<button onClick={() => setN(n + 1)}>Increase</button>
1841+
</div>
1842+
)
1843+
}
1844+
1845+
const rendered = render(<Page />)
1846+
1847+
expect(rendered.getByTestId('n').textContent).toBe('0')
1848+
expect(rendered.getByTestId('length').textContent).toBe('0')
1849+
1850+
fireEvent.click(rendered.getByRole('button', { name: /increase/i }))
1851+
await vi.advanceTimersByTimeAsync(0)
1852+
1853+
expect(rendered.getByTestId('n').textContent).toBe('1')
1854+
expect(rendered.getByTestId('length').textContent).toBe('1')
1855+
1856+
fireEvent.click(rendered.getByRole('button', { name: /increase/i }))
1857+
await vi.advanceTimersByTimeAsync(0)
1858+
1859+
expect(rendered.getByTestId('n').textContent).toBe('2')
1860+
expect(rendered.getByTestId('length').textContent).toBe('2')
1861+
1862+
results.forEach((result) => {
1863+
expect(result.length).toBe(result.n)
1864+
})
1865+
})
18141866
})

0 commit comments

Comments
 (0)