perf(router-core): stop re-validating search params for buildLocation - #8231
perf(router-core): stop re-validating search params for buildLocation#8231Sheraff wants to merge 1 commit into
Conversation
`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
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Bundle Size Benchmarks
The following scenarios have bundle-size changes compared with the baseline:
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. |
|
View your CI Pipeline Execution ↗ for commit 63b3cfb
☁️ Nx Cloud last updated this comment at |
🚀 Changeset Version Preview5 package(s) bumped directly, 18 bumped as dependents. 🟩 Patch bumps
|
Merging this PR will regress 19 benchmarks
|
| 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)
Summary
On a navigation, each matched route's
validateSearchran three times from three separate sites:matchRoutesLightweight(called frombuildLocationto computefromSearch), thevalidatemiddleware inapplySearchMiddleware(builds the committed URL), andmatchRoutesInternal. 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:
matchRoutesInternalstamps the accumulated, validated search of the leaf match onto the location object it resolved (location._search, same precedent as_redirects).matchRoutesLightweightreads 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). Becauseloadmatches the same object it publishes tostores.location, andbuildLocationdefaults tolatestLocation, every<Link>build and everynavigate()after a load hits, whether or not path or search changed.validateSearchshare 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
validateSearchcalls per navigation on a 3-route branch: 9 → 6. Reload,invalidateand 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
match.search(previously the validator received a discarded copy).validateSearchshare the parent'ssearchobject instead of receiving a structurally identical copy._searchproperty; nothing serializes or spreadsParsedLocationobjects 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,searchErrorafter a clean navigation,throwOnErrorthrowing 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