Skip to content

Conversation

Sheraff
Copy link
Contributor

@Sheraff Sheraff commented Aug 31, 2025

This PR replicates the changes that were done in TanStack/router#5046.

TL;DR: Improve replaceEqualDeep performance by ~2x

How?

  • pre-allocate new arrays w/ their eventual size (new Array(bSize)), to avoid the memory management that happens under the hood every time we add an item to it
  • avoid re-reading the same value many times (a[key]) and instead store it as const aItem
  • use hasOwnProperty instead of creating a Set of the keys, because in theory hasOwnProperty is already O(1) for lookups, so we don't need to create an extra data structure for this
  • avoid recursion if we can return early instead, as recursion needs a new entry in the stack

Benchmark

describe('replaceEqualDeep', () => {
  bench('old implementation', () => {
    replaceEqualDeepOld({ a: 1, b: [2, 3] }, { a: 1, b: [2, 3] })
    replaceEqualDeepOld({ a: 1, b: [2, 3] }, { a: 2, b: [2] })
  })

  bench('new implementation', () => {
    replaceEqualDeep({ a: 1, b: [2, 3] }, { a: 1, b: [2, 3] })
    replaceEqualDeep({ a: 1, b: [2, 3] }, { a: 2, b: [2] })
  })
})

Results

 ✓  @tanstack/query-core  src/__tests__/utils.bench.ts > replaceEqualDeep 1794ms
     name                          hz     min     max    mean     p75     p99    p995    p999     rme  samples
   · old implementation  1,513,008.76  0.0006  0.0660  0.0007  0.0007  0.0009  0.0010  0.0016  ±0.08%   756505
   · new implementation  3,112,607.29  0.0002  0.1535  0.0003  0.0003  0.0004  0.0005  0.0005  ±0.09%  1556310   fastest

 BENCH  Summary

   @tanstack/query-core  new implementation - src/__tests__/utils.bench.ts > replaceEqualDeep
    2.06x faster than old implementation

Summary by CodeRabbit

  • Bug Fixes

    • Improved deep-merge behavior for arrays and plain objects to avoid unnecessary updates and preserve unchanged values, preventing spurious state changes.
  • Refactor

    • Reworked deep-merge logic with stricter plain-object/array detection and added type safety checks for more reliable public API behavior and fewer allocations.

Copy link

coderabbitai bot commented Aug 31, 2025

Walkthrough

Replaces previous replaceEqualDeep with a unified deep-merge handling arrays and plain objects, adds type guards (isPlainArray, isPlainObject) and a hasOwn alias, tightens plain-object detection, and widens exported typings to unknown/any.

Changes

Cohort / File(s) Summary
Deep-merge & typings
packages/query-core/src/utils.ts
Replaced replaceEqualDeep with a unified algorithm that: returns b for non-matching collection types, iterates b to build merged copies for arrays/plain objects, preserves identical items via strict-equality, recursively merges nested collections, and returns original a when fully equal. Added hasOwn = Object.prototype.hasOwnProperty, isPlainArray and isPlainObject type-guard predicates, stricter plain-object checks (prototype/constructor), and widened exported typings (unknown/any).

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Caller
  participant Utils as replaceEqualDeep

  Caller->>Utils: replaceEqualDeep(a, b)
  alt not both arrays nor both plain objects
    Utils-->>Caller: return b
  else Arrays
    Note over Utils: build copy = new Array(b.length)
    loop for each index i
      Utils->>Utils: aItem = a[i]\nrecurse = replaceEqualDeep(aItem, b[i])
      alt aItem === b[i] or both undefined
        Note over Utils: equalItems++
      else
        Note over Utils: copy[i] = recurse
      end
    end
    alt lengths match and equalItems == a.length
      Utils-->>Caller: return a
    else
      Utils-->>Caller: return copy
    end
  else Plain Objects
    Note over Utils: build copy = {}
    loop for each own key in b
      Utils->>Utils: aItem = a[key]\nrecurse = replaceEqualDeep(aItem, b[key])
      alt aItem === b[key] or both undefined
        Note over Utils: equalItems++
      else
        Note over Utils: copy[key] = recurse
      end
    end
    alt own-key counts match and equalItems == aKeyCount
      Utils-->>Caller: return a
    else
      Utils-->>Caller: return copy
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

I hop through arrays and burrow in objects deep,
Counting equal footsteps where the old values keep.
With careful paws I check each key and strand,
Return the same if nothing changed, else stitch with my hand.
Thump-thump — a tidy merge from a rabbit’s leap. 🐇

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

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

nx-cloud bot commented Aug 31, 2025

View your CI Pipeline Execution ↗ for commit c433b8f

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

☁️ Nx Cloud last updated this comment at 2025-09-02 07:15:51 UTC

Copy link

pkg-pr-new bot commented Aug 31, 2025

More templates

@tanstack/angular-query-devtools-experimental

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

@tanstack/angular-query-experimental

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

@tanstack/eslint-plugin-query

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

@tanstack/query-async-storage-persister

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

@tanstack/query-broadcast-client-experimental

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

@tanstack/query-core

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

@tanstack/query-devtools

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

@tanstack/query-persist-client-core

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

@tanstack/query-sync-storage-persister

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

@tanstack/react-query

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

@tanstack/react-query-devtools

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

@tanstack/react-query-next-experimental

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

@tanstack/react-query-persist-client

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

@tanstack/solid-query

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

@tanstack/solid-query-devtools

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

@tanstack/solid-query-persist-client

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

@tanstack/svelte-query

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

@tanstack/svelte-query-devtools

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

@tanstack/svelte-query-persist-client

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

@tanstack/vue-query

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

@tanstack/vue-query-devtools

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

commit: c433b8f

Copy link

codecov bot commented Aug 31, 2025

Codecov Report

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

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             main    #9604       +/-   ##
===========================================
+ Coverage   45.17%   59.29%   +14.12%     
===========================================
  Files         208      137       -71     
  Lines        8327     5570     -2757     
  Branches     1879     1501      -378     
===========================================
- Hits         3762     3303      -459     
+ 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.42% <100.00%> (+<0.01%) ⬆️
@tanstack/query-devtools 3.48% <ø> (ø)
@tanstack/query-persist-client-core 79.47% <ø> (ø)
@tanstack/query-sync-storage-persister 84.61% <ø> (ø)
@tanstack/query-test-utils ∅ <ø> (∅)
@tanstack/react-query 95.95% <ø> (ø)
@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

@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/utils.ts (1)

273-286: Micro-opt: hoist hasOwn and avoid double reads of b[key]

Hoist hasOwn and reuse bItem to shave a couple of lookups in tight loops.

Apply:

-  let equalItems = 0
+  const hasOwn = Object.prototype.hasOwnProperty
+  let equalItems = 0
@@
-    if (
-      (array || Object.prototype.hasOwnProperty.call(a, key)) &&
+    if (
+      (array || hasOwn.call(a, key)) &&
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between b998f68 and e6029db.

📒 Files selected for processing (1)
  • packages/query-core/src/utils.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/query-core/src/utils.ts (1)
packages/query-core/src/index.ts (1)
  • replaceEqualDeep (19-19)
⏰ 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: Preview
  • GitHub Check: Test
🔇 Additional comments (3)
packages/query-core/src/utils.ts (3)

262-263: Confirm intent: bail out for non-plain arrays/objects

Early-returning b when neither a plain array nor a plain object skips structural sharing for sparse arrays, typed arrays, class instances, Dates, Maps/Sets, etc. Verify this matches prior behavior and expectations for consumers. If parity with previous semantics is desired, add tests around sparse arrays and typed arrays.


264-269: Prealloc and key-caching look good

Pre-allocating new Array(bSize) and caching key lists should reduce allocations and property lookups on hot paths.


292-292: Return condition reads clearly

Reusing a only when sizes match and all items are equal preserves structural sharing without false positives. LGTM.

Copy link

@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

♻️ Duplicate comments (1)
packages/query-core/src/utils.ts (1)

271-283: Cache bItem to avoid repeated property lookups inside the hot loop.

Reads b[key] twice per iteration. Caching improves locality and aligns with the PR’s stated perf goals.

   for (let i = 0; i < bSize; i++) {
     const key = array ? i : bItems[i]
     const aItem = a[key]
+    const bItem = b[key]
     if (
-      (array || Object.prototype.hasOwnProperty.call(a, key)) &&
-      aItem === undefined &&
-      b[key] === undefined
+      (array || Object.prototype.hasOwnProperty.call(a, key)) &&
+      aItem === undefined &&
+      bItem === undefined
     ) {
       copy[key] = undefined
       equalItems++
     } else {
-      const value = replaceEqualDeep(aItem, b[key])
+      const value = replaceEqualDeep(aItem, bItem)
       copy[key] = value
       if (value === aItem && aItem !== undefined) {
         equalItems++
       }
     }
   }
🧹 Nitpick comments (1)
packages/query-core/src/utils.ts (1)

261-268: Early-return and pre-allocation look good; trim an unnecessary allocation.

aItems is only used to compute aSize. Avoid creating it to reduce work/memory.

-  const aItems = array ? a : Object.keys(a)
-  const aSize = aItems.length
+  const aSize = array ? a.length : Object.keys(a).length
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 5e527f3 and 1007495.

📒 Files selected for processing (1)
  • packages/query-core/src/utils.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/query-core/src/utils.ts (1)
packages/query-core/src/index.ts (1)
  • replaceEqualDeep (19-19)
⏰ 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 (1)
packages/query-core/src/utils.ts (1)

275-276: Prototype-safe hasOwnProperty: LGTM.

Switching to Object.prototype.hasOwnProperty.call(a, key) avoids issues when hasOwnProperty is shadowed.

Copy link

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/query-core/src/utils.ts (1)

373-402: Use safe own-check and correct prototype comment

  • Change the own-property check on prot to use the helper for safety:
    if (!hasOwn.call(prot, 'isPrototypeOf')) {
      return false
    }
  • Revise the comment before the getPrototypeOf guard to accurately state that we reject any object whose prototype isn’t exactly Object.prototype (null-proto objects are already accepted by the early ctor === undefined return).
♻️ Duplicate comments (1)
packages/query-core/src/utils.ts (1)

248-248: Good fix: prototype-safe own-property check and cached reads.

Resolved the earlier “shadowed hasOwnProperty” concern and reduced repeated property access by caching aItem/bItem. Looks solid.

Also applies to: 319-327

🧹 Nitpick comments (3)
packages/query-core/src/utils.ts (3)

255-256: Prefer unknown over any in the implementation signature.

Keeping the overload replaceEqualDeep<T>(a: unknown, b: T): T is great. For the implementation, returning unknown avoids leaking any while staying compatible with the overloads.

-export function replaceEqualDeep(a: unknown, b: unknown): any {
+export function replaceEqualDeep(a: unknown, b: unknown): unknown {

311-345: Object path: tight loop and O(1) own-checks LGTM.

  • for..in over b avoids materializing key arrays.
  • Equality counting gated by hasOwn.call(a, k) is correct.
  • Recursion preserves references where possible.

One note: symbol keys aren’t considered (consistent with JSON-only constraint). If that’s intentional, consider documenting it near replaceData to preempt surprises.


368-370: Sparse/augmented arrays won’t be treated as “plain arrays”. Please confirm.

isPlainArray returns false for sparse arrays or arrays with extra enumerable props (length !== Object.keys(value).length). That changes behavior vs a naive Array.isArray check and will fall back to “return b” instead of structural sharing. If intentional, add tests for:

  • sparse arrays (holes)
  • arrays with extra enumerable props

I can draft these tests if desired.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 1007495 and 3c69c31.

📒 Files selected for processing (1)
  • packages/query-core/src/utils.ts (2 hunks)
⏰ 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/utils.ts (2)

261-299: Array path perf refactor looks correct.

Pre-allocation + strict-equality fast path + recursive sharing are implemented correctly, and the equality accounting (i < aSize) handles length mismatches safely.


301-309: Type-mismatch early return preserves previous semantics.

Returning b when only one side is an array or a non-plain object matches the intended “JSON-only” structural sharing behavior. Good.

Copy link

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/query-core/src/utils.ts (2)

345-346: Don’t call a potentially shadowed hasOwnProperty on prototypes

prot.hasOwnProperty('isPrototypeOf') can throw if hasOwnProperty is overridden on that prototype. Use the prototype-safe helper.

Apply:

-  if (!prot.hasOwnProperty('isPrototypeOf')) {
+  if (!hasOwn.call(prot, 'isPrototypeOf')) {

327-356: Allow null-prototype objects in isPlainObject
Relax the final prototype check so that Object.getPrototypeOf(o) === null is treated as plain (e.g. change

if (Object.getPrototypeOf(o) !== Object.prototype) {
  return false
}

to something like

const proto = Object.getPrototypeOf(o)
if (proto !== Object.prototype && proto !== null) {
  return false
}

) to restore the existing test behavior in utils.test.tsx.

♻️ Duplicate comments (1)
packages/query-core/src/utils.ts (1)

248-248: Prototype-safe own-property helper: LGTM

Using a cached reference and calling via .call avoids shadowing risks. This addresses the earlier review concern.

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

322-324: Clarify dense-array intent in isPlainArray

Minor: add a short doc comment noting that sparse arrays or arrays with extra enumerable props return false.

+/** True only for dense Arrays with no extra enumerable properties (sparse arrays excluded). */
 export function isPlainArray(value: unknown): value is Array<unknown> {
   return Array.isArray(value) && value.length === Object.keys(value).length
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 3c69c31 and 91494ca.

📒 Files selected for processing (1)
  • packages/query-core/src/utils.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/query-core/src/utils.ts (1)
packages/query-core/src/index.ts (1)
  • replaceEqualDeep (19-19)
⏰ 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 (1)
packages/query-core/src/utils.ts (1)

263-300: replaceEqualDeep hot path and allocation strategy: LGTM

Early bailout + preallocated arrays + cached aItem/bItem lookups are clean and align with the perf goals. The equality accounting also correctly preserves a only when sizes match and all items are equal.

@TkDodo TkDodo merged commit 7d24156 into TanStack:main Sep 2, 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.

2 participants