Skip to content

Conversation

m2na7
Copy link
Contributor

@m2na7 m2na7 commented Sep 10, 2025

Fixes #9638

useQueries was causing infinite re-renders when dynamically removing the last query from the queries array. This issue was introduced in v5.85.9 after PR #9592.

Problem

The queriesObserver.ts change detection logic had a flaw when handling array length changes:

  • When removing the last query: hasIndexChange = false but hasResultChange = true
  • This bypassed the early return condition, leading to infinite update cycles

Solution

Improved the change detection logic in queriesObserver.ts and added test in useQueries.test.tsx

// AS-IS
const hasIndexChange = newObservers.some(
  (observer, index) => observer !== prevObservers[index],
)

const hasResultChange =
  prevObservers.length === newObservers.length && !hasIndexChange
    ? newResult.some((result, index) => {
        const prev = this.#result[index]
        return !prev || !shallowEqualObjects(result, prev)
      })
    : true  // Issue: array length changes always return true

if (!hasIndexChange && !hasResultChange) return  // Missing condition!
// TO-BE
const hasLengthChange = prevObservers.length !== newObservers.length
const hasIndexChange = newObservers.some(
  (observer, index) => observer !== prevObservers[index],
)
const hasStructuralChange = hasLengthChange || hasIndexChange

// Only check result changes when there’s no structural change
const hasResultChange = hasStructuralChange
  ? true
  : newResult.some((result, index) => {
      const prev = this.#result[index]
      return !prev || !shallowEqualObjects(result, prev)
    })

if (!hasStructuralChange && !hasResultChange) return

Summary by CodeRabbit

  • Bug Fixes

    • Prevents infinite re-renders when removing the last query in useQueries.
    • Improves stability when adding, removing, or reordering queries, ensuring results update correctly and only when needed.
    • Ensures new queries are properly subscribed and removed ones are cleaned up to avoid stale updates.
  • Tests

    • Added coverage to verify no re-render loops when removing the last or first query and that the query count updates as expected.

Copy link
Contributor

coderabbitai bot commented Sep 10, 2025

Walkthrough

Introduces structural-change detection in queriesObserver to handle length/index differences, adjusts result-change and early-return logic accordingly, and updates subscription/destroy behavior for added/removed observers. Adds a React test to verify no infinite re-renders when removing the last query from useQueries.

Changes

Cohort / File(s) Summary of edits
Observer structural/change detection
packages/query-core/src/queriesObserver.ts
Added hasLengthChange/hasIndexChange and derived hasStructuralChange; recalculated hasResultChange; modified early-exit; on structural change, replaced observer list, destroyed removed observers, subscribed new ones, and set options before updates.
React test for regressions
packages/react-query/src/__tests__/useQueries.test.tsx
Added test ensuring removing the last query from a useQueries array does not cause infinite re-renders; validates render count and query count updates.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor C as React Component (useQueries)
  participant QO as QueriesObserver
  participant Obs as QueryObserver(s)

  C->>QO: setQueries(newQueries)
  QO->>QO: Compute hasLengthChange, hasIndexChange
  QO->>QO: hasStructuralChange = OR(len, idx)
  alt Structural change
    QO->>Obs: Destroy removed observers
    QO->>Obs: Subscribe newly added observers
    QO->>Obs: Set options on new/current observers
    QO->>QO: Update internal observers list
  else No structural change
    QO->>QO: Compare results (shallow) to detect changes
  end
  QO-->>C: Notify only if structural or result change
  note over QO,C: Prevents re-notify loops when removing last item
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • TkDodo

Pre-merge checks (4 passed, 1 warning)

❌ 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 (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The pull request title clearly and succinctly identifies the key fix of infinite re-renders in useQueries and accurately notes the affected packages, making it a concise summary of the primary change.
Linked Issues Check ✅ Passed The pull request directly addresses the bug described in issue #9638 by refining the change detection logic to handle structural and result changes correctly and includes a new test case for removing the last query, thereby fulfilling the linked issue’s primary objectives.
Out of Scope Changes Check ✅ Passed All code modifications are confined to the change detection logic in query-core and the corresponding test in react-query, aligning with the linked issue’s scope and introducing no unrelated or extraneous changes.

Poem

I twitch my nose at lists that shrink,
The last one hops—no endless blink.
I tidy burrows, add and prune,
Subscriptions hum a calmer tune.
Now queries rest, no frantic spree—
One hop, one render, bug set free. 🐇✨

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

Copy link

nx-cloud bot commented Sep 10, 2025

View your CI Pipeline Execution ↗ for commit 8dda7d3

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

☁️ Nx Cloud last updated this comment at 2025-09-10 12:45:14 UTC

Copy link

pkg-pr-new bot commented Sep 10, 2025

More templates

@tanstack/angular-query-devtools-experimental

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

@tanstack/angular-query-experimental

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

@tanstack/eslint-plugin-query

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

@tanstack/query-async-storage-persister

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

@tanstack/query-broadcast-client-experimental

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

@tanstack/query-core

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

@tanstack/query-devtools

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

@tanstack/query-persist-client-core

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

@tanstack/query-sync-storage-persister

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

@tanstack/react-query

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

@tanstack/react-query-devtools

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

@tanstack/react-query-next-experimental

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

@tanstack/react-query-persist-client

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

@tanstack/solid-query

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

@tanstack/solid-query-devtools

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

@tanstack/solid-query-persist-client

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

@tanstack/svelte-query

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

@tanstack/svelte-query-devtools

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

@tanstack/svelte-query-persist-client

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

@tanstack/vue-query

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

@tanstack/vue-query-devtools

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

commit: 8dda7d3

Copy link

codecov bot commented Sep 10, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 59.64%. Comparing base (ccedf33) to head (8dda7d3).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             main    #9639       +/-   ##
===========================================
+ Coverage   45.50%   59.64%   +14.13%     
===========================================
  Files         209      138       -71     
  Lines        8377     5617     -2760     
  Branches     1897     1514      -383     
===========================================
- Hits         3812     3350      -462     
+ Misses       4118     1963     -2155     
+ Partials      447      304      -143     
Components Coverage Δ
@tanstack/angular-query-devtools-experimental ∅ <ø> (∅)
@tanstack/angular-query-experimental 87.00% <ø> (ø)
@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.48% <100.00%> (+<0.01%) ⬆️
@tanstack/query-devtools 3.48% <ø> (ø)
@tanstack/query-persist-client-core 79.60% <ø> (ø)
@tanstack/query-sync-storage-persister 84.61% <ø> (ø)
@tanstack/query-test-utils ∅ <ø> (∅)
@tanstack/react-query 96.00% <ø> (ø)
@tanstack/react-query-devtools 10.00% <ø> (ø)
@tanstack/react-query-next-experimental ∅ <ø> (∅)
@tanstack/react-query-persist-client 100.00% <ø> (ø)
@tanstack/solid-query 78.13% <ø> (ø)
@tanstack/solid-query-devtools ∅ <ø> (∅)
@tanstack/solid-query-persist-client 100.00% <ø> (ø)
@tanstack/svelte-query 87.58% <ø> (ø)
@tanstack/svelte-query-devtools ∅ <ø> (∅)
@tanstack/svelte-query-persist-client 100.00% <ø> (ø)
@tanstack/vue-query 71.10% <ø> (ø)
@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.

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 (4)
packages/query-core/src/queriesObserver.ts (2)

121-126: Skip index scan when length already differs.

A tiny perf nit: short-circuit hasIndexChange if lengths differ to avoid an O(n) scan you already know will be irrelevant.

Apply:

-      const hasLengthChange = prevObservers.length !== newObservers.length
-      const hasIndexChange = newObservers.some(
-        (observer, index) => observer !== prevObservers[index],
-      )
+      const hasLengthChange = prevObservers.length !== newObservers.length
+      const hasIndexChange =
+        !hasLengthChange &&
+        newObservers.some(
+          (observer, index) => observer !== prevObservers[index],
+        )

144-153: Avoid building two Sets for differences.

Minor micro-opt: compute added/removed with precomputed sets once instead of calling difference twice (each builds a Set).

Apply:

-      if (hasStructuralChange) {
-        difference(prevObservers, newObservers).forEach((observer) => {
-          observer.destroy()
-        })
-        difference(newObservers, prevObservers).forEach((observer) => {
-          observer.subscribe((result) => {
-            this.#onUpdate(observer, result)
-          })
-        })
-      }
+      if (hasStructuralChange) {
+        const prevSet = new Set(prevObservers)
+        const nextSet = new Set(newObservers)
+        const removed = prevObservers.filter((o) => !nextSet.has(o))
+        const added = newObservers.filter((o) => !prevSet.has(o))
+
+        removed.forEach((observer) => {
+          observer.destroy()
+        })
+        added.forEach((observer) => {
+          observer.subscribe((result) => {
+            this.#onUpdate(observer, result)
+          })
+        })
+      }
packages/react-query/src/__tests__/useQueries.test.tsx (2)

1749-1758: Prefer unique keys via queryKey() to avoid cross-test collisions.

Using generated keys mirrors existing tests and minimizes shared-client interference.

Apply:

-    function Page() {
-      const [queries, setQueries] = React.useState([
+    function Page() {
+      const k1 = queryKey()
+      const k2 = queryKey()
+      const [queries, setQueries] = React.useState([
         {
-          queryKey: ['query1'],
+          queryKey: k1,
           queryFn: () => 'data1',
         },
         {
-          queryKey: ['query2'],
+          queryKey: k2,
           queryFn: () => 'data2',
         },
       ])
@@
           <button
             onClick={() => {
               setQueries([
                 {
-                  queryKey: ['query1'],
+                  queryKey: k1,
                   queryFn: () => 'data1',
                 },
               ])
             }}
           >
             remove last
           </button>
@@
           <button
             onClick={() => {
               setQueries([
                 {
-                  queryKey: ['query2'],
+                  queryKey: k2,
                   queryFn: () => 'data2',
                 },
               ])
             }}
           >
             remove first
           </button>

Also applies to: 1769-1775, 1781-1787


1746-1766: Track render count with a ref; avoid manual resets mid-test.

Keeps counting logic inside the component and removes the need to mutate outer scope.

Apply:

-    let renderCount = 0
+    let renderCount = 0
@@
-      renderCount++
+      renderCount++
@@
-    await vi.advanceTimersByTimeAsync(0)
-    renderCount = 0
+    await vi.advanceTimersByTimeAsync(0)
@@
-    renderCount = 0
+    // no manual reset needed

Alternative (cleaner inside component):

-    let renderCount = 0
+    const rendersRef = React.useRef(0)
@@
-      renderCount++
+      rendersRef.current++
@@
-          <div data-testid="render-count">renders: {renderCount}</div>
+          <div data-testid="render-count">renders: {rendersRef.current}</div>

Then assert by reading the DOM value instead of mutating a closed-over variable.

Also applies to: 1797-1807

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ccedf33 and 8dda7d3.

📒 Files selected for processing (2)
  • packages/query-core/src/queriesObserver.ts (1 hunks)
  • packages/react-query/src/__tests__/useQueries.test.tsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
packages/react-query/src/__tests__/useQueries.test.tsx (3)
packages/query-core/src/queriesObserver.ts (3)
  • queries (235-261)
  • setQueries (87-157)
  • result (195-210)
packages/react-query/src/useQueries.ts (1)
  • useQueries (208-332)
packages/react-query/src/__tests__/utils.tsx (1)
  • renderWithClient (9-23)
packages/query-core/src/queriesObserver.ts (1)
packages/query-core/src/utils.ts (1)
  • shallowEqualObjects (306-321)
⏰ 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). (1)
  • GitHub Check: Test
🔇 Additional comments (2)
packages/query-core/src/queriesObserver.ts (1)

121-139: Solid structural-change detection fix; aligns with the bug’s root cause.

Introducing hasLengthChange and folding it into hasStructuralChange, plus guarding result comparisons behind structural checks, correctly handles removals at the tail and reorders. The early-return now reflects actual list structure/result stability, preventing the infinite update loop.

packages/react-query/src/__tests__/useQueries.test.tsx (1)

1745-1813: Good regression test; reproduces and guards the original failure mode.

Covers both removing the last and the first element; assertions are lenient enough to avoid flakes.

@TkDodo TkDodo merged commit 19445a0 into TanStack:main Sep 10, 2025
8 checks passed
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.

useQueries() causes infinite rerenders since @tanstack/[email protected]
2 participants