Skip to content

Commit c01b150

Browse files
olivierbeaulieuTkDodoautofix-ci[bot]
authored
fix(useQueries): Prevent infinite render loops when useSuspenseQueries has duplicate queryKeys (#9886)
* Prevent infinite render loops when useSuspenseQueries has duplicate queryKeys * changeset * tweaks * smaller diff * lint * lint * remove newline * remove non-null assertion * ci: apply automated fixes --------- Co-authored-by: Dominik Dorfmeister <office@dorfmeister.cc> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 46ebef2 commit c01b150

File tree

3 files changed

+101
-15
lines changed

3 files changed

+101
-15
lines changed

.changeset/nine-shoes-tap.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@tanstack/react-query': patch
3+
'@tanstack/query-core': patch
4+
---
5+
6+
Prevent infinite render loops when useSuspenseQueries has duplicate queryKeys

packages/query-core/src/queriesObserver.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -235,26 +235,33 @@ export class QueriesObserver<
235235
#findMatchingObservers(
236236
queries: Array<QueryObserverOptions>,
237237
): Array<QueryObserverMatch> {
238-
const prevObserversMap = new Map(
239-
this.#observers.map((observer) => [observer.options.queryHash, observer]),
240-
)
238+
const prevObserversMap = new Map<string, Array<QueryObserver>>()
239+
240+
this.#observers.forEach((observer) => {
241+
const key = observer.options.queryHash
242+
if (!key) return
243+
244+
const previousObservers = prevObserversMap.get(key)
245+
246+
if (previousObservers) {
247+
previousObservers.push(observer)
248+
} else {
249+
prevObserversMap.set(key, [observer])
250+
}
251+
})
241252

242253
const observers: Array<QueryObserverMatch> = []
243254

244255
queries.forEach((options) => {
245256
const defaultedOptions = this.#client.defaultQueryOptions(options)
246-
const match = prevObserversMap.get(defaultedOptions.queryHash)
247-
if (match) {
248-
observers.push({
249-
defaultedQueryOptions: defaultedOptions,
250-
observer: match,
251-
})
252-
} else {
253-
observers.push({
254-
defaultedQueryOptions: defaultedOptions,
255-
observer: new QueryObserver(this.#client, defaultedOptions),
256-
})
257-
}
257+
const match = prevObserversMap.get(defaultedOptions.queryHash)?.shift()
258+
const observer =
259+
match ?? new QueryObserver(this.#client, defaultedOptions)
260+
261+
observers.push({
262+
defaultedQueryOptions: defaultedOptions,
263+
observer,
264+
})
258265
})
259266

260267
return observers

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,79 @@ describe('useSuspenseQueries', () => {
182182

183183
expect(spy).toHaveBeenCalled()
184184
})
185+
186+
it('should handle duplicate query keys without infinite loops', async () => {
187+
const key = queryKey()
188+
const localDuration = 10
189+
let renderCount = 0
190+
191+
function getUserData() {
192+
return {
193+
queryKey: key,
194+
queryFn: async () => {
195+
await sleep(localDuration)
196+
return { name: 'John Doe', age: 50 }
197+
},
198+
}
199+
}
200+
201+
function getName() {
202+
return {
203+
...getUserData(),
204+
select: (data: any) => data.name,
205+
}
206+
}
207+
208+
function getAge() {
209+
return {
210+
...getUserData(),
211+
select: (data: any) => data.age,
212+
}
213+
}
214+
215+
function App() {
216+
renderCount++
217+
const [{ data }, { data: data2 }] = useSuspenseQueries({
218+
queries: [getName(), getAge()],
219+
})
220+
221+
React.useEffect(() => {
222+
onQueriesResolution({ data, data2 })
223+
}, [data, data2])
224+
225+
return (
226+
<div>
227+
<h1>Data</h1>
228+
{JSON.stringify({ data }, null, 2)}
229+
{JSON.stringify({ data2 }, null, 2)}
230+
</div>
231+
)
232+
}
233+
234+
renderWithClient(
235+
queryClient,
236+
<React.Suspense fallback={<SuspenseFallback />}>
237+
<App />
238+
</React.Suspense>,
239+
)
240+
241+
await act(() => vi.advanceTimersByTimeAsync(localDuration))
242+
243+
expect(onSuspend).toHaveBeenCalledTimes(1)
244+
expect(onQueriesResolution).toHaveBeenCalledTimes(1)
245+
246+
await act(() => vi.advanceTimersByTimeAsync(100))
247+
248+
expect(onQueriesResolution).toHaveBeenCalledTimes(1)
249+
expect(onQueriesResolution).toHaveBeenLastCalledWith({
250+
data: 'John Doe',
251+
data2: 50,
252+
})
253+
254+
// With the infinite loop bug, renderCount would be very high (e.g. > 100)
255+
// Without bug, it should be small (initial suspend + resolution = 2-3)
256+
expect(renderCount).toBeLessThan(10)
257+
})
185258
})
186259

187260
describe('useSuspenseQueries 2', () => {

0 commit comments

Comments
 (0)