Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/nine-shoes-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/react-query': patch
'@tanstack/query-core': patch
---

Prevent infinite render loops when useSuspenseQueries has duplicate queryKeys
35 changes: 20 additions & 15 deletions packages/query-core/src/queriesObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,26 +235,31 @@ export class QueriesObserver<
#findMatchingObservers(
queries: Array<QueryObserverOptions>,
): Array<QueryObserverMatch> {
const prevObserversMap = new Map(
this.#observers.map((observer) => [observer.options.queryHash, observer]),
)
const prevObserversMap = new Map<string, Array<QueryObserver>>()


this.#observers.forEach((observer) => {
const key = observer.options.queryHash!
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const previousObservers = prevObserversMap.get(key)

if (previousObservers) {
previousObservers.push(observer)
} else {
prevObserversMap.set(key, [observer])
}
})

const observers: Array<QueryObserverMatch> = []

queries.forEach((options) => {
const defaultedOptions = this.#client.defaultQueryOptions(options)
const match = prevObserversMap.get(defaultedOptions.queryHash)
if (match) {
observers.push({
defaultedQueryOptions: defaultedOptions,
observer: match,
})
} else {
observers.push({
defaultedQueryOptions: defaultedOptions,
observer: new QueryObserver(this.#client, defaultedOptions),
})
}
const match = prevObserversMap.get(defaultedOptions.queryHash)?.shift()
const observer = match ?? new QueryObserver(this.#client, defaultedOptions)

observers.push({
defaultedQueryOptions: defaultedOptions,
observer,
})
})

return observers
Expand Down
73 changes: 73 additions & 0 deletions packages/react-query/src/__tests__/useSuspenseQueries.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,79 @@ describe('useSuspenseQueries', () => {

expect(spy).toHaveBeenCalled()
})

it('should handle duplicate query keys without infinite loops', async () => {
const key = queryKey()
const localDuration = 10
let renderCount = 0

function getUserData() {
return {
queryKey: key,
queryFn: async () => {
await sleep(localDuration)
return { name: 'John Doe', age: 50 }
},
}
}

function getName() {
return {
...getUserData(),
select: (data: any) => data.name,
}
}

function getAge() {
return {
...getUserData(),
select: (data: any) => data.age,
}
}

function App() {
renderCount++
const [{ data }, { data: data2 }] = useSuspenseQueries({
queries: [getName(), getAge()],
})

React.useEffect(() => {
onQueriesResolution({ data, data2 })
}, [data, data2])

return (
<div>
<h1>Data</h1>
{JSON.stringify({ data }, null, 2)}
{JSON.stringify({ data2 }, null, 2)}
</div>
)
}

renderWithClient(
queryClient,
<React.Suspense fallback={<SuspenseFallback />}>
<App />
</React.Suspense>,
)

await act(() => vi.advanceTimersByTimeAsync(localDuration))

expect(onSuspend).toHaveBeenCalledTimes(1)
expect(onQueriesResolution).toHaveBeenCalledTimes(1)

await act(() => vi.advanceTimersByTimeAsync(100))

expect(onQueriesResolution).toHaveBeenCalledTimes(1)
expect(onQueriesResolution).toHaveBeenLastCalledWith({
data: 'John Doe',
data2: 50,
})

// With the infinite loop bug, renderCount would be very high (e.g. > 100)
// Without bug, it should be small (initial suspend + resolution = 2-3)
expect(renderCount).toBeLessThan(10)
})
})

describe('useSuspenseQueries 2', () => {
Expand Down