Skip to content

perf(router-core): stop structurally sharing search and state in buildLocation - #8382

Open
schiller-manuel wants to merge 1 commit into
schiller-manuel-link-nested-value-stabilityfrom
schiller-manuel-remove-build-location-sharing
Open

perf(router-core): stop structurally sharing search and state in buildLocation#8382
schiller-manuel wants to merge 1 commit into
schiller-manuel-link-nested-value-stabilityfrom
schiller-manuel-remove-build-location-sharing

Conversation

@schiller-manuel

@schiller-manuel schiller-manuel commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #8380.

🎯 Changes

buildLocation ran nullReplaceEqualDeep over the built search and replaceEqualDeep over the built state against the current location on every build. That work only affected object identity, and nothing reads that identity from a built location:

  • Links select href and isActive from the built location; active state is computed by value and ignores history state.
  • commitLocation already compares state by value (deepEqual over _getUserHistoryState), so pre-shared children were at most a shortcut there.
  • The identity consumers actually rely on (selecting location.state.user or a nested search value without rerendering) comes from parseLocation, which stabilizes the committed location against the previous one. That sharing is untouched, as are the matchRoutes sites for search, params and loaderDeps. Both helpers stay imported and used.

On the server replaceEqualDeep was already a passthrough, so client and server builds now produce the same shapes.

Removed lines (packages/router-core/src/router.ts, inside buildLocation's build)

// Structural sharing only affects identity, so it does not make the
// location depend on the current one.
nextSearch = nullReplaceEqualDeep(lightweight[2 /* search */], nextSearch)
// Identity-only, as above.
nextState = replaceEqualDeep(currentLocation.state, nextState)

nextSearch became a const; nextState is now a single const conditional (EMPTY_RECORD without dest.state, current().state for true, dest.state(current().state) for a function, otherwise dest.state). The usedCurrent tracking is unchanged: only reads through current() / currentMatch() mark a build as location-dependent, so literal search/state keep hitting the per-options cache from #8370. lightweight[2] is still returned for fromSearch().

Behavior changes

  1. No build-time identity sharing. A literal search or state is returned as the caller's object; buildLocation(...).search.nested is no longer reference-equal to the current location's nested value. After the navigation commits, router.state.location.search.nested / .state.nested do keep the previous reference (via parseLocation).
  2. Key order. Because replaceEqualDeep returned the current search when contents were equal, buildLocation used to re-serialize the current key order. At ?a=1&b=2, buildLocation({ search: { b: 2, a: 1 } }) produced ?a=1&b=2, so navigating there was a same-location no-op. It now produces ?b=2&a=1 and pushes a new history entry.
  3. No caller mutation. The caller's state object flows straight to commitLocation; that path already copies before adding __hashScrollIntoViewOptions, and @tanstack/history copies before adding __TSR_key/key/index. Pinned by tests (plain and frozen state objects).
  4. A destination without search yields the frozen EMPTY_RECORD rather than a fresh or shared object. Nothing downstream mutates the built search (all consumers copy); the full unit suites pass.

Tests

  • Removed explicit state structurally shares unchanged nested values (asserted build-time sharing).
  • Added describe('buildLocation - no structural sharing with the current location'):
    • built state is the caller's object; after navigate, location.state.user keeps the previous reference
    • same for search with a nested filter subtree
    • navigate does not mutate a caller-supplied state (keys unchanged; a frozen state commits without throwing)
    • key order at the buildLocation level (searchStr/href follow the requested order)
    • key order at the navigation level (same order → history.length unchanged; different order → new entry with ?b=2&a=1)
  • 4 of the 5 new tests fail against the previous implementation; the mutation test is a regression guard.
  • No other test in the repo asserted build-time sharing or depended on the old key-order behavior (utils.test.ts line 951 tests replaceEqualDeep itself and is untouched).

Measurements

pnpm benchmark:bundle-size:run --scenario react-router.minimal: gzip 86026 → 86004 (−22), initial −23, raw −41, brotli +65.

Verification

  • @tanstack/router-core:test:unit 133 files / 3303 passed
  • @tanstack/react-router:test:unit 91 files / 1174 passed
  • @tanstack/solid-router:test:unit 68 + 7 files / 942 + 51 passed
  • @tanstack/vue-router:test:unit 71 files / 968 passed
  • @tanstack/router-core:test:types, @tanstack/router-core:test:eslint (0 errors), git diff --check, prettier

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested code changes locally with the relevant test commands, or tests do not apply to this pull request.
  • I fully understand the code in this pull request, including any code generated with AI assistance.

🚀 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

    • buildLocation no longer shares search and state objects with the current location before navigation is committed.
    • Navigation and location-building operations no longer mutate caller-provided state, including frozen objects.
    • Search key order is preserved, and equal searches with different key orders create distinct history entries.
  • Tests

    • Added coverage for state isolation, nested reference stabilization after navigation, search ordering, and caller-state immutability.

@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 75ed80ae-516e-42b2-a1c5-05895c55c29a

📥 Commits

Reviewing files that changed from the base of the PR and between 6163e6f and f5c4a4d.

📒 Files selected for processing (3)
  • .changeset/calm-locations-unshared.md
  • packages/router-core/src/router.ts
  • packages/router-core/tests/build-location.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

buildLocation now returns caller-supplied search and state without structural sharing. parseLocation stabilizes references when navigation commits the location. Tests cover immutability, nested reference stabilization, search key ordering, and history entry creation.

Changes

Location building and commit behavior

Layer / File(s) Summary
Build location reference handling
packages/router-core/src/router.ts
buildLocation returns search and state values without comparing them with the current location. Reference stabilization is deferred to parseLocation.
Build location behavior tests
packages/router-core/tests/build-location.test.ts
Tests verify caller-owned search and state objects remain unchanged during building, while nested references stabilize after navigation.
Navigation serialization and history tests
packages/router-core/tests/build-location.test.ts, .changeset/calm-locations-unshared.md
Tests cover frozen state objects, committed bookkeeping, search key ordering, and history entry creation. The changeset documents the updated behavior.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~15 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant RouterCore.buildLocation
  participant parseLocation
  participant History
  Caller->>RouterCore.buildLocation: provide search and state
  RouterCore.buildLocation-->>Caller: return caller-owned values
  Caller->>parseLocation: commit navigation
  parseLocation->>History: serialize search and update history
  parseLocation-->>Caller: return stabilized location
Loading

Suggested reviewers: sheraff

Merge Risk: ⚪ Minimal · up to e14d9

The updated location-building behavior is covered across build, commit, and history paths; no actionable merge risk remains.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ 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%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 2 files. (1 skipped: 1 … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely describes the primary change: removing structural sharing of search and state in RouterCore.buildLocation.
Description check ✅ Passed The description is complete and directly addresses the change, motivation, behavior impact, tests, measurements, verification, checklist, and release impact. It follows the required template and inclu…
Full details: Docstring Coverage

Explanation

Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 2 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch schiller-manuel-remove-build-location-sharing

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.

@schiller-manuel
schiller-manuel added this pull request to stack #8346 September 11, 2026 23:05
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Changeset Version Preview

7 package(s) bumped directly, 22 bumped as dependents.

🟩 Patch bumps

Package Version Reason
@tanstack/history 1.162.3 → 1.162.4 Changeset
@tanstack/react-router 1.170.35 → 1.170.36 Changeset
@tanstack/router-core 1.171.29 → 1.171.30 Changeset
@tanstack/router-devtools-core 1.168.1 → 1.168.2 Changeset
@tanstack/solid-router 1.170.33 → 1.170.34 Changeset
@tanstack/start-server-core 1.169.34 → 1.169.35 Changeset
@tanstack/vue-router 1.170.32 → 1.170.33 Changeset
@tanstack/react-router-devtools 1.167.1 → 1.167.2 Dependent
@tanstack/react-start 1.168.52 → 1.168.53 Dependent
@tanstack/react-start-client 1.168.33 → 1.168.34 Dependent
@tanstack/react-start-rsc 0.1.51 → 0.1.52 Dependent
@tanstack/react-start-server 1.167.40 → 1.167.41 Dependent
@tanstack/router-cli 1.167.35 → 1.167.36 Dependent
@tanstack/router-devtools 1.167.1 → 1.167.2 Dependent
@tanstack/router-generator 1.167.35 → 1.167.36 Dependent
@tanstack/router-plugin 1.168.37 → 1.168.38 Dependent
@tanstack/router-vite-plugin 1.167.37 → 1.167.38 Dependent
@tanstack/solid-router-devtools 1.167.1 → 1.167.2 Dependent
@tanstack/solid-start 1.168.50 → 1.168.51 Dependent
@tanstack/solid-start-client 1.168.32 → 1.168.33 Dependent
@tanstack/solid-start-server 1.167.39 → 1.167.40 Dependent
@tanstack/start-client-core 1.170.29 → 1.170.30 Dependent
@tanstack/start-plugin-core 1.171.42 → 1.171.43 Dependent
@tanstack/start-static-server-functions 1.167.34 → 1.167.35 Dependent
@tanstack/start-storage-context 1.167.31 → 1.167.32 Dependent
@tanstack/vue-router-devtools 1.167.1 → 1.167.2 Dependent
@tanstack/vue-start 1.168.49 → 1.168.50 Dependent
@tanstack/vue-start-client 1.167.35 → 1.167.36 Dependent
@tanstack/vue-start-server 1.167.39 → 1.167.40 Dependent

@nx-cloud

nx-cloud Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit e14d907

Command Status Duration Result
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 2m 13s View ↗

☁️ Nx Cloud last updated this comment at 2026-09-12 22:50:54 UTC

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Benchmarks

  • Commit: df2363724f51
  • Measured at: 2026-09-12T22:52:23.897Z
  • Baseline source: history:ae6853592904
  • Dashboard: bundle-size history

The following scenarios have bundle-size changes compared with the baseline:

Scenario Current (gzip) Initial (gzip) Raw Brotli Trend
react-router.minimal 84.0 KiB
+9 B
83.8 KiB
+7 B
261.6 KiB
-710 B
73.2 KiB
-29 B
█████▁▁▁▁▁▁▂
react-router.full 87.5 KiB
+30 B
87.4 KiB
+29 B
273.3 KiB
-718 B
76.3 KiB
+114 B
█████▂▂▂▂▂▁▃
solid-router.minimal 33.4 KiB
-29 B
33.3 KiB
-30 B
95.9 KiB
-847 B
30.2 KiB
-17 B
▆█████████▇▁
solid-router.full 38.3 KiB
-11 B
38.1 KiB
-12 B
110.6 KiB
-846 B
34.5 KiB
+96 B
▆█████████▆▁
vue-router.minimal 49.5 KiB
-90 B
49.4 KiB
-91 B
137.1 KiB
-1.3 KiB
44.7 KiB
-132 B
█████▃▃▃▃▃▃▁
vue-router.full 55.1 KiB
-91 B
55.0 KiB
-91 B
155.3 KiB
-1.3 KiB
49.7 KiB
-103 B
█████▃▃▃▃▃▃▁
react-start.minimal 96.9 KiB
+50 B
96.8 KiB
+47 B
303.9 KiB
-705 B
84.1 KiB
+79 B
█████▁▁▁▁▁▁▄
react-start.query-integration 104.3 KiB
+40 B
104.1 KiB
+39 B
330.4 KiB
-713 B
90.5 KiB
+105 B
█████▁▁▁▁▁▁▃
react-start.deferred-hydration 97.6 KiB
+41 B
96.8 KiB
+44 B
305.3 KiB
-705 B
84.7 KiB
-11 B
█████▁▁▁▁▁▁▃
react-start.full 100.1 KiB
+36 B
99.9 KiB
+38 B
313.6 KiB
-724 B
86.8 KiB
+97 B
█████▁▁▁▁▁▁▃
react-start.rsbuild.minimal 100.2 KiB
+50 B
100.0 KiB
+50 B
314.2 KiB
-633 B
86.4 KiB
-41 B
█████▁▁▁▁▁▁▄
react-start.rsbuild.minimal-iife 100.6 KiB
+58 B
100.4 KiB
+58 B
315.2 KiB
-616 B
86.8 KiB
+23 B
█████▁▁▁▁▁▁▄
react-start.rsbuild.full 103.5 KiB
+76 B
103.3 KiB
+76 B
324.3 KiB
-629 B
89.3 KiB
+91 B
█████▁▁▁▁▁▁▅
solid-start.minimal 46.4 KiB
+30 B
46.2 KiB
+28 B
137.1 KiB
-847 B
41.2 KiB
+32 B
▄▂▂▂▂▂▂▂▂▂▁█
solid-start.deferred-hydration 49.4 KiB
+13 B
46.3 KiB
+11 B
144.4 KiB
-845 B
44.1 KiB
+97 B
▁▃▃▃▃▃▃▃▃▃▄█
solid-start.full 51.4 KiB
+27 B
51.3 KiB
+25 B
152.4 KiB
-845 B
45.6 KiB
+62 B
▃▂▂▂▂▂▂▂▂▂▁█
vue-start.minimal 65.6 KiB
-99 B
65.5 KiB
-98 B
187.9 KiB
-1.3 KiB
58.4 KiB
-42 B
█████▃▃▃▃▃▃▁
vue-start.full 69.5 KiB
-65 B
69.4 KiB
-63 B
200.2 KiB
-1.3 KiB
61.8 KiB
-21 B
█████▂▂▂▂▂▂▁

Current gzip tracks all emitted client JS chunks. Initial gzip tracks only the entry/import graph. Trend sparkline is historical current gzip ending with this PR measurement; lower is better.

@pkg-pr-new

pkg-pr-new Bot commented Sep 11, 2026

Copy link
Copy Markdown
More templates

@tanstack/arktype-adapter

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

@tanstack/eslint-plugin-router

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

@tanstack/eslint-plugin-start

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

@tanstack/history

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

@tanstack/nitro-v2-vite-plugin

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

@tanstack/react-router

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

@tanstack/react-router-devtools

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

@tanstack/react-router-ssr-query

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

@tanstack/react-start

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

@tanstack/react-start-client

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

@tanstack/react-start-rsc

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

@tanstack/react-start-server

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

@tanstack/router-cli

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

@tanstack/router-core

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

@tanstack/router-devtools

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

@tanstack/router-devtools-core

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

@tanstack/router-generator

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

@tanstack/router-plugin

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

@tanstack/router-ssr-query-core

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

@tanstack/router-utils

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

@tanstack/router-vite-plugin

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

@tanstack/solid-router

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

@tanstack/solid-router-devtools

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

@tanstack/solid-router-ssr-query

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

@tanstack/solid-start

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

@tanstack/solid-start-client

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

@tanstack/solid-start-server

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

@tanstack/start-client-core

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

@tanstack/start-fn-stubs

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

@tanstack/start-plugin-core

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

@tanstack/start-server-core

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

@tanstack/start-static-server-functions

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

@tanstack/start-storage-context

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

@tanstack/valibot-adapter

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

@tanstack/virtual-file-routes

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

@tanstack/vue-router

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

@tanstack/vue-router-devtools

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

@tanstack/vue-router-ssr-query

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

@tanstack/vue-start

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

@tanstack/vue-start-client

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

@tanstack/vue-start-server

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

@tanstack/zod-adapter

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

commit: e14d907

@codspeed-hq

codspeed-hq Bot commented Sep 12, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 3.88%

⚠️ 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

⚡ 3 improved benchmarks
✅ 177 untouched benchmarks

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation client-links navigation loop (vue) 321.4 ms 304.9 ms +5.39%
Simulation client-search-params navigation loop (vue) 152.2 ms 147.5 ms +3.17%
Simulation client-links navigation loop (solid) 497.4 ms 482.4 ms +3.11%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing schiller-manuel-remove-build-location-sharing (e14d907) with schiller-manuel-link-nested-value-stability (98a9b40)

Open in CodSpeed

@TanStack TanStack deleted a comment from chatgpt-codex-connector Bot Sep 12, 2026
…dLocation

`buildLocation` ran `nullReplaceEqualDeep` over the built search and
`replaceEqualDeep` over the built state against the current location on
every build. That work only affected object identity, and nothing reads
that identity from a built location:

- Links select href and isActive from the built location; active state is
  computed by value and ignores history state.
- `commitLocation` already compares state by value (`deepEqual` over
  `_getUserHistoryState`), so pre-shared children were at most a shortcut.
- The identity consumers actually rely on (selecting `location.state.user`
  or a nested search value without rerendering) comes from `parseLocation`,
  which stabilizes the committed location against the previous one. That
  sharing stays untouched, as do the `matchRoutes` sites for search, params
  and loaderDeps.

On the server `replaceEqualDeep` was already a passthrough, so client and
server builds now produce the same shapes.

Behavior changes:

- A literal `search` or `state` is returned as the caller's object. It is
  never written to: `commitLocation` and history both copy before adding
  `__hashScrollIntoViewOptions`, `__TSR_key`, `key` and the index.
- Because `replaceEqualDeep` returned the *current* search when contents
  were equal, `buildLocation` used to re-serialize the current key order.
  At `?a=1&b=2`, `buildLocation({ search: { b: 2, a: 1 } })` produced
  `?a=1&b=2`, so navigating there was a same-location no-op. It now
  produces `?b=2&a=1` and pushes a new history entry.
- A destination without `search` yields the frozen `EMPTY_RECORD`
  instead of a fresh or shared object. Nothing downstream mutates the
  built search; all consumers copy.

The `usedCurrent` tracking is unchanged: only reads through `current()` /
`currentMatch()` mark a build as location-dependent, so literal search and
state keep hitting the per-options cache.

Tests: the build-time sharing assertion (`explicit state structurally
shares unchanged nested values`) is replaced by a describe block that pins
the new contract: built search/state are the caller's objects, equal nested
references are preserved after navigation via parseLocation, `navigate`
does not mutate a plain or frozen caller state, and the key-order case is
covered at both the buildLocation and the navigation level.

Measurements (react-router.minimal, this tree):
gzip 86026 -> 86004 (-22), initial -23, raw -41, brotli +65.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@schiller-manuel
schiller-manuel force-pushed the schiller-manuel-remove-build-location-sharing branch from f5c4a4d to e14d907 Compare September 12, 2026 21:35
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