Skip to content

perf(router-core): faster enumerable-key collection in structural sharing#7664

Closed
anonrig wants to merge 1 commit into
TanStack:mainfrom
anonrig:perf/structural-sharing-enumerable-keys
Closed

perf(router-core): faster enumerable-key collection in structural sharing#7664
anonrig wants to merge 1 commit into
TanStack:mainfrom
anonrig:perf/structural-sharing-enumerable-keys

Conversation

@anonrig

@anonrig anonrig commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

What

Speed up getEnumerableOwnKeys — the helper that backs replaceEqualDeep (structural sharing) in packages/router-core/src/utils.ts.

It previously called Object.getOwnPropertyNames(o) and then invoked propertyIsEnumerable once per key to confirm every own string property is enumerable. This swaps that O(n) loop of JS method calls for Object.keys(o) (native, enumerable-only) plus a single length comparison against getOwnPropertyNames(o) to detect non-enumerable own string props. Symbol handling is unchanged.

-  const names = Object.getOwnPropertyNames(o)
-  for (const name of names) {
-    if (!isEnumerable.call(o, name)) return false
-  }
+  const keys = Object.keys(o)
+  if (keys.length !== Object.getOwnPropertyNames(o).length) return false

Why

replaceEqualDeep runs on every selector result on every state update when defaultStructuralSharing is enabled, so getEnumerableOwnKeys is called for every plain object it walks. For selector-heavy apps this is a hot client path (it short-circuits on the server, so this is purely a client-side win).

Benchmark on the real function (Vitest bench, node env where isServer is false), deeply-equal new state object shaped like typical router state (search / params / nested loaderData / context):

ops/s
before ~560K
after ~762K

1.36× faster in-situ (an isolated A/B of just the key-collection step measured ~1.55×). The win grows with object key count, since the old path made one propertyIsEnumerable call per key.

Correctness

Behavior is identical:

  • Same returned keys in the same order. Object.keys and Object.getOwnPropertyNames follow the same [[OwnPropertyKeys]] ordering (integer indices ascending, then string keys in insertion order), and in the non-bail case every string key is enumerable, so the two orderings coincide.
  • Still bails (returns false) for objects with any non-enumerable own string property (length mismatch) or any non-enumerable own symbol (existing per-symbol check).

Verified by:

  • The existing getEnumerableOwnKeys behavior (via replaceEqualDeep) suite (non-enumerable strings/symbols, frozen/sealed, Object.create(null), numeric keys, shadowing, mixed string+symbol).
  • A 5000+ case fuzz comparing the new key-collection against the old.
  • Two added tests: partial structural sharing on a nested object (unchanged subtrees keep their reference), and a nested object carrying a non-enumerable prop bailing to next.
nx run @tanstack/router-core:test:unit   -> 1180 passed (+ 3 expected-fail)
nx run @tanstack/router-core:test:types  -> no errors

Notes

  • Single-function change; replaceEqualDeep itself is untouched.
  • Includes a changeset (@tanstack/router-core patch).

Summary by CodeRabbit

  • Performance

    • Improved selector performance through optimized structural sharing computation in router core
  • Tests

    • Added test coverage for structural sharing behavior with nested objects and non-enumerable properties

…ring

getEnumerableOwnKeys (used by replaceEqualDeep) called
Object.getOwnPropertyNames and then invoked propertyIsEnumerable once per
key to verify every own string prop is enumerable. Replace that O(n) loop
of JS method calls with Object.keys (native, enumerable-only) plus a
single length comparison against getOwnPropertyNames to detect
non-enumerable string props. Symbol handling is unchanged.

Behavior is identical (verified against the existing suite + fuzzing): the
function still returns the enumerable own keys in the same order and still
bails (returns false) for objects with any non-enumerable own string or
symbol property.

replaceEqualDeep runs on every selector result on every state update when
defaultStructuralSharing is enabled, so this is a hot client path for
selector-heavy apps. Measured ~1.3-1.5x faster on typical router state.
@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

getEnumerableOwnKeys in utils.ts is refactored to detect non-enumerable string keys via an Object.keys vs Object.getOwnPropertyNames length comparison instead of iterating and calling propertyIsEnumerable per key. Two new tests for replaceEqualDeep are added, and a patch changeset entry is included.

Changes

Structural Sharing Enumerable Key Optimization

Layer / File(s) Summary
getEnumerableOwnKeys fast-path refactor and changeset
packages/router-core/src/utils.ts, .changeset/perf-structural-sharing-enumerable-keys.md
getEnumerableOwnKeys now compares Object.keys(obj).length against Object.getOwnPropertyNames(obj).length to detect non-enumerable string keys in one shot instead of looping with propertyIsEnumerable; symbol handling is retained. A patch changeset documents the optimization.
replaceEqualDeep identity-sharing and non-enumerable tests
packages/router-core/tests/utils.test.ts
Two new test cases added: one asserting unchanged nested subtree references are preserved when only a leaf value changes, and one asserting that a nested object with a non-enumerable property causes bail-out to the next reference while unchanged siblings retain identity.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • TanStack/router#7577: Refactors React hooks to centralize structural-sharing logic using the same replaceEqualDeep / useStructuralSharing implementation that this PR optimizes.

Poem

🐇 Hop, hop — no more looping through each key,
One length compare sets the fast path free!
Non-enumerable props? We bail with grace,
Identity preserved all over the place.
Object.keys wins this little race! 🗝️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main change: a performance optimization for enumerable-key collection in the structural sharing mechanism of router-core.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/router-core/src/utils.ts`:
- Line 308: The if statement checking keys.length against
Object.getOwnPropertyNames(o).length on line 308 uses a one-line body without
curly braces, which violates the repository's style guidelines requiring braces
for all control statements. Add curly braces around the return false statement
to wrap the body. Apply the same fix to the similar if statements also mentioned
on lines 314 and 319.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c38a969f-acca-43e4-97f3-10cdc0a50a17

📥 Commits

Reviewing files that changed from the base of the PR and between 279a849 and d18d300.

📒 Files selected for processing (3)
  • .changeset/perf-structural-sharing-enumerable-keys.md
  • packages/router-core/src/utils.ts
  • packages/router-core/tests/utils.test.ts

// "clone-friendly" -> bail. This replaces an O(n) loop of
// `propertyIsEnumerable` calls with two native calls.
const keys = Object.keys(o)
if (keys.length !== Object.getOwnPropertyNames(o).length) return false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use block bodies for all changed if statements.

These one-line if bodies violate the repository rule requiring braces on control statements.

Suggested patch
-  if (keys.length !== Object.getOwnPropertyNames(o).length) return false
+  if (keys.length !== Object.getOwnPropertyNames(o).length) {
+    return false
+  }
@@
-  if (symbols.length === 0) return keys
+  if (symbols.length === 0) {
+    return keys
+  }
@@
-    if (!isEnumerable.call(o, symbol)) return false
+    if (!isEnumerable.call(o, symbol)) {
+      return false
+    }

As per coding guidelines, "**/*.{ts,tsx,js,jsx}: Always use curly braces for if, else, loops, and similar control statements."

Also applies to: 314-314, 319-319

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router-core/src/utils.ts` at line 308, The if statement checking
keys.length against Object.getOwnPropertyNames(o).length on line 308 uses a
one-line body without curly braces, which violates the repository's style
guidelines requiring braces for all control statements. Add curly braces around
the return false statement to wrap the body. Apply the same fix to the similar
if statements also mentioned on lines 314 and 319.

Source: Coding guidelines

@nx-cloud

nx-cloud Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit d18d300

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ✅ Succeeded 12m 49s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 2m 14s View ↗

☁️ Nx Cloud last updated this comment at 2026-06-22 11:10:10 UTC

@pkg-pr-new

pkg-pr-new Bot commented Jun 22, 2026

Copy link
Copy Markdown
More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/@tanstack/arktype-adapter@7664

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/@tanstack/eslint-plugin-router@7664

@tanstack/eslint-plugin-start

npm i https://pkg.pr.new/@tanstack/eslint-plugin-start@7664

@tanstack/history

npm i https://pkg.pr.new/@tanstack/history@7664

@tanstack/nitro-v2-vite-plugin

npm i https://pkg.pr.new/@tanstack/nitro-v2-vite-plugin@7664

@tanstack/react-router

npm i https://pkg.pr.new/@tanstack/react-router@7664

@tanstack/react-router-devtools

npm i https://pkg.pr.new/@tanstack/react-router-devtools@7664

@tanstack/react-router-ssr-query

npm i https://pkg.pr.new/@tanstack/react-router-ssr-query@7664

@tanstack/react-start

npm i https://pkg.pr.new/@tanstack/react-start@7664

@tanstack/react-start-client

npm i https://pkg.pr.new/@tanstack/react-start-client@7664

@tanstack/react-start-rsc

npm i https://pkg.pr.new/@tanstack/react-start-rsc@7664

@tanstack/react-start-server

npm i https://pkg.pr.new/@tanstack/react-start-server@7664

@tanstack/router-cli

npm i https://pkg.pr.new/@tanstack/router-cli@7664

@tanstack/router-core

npm i https://pkg.pr.new/@tanstack/router-core@7664

@tanstack/router-devtools

npm i https://pkg.pr.new/@tanstack/router-devtools@7664

@tanstack/router-devtools-core

npm i https://pkg.pr.new/@tanstack/router-devtools-core@7664

@tanstack/router-generator

npm i https://pkg.pr.new/@tanstack/router-generator@7664

@tanstack/router-plugin

npm i https://pkg.pr.new/@tanstack/router-plugin@7664

@tanstack/router-ssr-query-core

npm i https://pkg.pr.new/@tanstack/router-ssr-query-core@7664

@tanstack/router-utils

npm i https://pkg.pr.new/@tanstack/router-utils@7664

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/@tanstack/router-vite-plugin@7664

@tanstack/solid-router

npm i https://pkg.pr.new/@tanstack/solid-router@7664

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/@tanstack/solid-router-devtools@7664

@tanstack/solid-router-ssr-query

npm i https://pkg.pr.new/@tanstack/solid-router-ssr-query@7664

@tanstack/solid-start

npm i https://pkg.pr.new/@tanstack/solid-start@7664

@tanstack/solid-start-client

npm i https://pkg.pr.new/@tanstack/solid-start-client@7664

@tanstack/solid-start-server

npm i https://pkg.pr.new/@tanstack/solid-start-server@7664

@tanstack/start-client-core

npm i https://pkg.pr.new/@tanstack/start-client-core@7664

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/@tanstack/start-fn-stubs@7664

@tanstack/start-plugin-core

npm i https://pkg.pr.new/@tanstack/start-plugin-core@7664

@tanstack/start-server-core

npm i https://pkg.pr.new/@tanstack/start-server-core@7664

@tanstack/start-static-server-functions

npm i https://pkg.pr.new/@tanstack/start-static-server-functions@7664

@tanstack/start-storage-context

npm i https://pkg.pr.new/@tanstack/start-storage-context@7664

@tanstack/valibot-adapter

npm i https://pkg.pr.new/@tanstack/valibot-adapter@7664

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/@tanstack/virtual-file-routes@7664

@tanstack/vue-router

npm i https://pkg.pr.new/@tanstack/vue-router@7664

@tanstack/vue-router-devtools

npm i https://pkg.pr.new/@tanstack/vue-router-devtools@7664

@tanstack/vue-router-ssr-query

npm i https://pkg.pr.new/@tanstack/vue-router-ssr-query@7664

@tanstack/vue-start

npm i https://pkg.pr.new/@tanstack/vue-start@7664

@tanstack/vue-start-client

npm i https://pkg.pr.new/@tanstack/vue-start-client@7664

@tanstack/vue-start-server

npm i https://pkg.pr.new/@tanstack/vue-start-server@7664

@tanstack/zod-adapter

npm i https://pkg.pr.new/@tanstack/zod-adapter@7664

commit: d18d300

@codspeed-hq

codspeed-hq Bot commented Jun 22, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 11.21%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 2 improved benchmarks
❌ 5 regressed benchmarks
✅ 137 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Memory mem aborted-requests (solid) 1.8 MB 2.7 MB -35.01%
Memory mem serialization-payload (solid) 6.9 MB 8.9 MB -22.2%
Memory mem peak-large-page (solid) 3.4 MB 3.8 MB -11.32%
Memory mem navigation-churn (solid) 1.4 MB 1.5 MB -5.76%
Memory mem aborted-requests (vue) 959.1 KB 989.5 KB -3.07%
Memory mem streaming-peak chunked (vue) 12.2 MB 11.9 MB +3.1%
Memory mem request-churn (solid) 1.2 MB 1.1 MB +3.04%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing anonrig:perf/structural-sharing-enumerable-keys (d18d300) with main (f23ed0f)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (279a849) during the generation of this report, so f23ed0f was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@anonrig anonrig closed this Jun 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant