Skip to content

Conversation

@olivierbeaulieu
Copy link
Contributor

@olivierbeaulieu olivierbeaulieu commented Nov 19, 2025

🎯 Changes

Fixes an infinite re-render loop in useSuspenseQueries (and useQueries) when duplicate query keys are provided introduced in #8771.

While duplicate query keys are technically unsupported and log a warning, the current behavior causes the application to crash due to an infinite render loop, which is a severe degradation.

The issue

QueriesObserver used a Map keyed by queryHash to track observers, causing duplicate queries to overwrite each other in the map. This forced all duplicate queries to share a single observer instance. During updates, this shared observer was repeatedly reconfigured with conflicting options (like different select functions) within the same render cycle, causing its state to thrash and triggering infinite re-renders.

The fix

Updated QueriesObserver to track a list of observers per hash (Map<string, QueryObserver[]>). This ensures each duplicate query is matched to its own distinct, stable observer instance, preventing the state conflict.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm run test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Bug Fixes

    • Prevents infinite render loops when using duplicate query keys with useSuspenseQueries; improves stability when multiple queries share the same key.
  • Tests

    • Added tests validating duplicate query keys no longer cause unbounded re-renders under Suspense and that resolution occurs as expected.
  • Chores

    • Released a changeset bumping related packages to patch versions containing the fix.

✏️ Tip: You can customize this high-level summary in your review settings.

@changeset-bot
Copy link

changeset-bot bot commented Nov 19, 2025

🦋 Changeset detected

Latest commit: a3ae6a0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 19 packages
Name Type
@tanstack/react-query Patch
@tanstack/query-core Patch
@tanstack/react-query-devtools Patch
@tanstack/react-query-next-experimental Patch
@tanstack/react-query-persist-client Patch
@tanstack/angular-query-experimental Patch
@tanstack/query-async-storage-persister Patch
@tanstack/query-broadcast-client-experimental Patch
@tanstack/query-persist-client-core Patch
@tanstack/query-sync-storage-persister Patch
@tanstack/solid-query Patch
@tanstack/svelte-query Patch
@tanstack/vue-query Patch
@tanstack/angular-query-persist-client Patch
@tanstack/solid-query-persist-client Patch
@tanstack/svelte-query-persist-client Patch
@tanstack/solid-query-devtools Patch
@tanstack/svelte-query-devtools Patch
@tanstack/vue-query-devtools Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 19, 2025

Walkthrough

Changes shift observer tracking to arrays per queryHash, reuse observers to avoid duplicate-key infinite renders, add a regression test for duplicate query keys in useSuspenseQueries, and add a changeset bumping two TanStack packages to patch releases.

Changes

Cohort / File(s) Summary
Changeset entry
.changeset/nine-shoes-tap.md
Adds a changeset bumping @tanstack/react-query and @tanstack/query-core to patch releases; documents fix preventing infinite render loops when useSuspenseQueries receives duplicate queryKeys.
Observer reuse refactor
packages/query-core/src/queriesObserver.ts
Replaces single-observer mapping with queryHash -> observer[]; groups previous observers by hash and reuses observers by shifting from arrays or creating new QueryObserver instances; adjusts control flow for observer selection and attachment.
Regression test(s)
packages/react-query/src/__tests__/useSuspenseQueries.test.tsx
Adds test(s) "should handle duplicate query keys without infinite loops" verifying bounded render count and correct suspend/resolve behavior when duplicate query keys and different selectors are used.

Sequence Diagram

sequenceDiagram
    participant App as Application
    participant USQ as useSuspenseQueries
    participant QO as queriesObserver
    participant Cache as Query Cache

    rect rgb(241,250,255)
    Note over QO: group prevObservers by queryHash → observer[]
    end

    App->>USQ: call useSuspenseQueries(queries with duplicate keys)
    USQ->>QO: request observer assignments
    loop per query
        QO->>QO: check observer array for queryHash
        alt array non-empty
            QO->>QO: reuse observer (shift from array)
        else array empty
            QO->>QO: create new QueryObserver
        end
        QO->>Cache: attach/update observer for query
    end
    Cache-->>USQ: observers registered
    USQ-->>App: render (suspended/resolved) with stable observers
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Pay attention to: reuse/shift logic and edge cases in packages/query-core/src/queriesObserver.ts; observer lifecycle and cleanup.
  • Verify determinism and flakiness risk in packages/react-query/src/__tests__/useSuspenseQueries.test.tsx.

Possibly related PRs

Suggested reviewers

  • TkDodo

Poem

🐰 Hopping through queries with keys the same,
Arrays line up neatly, no more looping game.
Observers step forward, shifted out with care,
Suspense settles softly — renders stay fair. ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main fix: preventing infinite render loops in useSuspenseQueries with duplicate queryKeys.
Description check ✅ Passed The description includes all required sections from the template with comprehensive details about the problem, solution, and completed checklist items.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8f6ecf5 and a3ae6a0.

📒 Files selected for processing (2)
  • packages/query-core/src/queriesObserver.ts (1 hunks)
  • packages/react-query/src/__tests__/useSuspenseQueries.test.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react-query/src/tests/useSuspenseQueries.test.tsx
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: sukvvon
Repo: TanStack/query PR: 9892
File: packages/solid-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx:331-335
Timestamp: 2025-11-22T09:06:05.219Z
Learning: In TanStack/query test files, when a queryFn contains side effects (e.g., setting flags for test verification), prefer async/await syntax for clarity; when there are no side effects, prefer the .then() pattern for conciseness.
📚 Learning: 2025-08-19T03:18:18.303Z
Learnt from: oscartbeaumont
Repo: TanStack/query PR: 9564
File: packages/solid-query-devtools/src/production.tsx:2-3
Timestamp: 2025-08-19T03:18:18.303Z
Learning: In the solid-query-devtools package, the codebase uses a pattern of type-only default imports combined with typeof for component type annotations (e.g., `import type SolidQueryDevtoolsComp from './devtools'` followed by `typeof SolidQueryDevtoolsComp`). This pattern is consistently used across index.tsx and production.tsx files, and the maintainers prefer consistency over changing this approach.

Applied to files:

  • packages/query-core/src/queriesObserver.ts
🧬 Code graph analysis (1)
packages/query-core/src/queriesObserver.ts (1)
packages/query-core/src/queryObserver.ts (2)
  • QueryObserver (41-747)
  • options (379-385)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Test
  • GitHub Check: Preview
🔇 Additional comments (2)
packages/query-core/src/queriesObserver.ts (2)

238-251: LGTM! Array-based grouping prevents observer conflicts.

The shift from a single-observer map to Map<string, Array<QueryObserver>> is the core fix. By grouping existing observers by their queryHash, each duplicate query can claim its own distinct observer instance (via .shift() later), eliminating the state thrashing that caused infinite renders.

The early return on line 242 safely handles the case where queryHash might be undefined, avoiding map pollution and correctly addressing the type-safety concern raised in the previous review.


257-264: LGTM! Reuse-or-create logic ensures stable observer pairing.

The .shift() on line 257 pops the first available observer from the grouped array (if any), ensuring each query is paired with a distinct, stable observer instance. When no cached observer exists, line 259 creates a fresh one. This guarantees that duplicate queryKey entries won't share a single observer and trigger re-configuration conflicts during the same render cycle.

Note: This does create additional observers for duplicate keys, increasing memory usage, but since duplicate keys are already unsupported and emit a warning (lines 94-103), the trade-off is acceptable for correctness.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@olivierbeaulieu olivierbeaulieu changed the title Prevent infinite render loops when useSuspenseQueries has duplicate queryKeys fix: Prevent infinite render loops when useSuspenseQueries has duplicate queryKeys Nov 19, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
packages/query-core/src/queriesObserver.ts (1)

254-263: Observer consumption logic correctly prevents state thrashing.

The use of shift() at line 256 ensures each query gets its own observer instance, even when multiple queries share the same queryHash. This prevents the infinite loop that occurred when a single observer was reconfigured multiple times per render with conflicting options.

The fallback to new QueryObserver at line 257 handles cases where no previous observer exists for reuse. The logic is sound and maintains observer stability across renders.

Optional: While shift() is O(n), it's acceptable here since duplicate keys are rare and arrays are small. If performance becomes a concern in the future, consider tracking an index instead of mutating the array. However, this is a minor optimization and not necessary now.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 66a194e and 429c4a5.

📒 Files selected for processing (3)
  • .changeset/nine-shoes-tap.md (1 hunks)
  • packages/query-core/src/queriesObserver.ts (1 hunks)
  • packages/react-query/src/__tests__/useSuspenseQueries.test.tsx (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-02T17:57:33.184Z
Learnt from: TkDodo
Repo: TanStack/query PR: 9612
File: packages/query-async-storage-persister/src/asyncThrottle.ts:0-0
Timestamp: 2025-09-02T17:57:33.184Z
Learning: When importing from tanstack/query-core in other TanStack Query packages like query-async-storage-persister, a workspace dependency "tanstack/query-core": "workspace:*" needs to be added to the package.json.

Applied to files:

  • .changeset/nine-shoes-tap.md
🧬 Code graph analysis (2)
packages/react-query/src/__tests__/useSuspenseQueries.test.tsx (1)
packages/react-query/src/__tests__/utils.tsx (1)
  • renderWithClient (9-23)
packages/query-core/src/queriesObserver.ts (1)
packages/query-core/src/queryObserver.ts (2)
  • QueryObserver (41-747)
  • options (379-385)
🔇 Additional comments (3)
packages/react-query/src/__tests__/useSuspenseQueries.test.tsx (1)

186-257: Excellent test coverage for the duplicate key scenario.

The test properly validates that duplicate query keys with different selectors no longer cause infinite render loops. The structure is clear:

  • Two queries share the same base key but have different select functions
  • renderCount tracks total renders to detect loops
  • Assertions verify single suspend/resolution and bounded renders

Minor suggestion: The renderCount < 10 assertion at line 256 is somewhat arbitrary. Consider adding a comment explaining the expected render count (e.g., "Expected: 1 suspend + 1 resolution ≈ 2-3 renders") to make the test's intent clearer.

packages/query-core/src/queriesObserver.ts (1)

238-250: LGTM! Observer grouping by queryHash is correctly implemented.

The change from Map<string, QueryObserver> to Map<string, Array<QueryObserver>> is the key fix. Each queryHash now maps to an array of observers, enabling proper tracking when duplicate query keys exist. The grouping logic correctly handles both the case where an entry already exists (push to array) and when it doesn't (create new array).

.changeset/nine-shoes-tap.md (1)

1-6: LGTM! Changeset properly documents the patch release.

The changeset correctly identifies both affected packages (@tanstack/react-query and @tanstack/query-core) and uses patch version bumps, which is appropriate for a bug fix. The description clearly states what issue is being resolved.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/query-core/src/queriesObserver.ts (1)

253-262: Observer matching logic is correct with minor performance consideration.

The use of shift() to consume observers from the array ensures each query gets a distinct observer instance, which is exactly what's needed to prevent the state thrashing. The FIFO order provides reasonable stability when query order doesn't change.

Performance note: shift() has O(n) complexity for arrays, but given that duplicate query keys are unsupported (and a warning is logged), the arrays should be small in practice, making this acceptable.

If performance becomes a concern with many duplicates in the future, consider using an index-based approach:

const prevObserversMap = new Map<string, { observers: Array<QueryObserver>; index: number }>()

// During matching:
const entry = prevObserversMap.get(defaultedOptions.queryHash)
const match = entry && entry.index < entry.observers.length 
  ? entry.observers[entry.index++] 
  : undefined

However, this optimization is not necessary given the current use case.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 429c4a5 and 495c063.

📒 Files selected for processing (1)
  • packages/query-core/src/queriesObserver.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/query-core/src/queriesObserver.ts (1)
packages/query-core/src/queryObserver.ts (2)
  • QueryObserver (41-747)
  • options (379-385)
🔇 Additional comments (1)
packages/query-core/src/queriesObserver.ts (1)

238-249: Excellent fix for the duplicate key issue!

The change from mapping queryHash to a single observer to mapping it to an array of observers correctly addresses the root cause of the infinite render loop. This ensures each query with a duplicate key gets its own stable observer instance instead of sharing one.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
packages/query-core/src/queriesObserver.ts (1)

255-264: Observer matching logic is sound.

The use of shift() (line 257) ensures each query gets a distinct observer from the pool, with new observers created only when needed. The optional chaining and nullish coalescing handle all edge cases correctly.

Consider adding a brief comment explaining the shift pattern for future maintainers, as it's a key part of preventing the duplicate key issue:

   queries.forEach((options) => {
     const defaultedOptions = this.#client.defaultQueryOptions(options)
+    // Shift ensures each query gets its own observer, preventing shared state conflicts
     const match = prevObserversMap.get(defaultedOptions.queryHash)?.shift()
     const observer = match ?? new QueryObserver(this.#client, defaultedOptions)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 495c063 and 8f6ecf5.

📒 Files selected for processing (1)
  • packages/query-core/src/queriesObserver.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/query-core/src/queriesObserver.ts (1)
packages/query-core/src/queryObserver.ts (2)
  • QueryObserver (41-747)
  • options (379-385)
🔇 Additional comments (1)
packages/query-core/src/queriesObserver.ts (1)

238-251: Excellent fix for the infinite render loop issue.

The array-based grouping is the right approach to handle duplicate query keys. Each query will now get its own stable observer instance instead of sharing one, which prevents the state thrashing that caused infinite re-renders.

Also, I notice the past review concern about the non-null assertion on queryHash has been addressed. The code now reads observer.options.queryHash without the ! assertion (line 241) and includes a defensive check if (!key) return (line 242) to handle undefined cases gracefully.

@nx-cloud
Copy link

nx-cloud bot commented Nov 25, 2025

View your CI Pipeline Execution ↗ for commit a3ae6a0

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 4m 7s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 1m 24s View ↗

☁️ Nx Cloud last updated this comment at 2025-11-25 16:34:26 UTC

@pkg-pr-new
Copy link

pkg-pr-new bot commented Nov 25, 2025

More templates

@tanstack/angular-query-experimental

npm i https://pkg.pr.new/@tanstack/angular-query-experimental@9886

@tanstack/eslint-plugin-query

npm i https://pkg.pr.new/@tanstack/eslint-plugin-query@9886

@tanstack/query-async-storage-persister

npm i https://pkg.pr.new/@tanstack/query-async-storage-persister@9886

@tanstack/query-broadcast-client-experimental

npm i https://pkg.pr.new/@tanstack/query-broadcast-client-experimental@9886

@tanstack/query-core

npm i https://pkg.pr.new/@tanstack/query-core@9886

@tanstack/query-devtools

npm i https://pkg.pr.new/@tanstack/query-devtools@9886

@tanstack/query-persist-client-core

npm i https://pkg.pr.new/@tanstack/query-persist-client-core@9886

@tanstack/query-sync-storage-persister

npm i https://pkg.pr.new/@tanstack/query-sync-storage-persister@9886

@tanstack/react-query

npm i https://pkg.pr.new/@tanstack/react-query@9886

@tanstack/react-query-devtools

npm i https://pkg.pr.new/@tanstack/react-query-devtools@9886

@tanstack/react-query-next-experimental

npm i https://pkg.pr.new/@tanstack/react-query-next-experimental@9886

@tanstack/react-query-persist-client

npm i https://pkg.pr.new/@tanstack/react-query-persist-client@9886

@tanstack/solid-query

npm i https://pkg.pr.new/@tanstack/solid-query@9886

@tanstack/solid-query-devtools

npm i https://pkg.pr.new/@tanstack/solid-query-devtools@9886

@tanstack/solid-query-persist-client

npm i https://pkg.pr.new/@tanstack/solid-query-persist-client@9886

@tanstack/svelte-query

npm i https://pkg.pr.new/@tanstack/svelte-query@9886

@tanstack/svelte-query-devtools

npm i https://pkg.pr.new/@tanstack/svelte-query-devtools@9886

@tanstack/svelte-query-persist-client

npm i https://pkg.pr.new/@tanstack/svelte-query-persist-client@9886

@tanstack/vue-query

npm i https://pkg.pr.new/@tanstack/vue-query@9886

@tanstack/vue-query-devtools

npm i https://pkg.pr.new/@tanstack/vue-query-devtools@9886

commit: a3ae6a0

@TkDodo
Copy link
Collaborator

TkDodo commented Nov 25, 2025

nice work! does that mean we can actually support the same key multiple times within useQueries?

@codecov
Copy link

codecov bot commented Nov 25, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 59.84%. Comparing base (46ebef2) to head (a3ae6a0).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             main    #9886       +/-   ##
===========================================
+ Coverage   45.79%   59.84%   +14.05%     
===========================================
  Files         200      129       -71     
  Lines        8421     5640     -2781     
  Branches     1928     1536      -392     
===========================================
- Hits         3856     3375      -481     
+ Misses       4116     1959     -2157     
+ Partials      449      306      -143     
Components Coverage Δ
@tanstack/angular-query-experimental 93.85% <ø> (ø)
@tanstack/eslint-plugin-query ∅ <ø> (∅)
@tanstack/query-async-storage-persister 43.85% <ø> (ø)
@tanstack/query-broadcast-client-experimental 24.39% <ø> (ø)
@tanstack/query-codemods ∅ <ø> (∅)
@tanstack/query-core 97.39% <100.00%> (+<0.01%) ⬆️
@tanstack/query-devtools 3.48% <ø> (ø)
@tanstack/query-persist-client-core 80.00% <ø> (ø)
@tanstack/query-sync-storage-persister 84.61% <ø> (ø)
@tanstack/query-test-utils ∅ <ø> (∅)
@tanstack/react-query 96.01% <ø> (ø)
@tanstack/react-query-devtools 9.25% <ø> (ø)
@tanstack/react-query-next-experimental ∅ <ø> (∅)
@tanstack/react-query-persist-client 100.00% <ø> (ø)
@tanstack/solid-query 77.81% <ø> (ø)
@tanstack/solid-query-devtools 64.17% <ø> (ø)
@tanstack/solid-query-persist-client 100.00% <ø> (ø)
@tanstack/svelte-query ∅ <ø> (∅)
@tanstack/svelte-query-devtools ∅ <ø> (∅)
@tanstack/svelte-query-persist-client ∅ <ø> (∅)
@tanstack/vue-query 71.28% <ø> (ø)
@tanstack/vue-query-devtools ∅ <ø> (∅)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@TkDodo TkDodo merged commit c01b150 into TanStack:main Nov 25, 2025
9 checks passed
@github-actions github-actions bot mentioned this pull request Nov 25, 2025
@olivierbeaulieu
Copy link
Contributor Author

olivierbeaulieu commented Nov 25, 2025

@TkDodo I'm not sure if this issue still applies? #4187

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants