Skip to content

perf(router-core): stop re-validating search params for buildLocation - #8231

Draft
Sheraff wants to merge 1 commit into
mainfrom
perf/single-search-validation-lean
Draft

perf(router-core): stop re-validating search params for buildLocation#8231
Sheraff wants to merge 1 commit into
mainfrom
perf/single-search-validation-lean

Conversation

@Sheraff

@Sheraff Sheraff commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

On a navigation, each matched route's validateSearch ran three times from three separate sites: matchRoutesLightweight (called from buildLocation to compute fromSearch), the validate middleware in applySearchMiddleware (builds the committed URL), and matchRoutesInternal. With real schemas (zod, valibot) this is the dominant per-navigation cost for apps that use search params; the benchmark scenarios do not show it because their validators are trivial normalizers.

This removes the first pass and trims the allocations of the third:

  • Reuse the matched search. matchRoutesInternal stamps the accumulated, validated search of the leaf match onto the location object it resolved (location._search, same precedent as _redirects). matchRoutesLightweight reads it back before falling back to the validator loop. The value was computed for that exact object, so the reuse is correct by construction; the loop still runs for a location that was never matched (a freshly built destination, an explicit _fromLocation). Because load matches the same object it publishes to stores.location, and buildLocation defaults to latestLocation, every <Link> build and every navigate() after a load hits, whether or not path or search changed.
  • One accumulated search object per depth instead of three. The object handed to the validator becomes the match's search; the strict result is assigned into it. Routes without validateSearch share the parent's object.

The middleware pass is left alone: it validates inner-to-outer over descendant-accumulated search while matching accumulates outer-to-inner, so its input genuinely differs.

Measurements

validateSearch calls per navigation on a 3-route branch: 9 → 6. Reload, invalidate and HMR still re-run every validator exactly as before.

zod v4 schemas, jsdom, 3000 navigations, µs per navigation (two runs each): pathname-only 73.2/76.7/77.3 → 67.9/68.0; sibling + search change 66.2/66.6/68.8 → ~62; same route + search change 67.4/67.6/71.6 → ~63.

Bundle gzip vs the pre-change build: react-router.minimal +20 B, react-router.full +20 B (the flattening alone is +0; the reuse is the +20).

Semantics to review

  • A validator that mutates its argument now sees the mutation persist into match.search (previously the validator received a discarded copy).
  • Routes without validateSearch share the parent's search object instead of receiving a structurally identical copy.
  • Locations gain a non-enumerable-looking but ordinary _search property; nothing serializes or spreads ParsedLocation objects into other locations (checked the SSR path and devtools).

Tests

tests/search-validation-reuse.test.ts (10): exact per-route call-count pins for navigations (fail on main), accumulated vs strict search across sibling navigations, searchError after a clean navigation, throwOnError throwing twice in a row, retainSearchParams/stripSearchParams, validator-less routes, impure validator re-run on reload.

Suites on the rebased branch: router-core 109 files / 1623 passed / 4 expected fail; react-router 77 / 1037 / 1 skipped; solid-router 887; vue-router 871 (pre-rebase); tsc --noEmit, prettier clean, eslint unchanged from main.

🤖 Generated with Claude Code

https://claude.ai/code/session_01C1tX2n8xegVBsZqoJPu7iv

`buildLocation` asked `matchRoutesLightweight` for the current location's
accumulated search, which re-ran every matched route's `validateSearch`.
`matchRoutesInternal` already computes that exact value, so it now stamps it on
the location it resolved (`_search`) and `matchRoutesLightweight` reads it back.
The value is therefore correct by construction - it was computed for that very
location object - and the validator loop is only reached for a location that was
never matched, such as a freshly built destination. That removes one full
validation pass per navigation, three runs per matched route down to two.

In the same block, `matchRoutesInternal` now hands the validator the object that
becomes the match's search and fills it in place, so a branch allocates one
search object per depth instead of three.

Measured with zod v4 schemas on a 3-route branch (jsdom, 3000 navigations):
9 -> 6 `validateSearch` calls per navigation, ~73-77us -> ~64-68us.
Bundle: react-router.minimal 85807 -> 85827 gzip (+20),
react-router.full 89391 -> 89411 gzip (+20). The in-place fill is byte-neutral
on its own (+0/+1); the stamp-and-reuse accounts for the whole +20.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1tX2n8xegVBsZqoJPu7iv
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Benchmarks

  • Commit: cd4d01c2fee1
  • Measured at: 2026-09-03T13:52:33.676Z
  • Baseline source: history:edf0e16ebfe8
  • 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 83.8 KiB
+24 B
83.7 KiB
+22 B
262.2 KiB
+53 B
72.9 KiB
+37 B
▇██████▂▁▁▁▆
react-router.full 87.3 KiB
+18 B
87.2 KiB
+21 B
273.9 KiB
+53 B
76.0 KiB
+51 B
▇██████▁▁▁▂▇
solid-router.minimal 33.2 KiB
+24 B
33.0 KiB
+24 B
96.3 KiB
+53 B
29.9 KiB
-17 B
▄▅▅▅▅▅▅▂▁▁▁█
solid-router.full 38.0 KiB
+21 B
37.9 KiB
+21 B
110.9 KiB
+53 B
34.3 KiB
+49 B
▅▆▆▆▆▆▆▁▂▂▁█
vue-router.minimal 49.5 KiB
+23 B
49.4 KiB
+25 B
138.3 KiB
+53 B
44.7 KiB
+15 B
▃▅▅▅▅▅▅▂▁▁▁█
vue-router.full 55.1 KiB
+27 B
55.0 KiB
+25 B
156.5 KiB
+53 B
49.6 KiB
-10 B
▂▄▄▄▄▄▄▂▁▁▂█
react-start.minimal 96.7 KiB
+25 B
96.6 KiB
+25 B
304.5 KiB
+53 B
83.8 KiB
+95 B
▆██████▂▁▁▂▇
react-start.query-integration 104.1 KiB
+25 B
103.9 KiB
+24 B
331.0 KiB
+53 B
90.2 KiB
+24 B
▇██▁▁▁▁▇
react-start.deferred-hydration 97.4 KiB
+19 B
96.6 KiB
+24 B
305.8 KiB
+53 B
84.5 KiB
-19 B
▇██████▂▁▁▃▇
react-start.full 99.9 KiB
+24 B
99.7 KiB
+22 B
314.2 KiB
+53 B
86.6 KiB
+27 B
▇██████▂▁▁▂▇
react-start.rsbuild.minimal 100.0 KiB
+23 B
99.8 KiB
+23 B
314.8 KiB
+52 B
86.3 KiB
+8 B
▇██████▂▁▁▁▆
react-start.rsbuild.minimal-iife 100.4 KiB
+22 B
100.2 KiB
+22 B
315.7 KiB
+52 B
86.6 KiB
+45 B
▇██████▂▁▁▁▆
react-start.rsbuild.full 103.3 KiB
+26 B
103.2 KiB
+26 B
324.9 KiB
+52 B
89.0 KiB
+216 B
▇██████▃▃▁▁▇
solid-start.minimal 46.0 KiB
+23 B
45.9 KiB
+20 B
137.4 KiB
+53 B
40.9 KiB
-19 B
▅▅▅▅▅▅▅▁▁▁▂█
solid-start.deferred-hydration 49.1 KiB
+25 B
46.0 KiB
+22 B
144.8 KiB
+53 B
43.7 KiB
+45 B
▄▅▅▅▅▅▅▃▁▁▃█
solid-start.full 51.1 KiB
+20 B
51.0 KiB
+23 B
152.8 KiB
+53 B
45.3 KiB
-6 B
▄▄▄▄▄▄▄▂▁▁▂█
vue-start.minimal 65.6 KiB
+17 B
65.5 KiB
+17 B
189.2 KiB
+53 B
58.4 KiB
+47 B
▂▆▆▆▆▆▆▁▂▂▂█
vue-start.full 69.5 KiB
+18 B
69.4 KiB
+19 B
201.5 KiB
+53 B
61.7 KiB
+12 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.

@nx-cloud

nx-cloud Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit 63b3cfb

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

☁️ Nx Cloud last updated this comment at 2026-09-03 14:07:20 UTC

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🚀 Changeset Version Preview

5 package(s) bumped directly, 18 bumped as dependents.

🟩 Patch bumps

Package Version Reason
@tanstack/react-router 1.170.32 → 1.170.33 Changeset
@tanstack/router-core 1.171.27 → 1.171.28 Changeset
@tanstack/solid-router 1.170.30 → 1.170.31 Changeset
@tanstack/start-plugin-core 1.171.39 → 1.171.40 Changeset
@tanstack/vue-router 1.170.29 → 1.170.30 Changeset
@tanstack/react-start 1.168.49 → 1.168.50 Dependent
@tanstack/react-start-client 1.168.30 → 1.168.31 Dependent
@tanstack/react-start-rsc 0.1.48 → 0.1.49 Dependent
@tanstack/react-start-server 1.167.37 → 1.167.38 Dependent
@tanstack/router-cli 1.167.33 → 1.167.34 Dependent
@tanstack/router-generator 1.167.33 → 1.167.34 Dependent
@tanstack/router-plugin 1.168.35 → 1.168.36 Dependent
@tanstack/router-vite-plugin 1.167.35 → 1.167.36 Dependent
@tanstack/solid-start 1.168.47 → 1.168.48 Dependent
@tanstack/solid-start-client 1.168.29 → 1.168.30 Dependent
@tanstack/solid-start-server 1.167.36 → 1.167.37 Dependent
@tanstack/start-client-core 1.170.27 → 1.170.28 Dependent
@tanstack/start-server-core 1.169.31 → 1.169.32 Dependent
@tanstack/start-static-server-functions 1.167.32 → 1.167.33 Dependent
@tanstack/start-storage-context 1.167.29 → 1.167.30 Dependent
@tanstack/vue-start 1.168.46 → 1.168.47 Dependent
@tanstack/vue-start-client 1.167.32 → 1.167.33 Dependent
@tanstack/vue-start-server 1.167.36 → 1.167.37 Dependent

@pkg-pr-new

pkg-pr-new Bot commented Sep 3, 2026

Copy link
Copy Markdown
More templates

@tanstack/arktype-adapter

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

@tanstack/eslint-plugin-router

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

@tanstack/eslint-plugin-start

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

@tanstack/history

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

@tanstack/nitro-v2-vite-plugin

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

@tanstack/react-router

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

@tanstack/react-router-devtools

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

@tanstack/react-router-ssr-query

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

@tanstack/react-start

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

@tanstack/react-start-client

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

@tanstack/react-start-rsc

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

@tanstack/react-start-server

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

@tanstack/router-cli

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

@tanstack/router-core

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

@tanstack/router-devtools

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

@tanstack/router-devtools-core

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

@tanstack/router-generator

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

@tanstack/router-plugin

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

@tanstack/router-ssr-query-core

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

@tanstack/router-utils

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

@tanstack/router-vite-plugin

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

@tanstack/solid-router

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

@tanstack/solid-router-devtools

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

@tanstack/solid-router-ssr-query

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

@tanstack/solid-start

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

@tanstack/solid-start-client

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

@tanstack/solid-start-server

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

@tanstack/start-client-core

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

@tanstack/start-fn-stubs

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

@tanstack/start-plugin-core

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

@tanstack/start-server-core

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

@tanstack/start-static-server-functions

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

@tanstack/start-storage-context

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

@tanstack/valibot-adapter

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

@tanstack/virtual-file-routes

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

@tanstack/vue-router

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

@tanstack/vue-router-devtools

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

@tanstack/vue-router-ssr-query

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

@tanstack/vue-start

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

@tanstack/vue-start-client

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

@tanstack/vue-start-server

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

@tanstack/zod-adapter

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

commit: 63b3cfb

@codspeed-hq

codspeed-hq Bot commented Sep 3, 2026

Copy link
Copy Markdown

Merging this PR will regress 19 benchmarks

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

⚡ 22 improved benchmarks
❌ 19 regressed benchmarks
✅ 139 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Memory mem client unique-location-churn (solid) 257.5 KB 478.1 KB -46.16%
Simulation client-search-params navigation loop (vue) 125.4 ms 172.6 ms -27.35%
Simulation client-links navigation loop (vue) 196.2 ms 252.9 ms -22.42%
Simulation client-nested-params navigation loop (react) 136.9 ms 151 ms -9.35%
Memory mem server error-paths unmatched (vue) 577.6 KB 627.5 KB -7.95%
Memory mem client unique-location-churn (vue) 459.1 KB 498.7 KB -7.94%
Simulation client-rewrites navigation loop (vue) 120.5 ms 130.8 ms -7.84%
Simulation client-side navigation loop (vue) 128.6 ms 137.6 ms -6.55%
Simulation client-route-tree-scale navigation loop (vue) 116.1 ms 124 ms -6.39%
Memory mem server error-paths not-found (react) 433.9 KB 462.9 KB -6.27%
Memory mem server error-paths redirect (react) 311.8 KB 331.8 KB -6.03%
Simulation client-control-flow navigation loop (vue) 60 ms 63.8 ms -5.84%
Simulation client-async-pipeline navigation loop (react) 61.5 ms 64.3 ms -4.39%
Simulation client-control-flow navigation loop (solid) 106.4 ms 111.1 ms -4.23%
Simulation ssr control-flow route headers (solid) 216.2 ms 224.9 ms -3.89%
Simulation ssr dehydrate rich types (solid) 208.1 ms 216.1 ms -3.72%
Simulation client-head navigation loop (react) 121.7 ms 126.1 ms -3.51%
Memory mem client navigation-churn (vue) 1.5 MB 1.6 MB -3.38%
Simulation client-head navigation loop (vue) 164.7 ms 170.1 ms -3.18%
Memory mem server peak-large-page (react) 2.3 MB 1.2 MB +95.86%
... ... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

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


Comparing perf/single-search-validation-lean (63b3cfb) with main (edf0e16)

Open in CodSpeed

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