diff --git a/.changeset/nine-shoes-tap.md b/.changeset/nine-shoes-tap.md new file mode 100644 index 0000000000..f5d8a33f4f --- /dev/null +++ b/.changeset/nine-shoes-tap.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-query': patch +'@tanstack/query-core': patch +--- + +Prevent infinite render loops when useSuspenseQueries has duplicate queryKeys diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index b8068ec491..0590e1e995 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -235,26 +235,33 @@ export class QueriesObserver< #findMatchingObservers( queries: Array, ): Array { - const prevObserversMap = new Map( - this.#observers.map((observer) => [observer.options.queryHash, observer]), - ) + const prevObserversMap = new Map>() + + this.#observers.forEach((observer) => { + const key = observer.options.queryHash + if (!key) return + + const previousObservers = prevObserversMap.get(key) + + if (previousObservers) { + previousObservers.push(observer) + } else { + prevObserversMap.set(key, [observer]) + } + }) const observers: Array = [] 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 diff --git a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx index a0b923493f..7b523aea99 100644 --- a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx @@ -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 ( +
+

Data

+ {JSON.stringify({ data }, null, 2)} + {JSON.stringify({ data2 }, null, 2)} +
+ ) + } + + renderWithClient( + queryClient, + }> + + , + ) + + 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', () => {