From 84e0088b87a72243d02d3b0d564c250775182fed Mon Sep 17 00:00:00 2001 From: Olivier Beaulieu Date: Tue, 18 Nov 2025 19:39:13 -0500 Subject: [PATCH 1/9] Prevent infinite render loops when useSuspenseQueries has duplicate queryKeys --- packages/query-core/src/queriesObserver.ts | 34 ++++----- .../src/__tests__/useSuspenseQueries.test.tsx | 73 +++++++++++++++++++ 2 files changed, 89 insertions(+), 18 deletions(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index b8068ec491..ff2341063c 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -235,29 +235,27 @@ export class QueriesObserver< #findMatchingObservers( queries: Array, ): Array { - const prevObserversMap = new Map( - this.#observers.map((observer) => [observer.options.queryHash, observer]), - ) - - const observers: Array = [] + const prevObserversMap = new Map>() - queries.forEach((options) => { - const defaultedOptions = this.#client.defaultQueryOptions(options) - const match = prevObserversMap.get(defaultedOptions.queryHash) - if (match) { - observers.push({ - defaultedQueryOptions: defaultedOptions, - observer: match, - }) + this.#observers.forEach((observer) => { + const key = observer.options.queryHash! + const observers = prevObserversMap.get(key) + if (observers) { + observers.push(observer) } else { - observers.push({ - defaultedQueryOptions: defaultedOptions, - observer: new QueryObserver(this.#client, defaultedOptions), - }) + prevObserversMap.set(key, [observer]) } }) - return observers + return queries.map((options) => { + const defaultedOptions = this.#client.defaultQueryOptions(options) + const match = prevObserversMap.get(defaultedOptions.queryHash)?.shift() + const observer = match ?? new QueryObserver(this.#client, defaultedOptions) + return { + defaultedQueryOptions: defaultedOptions, + observer, + } + }) } #onUpdate(observer: QueryObserver, result: QueryObserverResult): void { diff --git a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx index a0b923493f..d4af257d42 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', () => { From 83813bf3313ea86eb36925062fe8c9a1c53cf52f Mon Sep 17 00:00:00 2001 From: Olivier Beaulieu Date: Tue, 18 Nov 2025 23:29:36 -0500 Subject: [PATCH 2/9] changeset --- .changeset/nine-shoes-tap.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/nine-shoes-tap.md 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 From f963c1ede935f84daf32acd62fa59d84ea6dddd8 Mon Sep 17 00:00:00 2001 From: Olivier Beaulieu Date: Tue, 18 Nov 2025 23:39:14 -0500 Subject: [PATCH 3/9] tweaks --- packages/query-core/src/queriesObserver.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index ff2341063c..6a5c3e5c24 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -236,26 +236,32 @@ export class QueriesObserver< queries: Array, ): Array { const prevObserversMap = new Map>() + + const observers: Array = [] this.#observers.forEach((observer) => { const key = observer.options.queryHash! - const observers = prevObserversMap.get(key) - if (observers) { - observers.push(observer) + const previousObservers = prevObserversMap.get(key) + + if (previousObservers) { + previousObservers.push(observer) } else { prevObserversMap.set(key, [observer]) } }) - return queries.map((options) => { + queries.forEach((options) => { const defaultedOptions = this.#client.defaultQueryOptions(options) const match = prevObserversMap.get(defaultedOptions.queryHash)?.shift() const observer = match ?? new QueryObserver(this.#client, defaultedOptions) - return { + + observers.push({ defaultedQueryOptions: defaultedOptions, observer, - } + }) }) + + return observers } #onUpdate(observer: QueryObserver, result: QueryObserverResult): void { From 0d051c919002bb772d4d1e6efb7d5827b3de1ba6 Mon Sep 17 00:00:00 2001 From: Olivier Beaulieu Date: Tue, 18 Nov 2025 23:41:00 -0500 Subject: [PATCH 4/9] smaller diff --- packages/query-core/src/queriesObserver.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 6a5c3e5c24..176694d9dc 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -237,19 +237,20 @@ export class QueriesObserver< ): Array { const prevObserversMap = new Map>() - const observers: Array = [] - + this.#observers.forEach((observer) => { const key = observer.options.queryHash! 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)?.shift() From a92c36428cb362ab0fe00621df8819ef8b03c242 Mon Sep 17 00:00:00 2001 From: Olivier Beaulieu Date: Tue, 18 Nov 2025 23:41:41 -0500 Subject: [PATCH 5/9] lint --- packages/query-core/src/queriesObserver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 176694d9dc..3ea10b78f1 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -250,7 +250,7 @@ export class QueriesObserver< }) const observers: Array = [] - + queries.forEach((options) => { const defaultedOptions = this.#client.defaultQueryOptions(options) const match = prevObserversMap.get(defaultedOptions.queryHash)?.shift() From 429c4a5da4a1a308514fa5089d874615a068d471 Mon Sep 17 00:00:00 2001 From: Olivier Beaulieu Date: Tue, 18 Nov 2025 23:42:45 -0500 Subject: [PATCH 6/9] lint --- packages/query-core/src/queriesObserver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 3ea10b78f1..a3dcbe80f8 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -241,7 +241,7 @@ export class QueriesObserver< this.#observers.forEach((observer) => { const key = observer.options.queryHash! const previousObservers = prevObserversMap.get(key) - + if (previousObservers) { previousObservers.push(observer) } else { From 495c063718a2313ff3634e620b81a3f01d3ebb09 Mon Sep 17 00:00:00 2001 From: Olivier Beaulieu Date: Wed, 19 Nov 2025 09:57:25 -0500 Subject: [PATCH 7/9] remove newline --- packages/query-core/src/queriesObserver.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index a3dcbe80f8..3582d33bdc 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -237,7 +237,6 @@ export class QueriesObserver< ): Array { const prevObserversMap = new Map>() - this.#observers.forEach((observer) => { const key = observer.options.queryHash! const previousObservers = prevObserversMap.get(key) From 8f6ecf5b50d19d201b8184bd0eb334aaa5c175b4 Mon Sep 17 00:00:00 2001 From: Olivier Beaulieu Date: Wed, 19 Nov 2025 10:05:37 -0500 Subject: [PATCH 8/9] remove non-null assertion --- packages/query-core/src/queriesObserver.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 3582d33bdc..da5c4f7ab6 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -238,7 +238,9 @@ export class QueriesObserver< const prevObserversMap = new Map>() this.#observers.forEach((observer) => { - const key = observer.options.queryHash! + const key = observer.options.queryHash + if (!key) return + const previousObservers = prevObserversMap.get(key) if (previousObservers) { From a3ae6a0a9b61d0ff284f31ebafe076878e8b32ec Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 16:28:50 +0000 Subject: [PATCH 9/9] ci: apply automated fixes --- packages/query-core/src/queriesObserver.ts | 9 +++++---- .../src/__tests__/useSuspenseQueries.test.tsx | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index da5c4f7ab6..0590e1e995 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -236,7 +236,7 @@ export class QueriesObserver< queries: Array, ): Array { const prevObserversMap = new Map>() - + this.#observers.forEach((observer) => { const key = observer.options.queryHash if (!key) return @@ -249,14 +249,15 @@ export class QueriesObserver< prevObserversMap.set(key, [observer]) } }) - + const observers: Array = [] queries.forEach((options) => { const defaultedOptions = this.#client.defaultQueryOptions(options) const match = prevObserversMap.get(defaultedOptions.queryHash)?.shift() - const observer = match ?? new QueryObserver(this.#client, defaultedOptions) - + const observer = + match ?? new QueryObserver(this.#client, defaultedOptions) + observers.push({ defaultedQueryOptions: defaultedOptions, observer, diff --git a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx index d4af257d42..7b523aea99 100644 --- a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx @@ -250,7 +250,7 @@ describe('useSuspenseQueries', () => { 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)