Skip to content

fix(react-router): treat Link params and search as immutable inputs - #8380

Merged
schiller-manuel merged 1 commit into
optimize-link-location-reusefrom
schiller-manuel-link-nested-value-stability
Sep 13, 2026
Merged

schiller-manuel merged 1 commit into
optimize-link-location-reusefrom
schiller-manuel-link-nested-value-stability

Conversation

@schiller-manuel

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

Copy link
Copy Markdown
Collaborator

Stacked on #8370.

useValueStable kept a shallow copy of params / search / activeOptions so that an object mutated in place would yield a new reference on the next render. As CodeRabbit noted on #8370, that only ever held one level deep: search.filters.page = 2 leaves the shared nested reference in the copy, deepEqual short-circuits on it, dest keeps its identity and buildLocation serves the cached location.

Detecting mutation is not a contract React lets us keep — a mutation on its own never renders anything; the tests exercising it needed a manual re-render button. Rather than snapshot deeper (measured +63 gzip on react-router.minimal, and a 3–5× slower per-render compare whenever nested references are shared, since nothing can short-circuit), useValueStable stores the caller's reference again, exactly as on main. The reference is the router's invalidation signal: pass a new object to change a destination, like any other React prop.

Changes

  • link.tsx: useValueStable stores the reference; comment states the contract.
  • link-destination.test.tsx: removes the accessor / in-place mutation tests and their Rerender fixture hack. Adds one regression test for what can actually break: fresh inline literals with equal nested contents must not trigger another buildLocation (spy), and a nested value changed through React state must produce a new href on that render and keep it across navigations. Verified the test fails when useValueStable is neutered.
  • .changeset/still-links-rest.md: replaces the "mutated in place is picked up on the next render" promise with the immutable-inputs contract.

Measurements

useValueStable variant react-router.minimal gzip same-reference compare (medium search object)
store reference (this PR, = main) 86,026 (−12) 0 ns
shallow copy (#8370) 86,038 55 ns
deep snapshot (CodeRabbit's suggestion) 86,101 (+63) 225 ns

Solid and Vue are unaffected: their Links build a fresh options object per read and never hit the buildLocation cache.

Verification

  • @tanstack/react-router test:unit (Link suites, 181 tests), test:types, test:eslint
  • pnpm benchmark:bundle-size:run --scenario react-router.minimal: 86038 → 86026

Summary by CodeRabbit

  • Bug Fixes

    • Link destinations now reliably reuse previously built locations when params, search, and activeOptions contain unchanged values.
    • Link destinations rebuild when nested values change through React state.
    • In-place mutations to existing objects are no longer re-read automatically, consistent with standard React prop behavior.
  • Documentation

    • Clarified how Link destination values are compared and reused.

@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: f0dc0934-6e70-46cf-aba8-198d6a938cb0

📥 Commits

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

📒 Files selected for processing (3)
  • .changeset/still-links-rest.md
  • packages/react-router/src/link.tsx
  • packages/react-router/tests/link-destination.test.tsx

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


📝 Walkthrough

Walkthrough

The change updates Link destination stability to preserve references for deeply equal values. Tests now verify that equal inline literals reuse locations and nested value changes rebuild them.

Changes

Link destination stability

Layer / File(s) Summary
Stable value reference handling
packages/react-router/src/link.tsx, .changeset/still-links-rest.md
useValueStable now keeps the original reference for deeply equal values. The changeset documents reuse for equal inputs and the behavior of in-place mutations.
Link destination reuse tests
packages/react-router/tests/link-destination.test.tsx
Tests remove the manual rerender control and verify that equal inline literals reuse the location while nested state changes rebuild it.

Priority: ⬇️ Low

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

Change: Bug fix

Merge Risk: ⚪ Minimal · up to 98a9b

Link destination reuse now consistently treats destination inputs as immutable, with coverage for equal literals and nested state-driven updates. No merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 3 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 identifies the main change: React Router Link inputs are treated as immutable. It is concise and directly related to the changeset.
Description check ✅ Passed The description clearly explains the motivation, implementation, tests, measurements, and verification. It does not include the template's Checklist or Release Impact headings, but it provides equival…
Full details: Docstring Coverage

Explanation

Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 3 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-link-nested-value-stability

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.

@nx-cloud

nx-cloud Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit 98a9b40

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ✅ Succeeded 14m 49s View ↗

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

@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@8380

@tanstack/eslint-plugin-router

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

@tanstack/eslint-plugin-start

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

@tanstack/history

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

@tanstack/nitro-v2-vite-plugin

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

@tanstack/react-router

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

@tanstack/react-router-devtools

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

@tanstack/react-router-ssr-query

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

@tanstack/react-start

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

@tanstack/react-start-client

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

@tanstack/react-start-rsc

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

@tanstack/react-start-server

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

@tanstack/router-cli

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

@tanstack/router-core

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

@tanstack/router-devtools

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

@tanstack/router-devtools-core

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

@tanstack/router-generator

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

@tanstack/router-plugin

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

@tanstack/router-ssr-query-core

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

@tanstack/router-utils

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

@tanstack/router-vite-plugin

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

@tanstack/solid-router

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

@tanstack/solid-router-devtools

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

@tanstack/solid-router-ssr-query

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

@tanstack/solid-start

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

@tanstack/solid-start-client

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

@tanstack/solid-start-server

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

@tanstack/start-client-core

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

@tanstack/start-fn-stubs

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

@tanstack/start-plugin-core

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

@tanstack/start-server-core

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

@tanstack/start-static-server-functions

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

@tanstack/start-storage-context

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

@tanstack/valibot-adapter

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

@tanstack/virtual-file-routes

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

@tanstack/vue-router

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

@tanstack/vue-router-devtools

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

@tanstack/vue-router-ssr-query

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

@tanstack/vue-start

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

@tanstack/vue-start-client

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

@tanstack/vue-start-server

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

@tanstack/zod-adapter

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

commit: 98a9b40

@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

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Benchmarks

  • Commit: 705cf0ea471a
  • Measured at: 2026-09-12T22:23:35.359Z
  • 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
+34 B
83.9 KiB
+30 B
261.7 KiB
-669 B
73.3 KiB
+56 B
█████▁▁▁▁▁▁▃
react-router.full 87.5 KiB
+50 B
87.4 KiB
+52 B
273.4 KiB
-677 B
76.3 KiB
+112 B
█████▂▂▂▂▂▁▄
solid-router.minimal 33.4 KiB
-12 B
33.3 KiB
-10 B
96.0 KiB
-805 B
30.2 KiB
-30 B
▄█████████▆▁
solid-router.full 38.3 KiB
+20 B
38.2 KiB
+18 B
110.6 KiB
-805 B
34.5 KiB
+80 B
▁▃▃▃▃▃▃▃▃▃▁█
vue-router.minimal 49.5 KiB
-72 B
49.4 KiB
-70 B
137.1 KiB
-1.2 KiB
44.8 KiB
-70 B
█████▃▃▃▃▃▂▁
vue-router.full 55.1 KiB
-70 B
55.0 KiB
-69 B
155.3 KiB
-1.2 KiB
49.7 KiB
-91 B
█████▂▂▂▂▂▂▁
react-start.minimal 97.0 KiB
+75 B
96.8 KiB
+70 B
303.9 KiB
-663 B
84.0 KiB
-12 B
█████▁▁▁▁▁▁▅
react-start.query-integration 104.3 KiB
+64 B
104.2 KiB
+62 B
330.4 KiB
-671 B
90.4 KiB
+22 B
█████▁▁▁▁▁▁▅
react-start.deferred-hydration 97.7 KiB
+66 B
96.8 KiB
+67 B
305.3 KiB
-663 B
84.8 KiB
+57 B
█████▁▁▁▁▁▁▅
react-start.full 100.1 KiB
+66 B
100.0 KiB
+66 B
313.6 KiB
-682 B
86.8 KiB
+110 B
█████▁▁▁▁▁▁▄
react-start.rsbuild.minimal 100.2 KiB
+76 B
100.0 KiB
+76 B
314.3 KiB
-591 B
86.6 KiB
+76 B
█████▁▁▁▁▁▁▅
react-start.rsbuild.minimal-iife 100.6 KiB
+83 B
100.4 KiB
+83 B
315.2 KiB
-574 B
86.9 KiB
+92 B
█████▁▁▁▁▁▁▅
react-start.rsbuild.full 103.5 KiB
+103 B
103.3 KiB
+103 B
324.4 KiB
-587 B
89.2 KiB
+75 B
█████▁▁▁▁▁▁▆
solid-start.minimal 46.4 KiB
+54 B
46.3 KiB
+52 B
137.1 KiB
-805 B
41.3 KiB
+121 B
▂▂▂▂▂▂▂▂▂▂▁█
solid-start.deferred-hydration 49.4 KiB
+34 B
46.3 KiB
+33 B
144.5 KiB
-804 B
44.0 KiB
+9 B
▁▂▂▂▂▂▂▂▂▂▃█
solid-start.full 51.4 KiB
+48 B
51.3 KiB
+46 B
152.5 KiB
-801 B
45.5 KiB
+5 B
▂▂▂▂▂▂▂▂▂▂▁█
vue-start.minimal 65.7 KiB
-78 B
65.5 KiB
-75 B
188.0 KiB
-1.2 KiB
58.5 KiB
-5 B
█████▃▃▃▃▃▂▁
vue-start.full 69.5 KiB
-43 B
69.4 KiB
-40 B
200.3 KiB
-1.2 KiB
61.8 KiB
+54 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.

@codspeed

codspeed Bot commented Sep 11, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 180 untouched benchmarks


Comparing schiller-manuel-link-nested-value-stability (98a9b40) with optimize-link-location-reuse (d5d73c7)

Open in CodSpeed

`useValueStable` kept a shallow copy of `params` / `search` /
`activeOptions` (dac2bf2) so that an object mutated in place would
yield a new reference on the next render. That only ever held one level
deep: a nested change such as `search.filters.page = 2` left the shared
nested reference in the copy, `deepEqual` short-circuited on it, `dest`
kept its identity and `buildLocation` served the cached location.

Detecting mutation is not a contract React lets us keep. A mutation on its
own never renders anything; the tests that exercised it needed a manual
re-render button to observe the change, which is the same situation as a
mutated `React.memo` prop or `useMemo` dependency. Rather than snapshot
deeper (measured +63 gzip bytes on react-router.minimal and a 3-5x slower
compare whenever nested references are shared, since nothing can
short-circuit), `useValueStable` stores the caller's reference again, as
on main. The reference is the router's invalidation signal: pass a new
object to change a destination.

The regression test now pins the contract that can actually break: fresh
inline literals with equal nested contents must not trigger another
`buildLocation`, and a nested value that changes through React state must
produce a new href on that render and keep it across navigations. The
accessor and in-place mutation tests are removed along with the promise
in the changeset.

Bundle (react-router.minimal gzip): 86038 -> 86026 (-12).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@schiller-manuel
schiller-manuel force-pushed the schiller-manuel-link-nested-value-stability branch from 6163e6f to 98a9b40 Compare September 12, 2026 21:35
@schiller-manuel
schiller-manuel merged commit 465c85a into main Sep 13, 2026
41 of 47 checks passed
@schiller-manuel
schiller-manuel deleted the schiller-manuel-link-nested-value-stability branch September 13, 2026 11:55
Sheraff pushed a commit that referenced this pull request Sep 14, 2026
…8380)

`useValueStable` kept a shallow copy of `params` / `search` /
`activeOptions` (dac2bf2) so that an object mutated in place would
yield a new reference on the next render. That only ever held one level
deep: a nested change such as `search.filters.page = 2` left the shared
nested reference in the copy, `deepEqual` short-circuited on it, `dest`
kept its identity and `buildLocation` served the cached location.

Detecting mutation is not a contract React lets us keep. A mutation on its
own never renders anything; the tests that exercised it needed a manual
re-render button to observe the change, which is the same situation as a
mutated `React.memo` prop or `useMemo` dependency. Rather than snapshot
deeper (measured +63 gzip bytes on react-router.minimal and a 3-5x slower
compare whenever nested references are shared, since nothing can
short-circuit), `useValueStable` stores the caller's reference again, as
on main. The reference is the router's invalidation signal: pass a new
object to change a destination.

The regression test now pins the contract that can actually break: fresh
inline literals with equal nested contents must not trigger another
`buildLocation`, and a nested value that changes through React state must
produce a new href on that render and keep it across navigations. The
accessor and in-place mutation tests are removed along with the promise
in the changeset.

Bundle (react-router.minimal gzip): 86038 -> 86026 (-12).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
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.

1 participant