perf(router): keep the location cache client-only and slim Link SSR - #8390
Conversation
The per-options location cache from the location-reuse changes only ever hits on the client: server renders never repeat an options object, yet every server build still created the WeakMap, looked it up per build, and Solid/Vue server Links stored an entry per render. All three sites are now guarded by `!(isServer ?? this.isServer)`. `isServer` is a per-bundle constant, so server bundles contain no cache at all (the Link SSR bundle has zero references); client bundles keep it unchanged. Rendering a React Link on the server copied its props four times: `Link` split off `_asChild`, `useLinkProps` split the router options off with an object rest, the result was assembled with spreads, and `Link` copied once more to drop `type` and `disabled`. Profiling showed the object rest alone was 85% of the hook's self time: V8 checks every key against the whole 35-entry exclusion list (about 470 ns per call versus 95 ns for a key-set copy). `useLinkProps` is now a wrapper over `useLinkPropsFor(options, ref, host)`. `Link` passes its host (`'a'` or the `createLink` component), so the hook omits `disabled` for anchors and `type` for both, and `Link` passes the result straight to `createElement`. The server branch is a separate `getServerLinkProps` referenced only inside the `isServer` check, so it and its key set are dropped from client bundles; it reads the few options it needs, splits element props with the key set, fills the props object in place with the same precedence and attribute order as before, and no longer runs `useForwardedRef` (moved after the server return). The client keeps its rest destructure; the host handling costs a few bytes there, mostly paid back by the folded router-core guards (numbers below). `encodePathLikeUrl` tests with one merged character class (`/[\s\u0080-\uFFFF]/`), which matches exactly the same code units as the alternation it replaces and is about 10% cheaper. Measurements (macOS arm64, Node 24.8.0, local): - Link SSR paired runner (ABBA, 4 fresh processes per case) against the previous stack top: all 13 cases faster, CPU -19.5% to -40.3%. - Link client paired runner: all 13 cases faster, CPU -4.6% to -9.2%. - Start SSR request loop (benchmarks/ssr, react), interleaved builds: 3.120 -> 2.890 ms per loop (-7.4%), 320.5 -> 346.1 hz. - Client bundle react-router.minimal gzip: 86012 -> 86021 (+9; raw +7, brotli -22): the router-core guards fold to -12, the Link host handling costs +19. The Link SSR bundle shrinks from 197832 to 189989 bytes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (1)
Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review. 📝 WalkthroughWalkthroughThe router now avoids location-cache allocation on the server. React Links use host-aware server and client prop generation with consistent state-prop precedence. Blocked custom links and related tests now cover state refs and handlers. ChangesServer rendering behavior
Priority: ➖ Normal Estimated code review effort: 3 (Moderate) | ~25 minutes Change: Refactor Suggested reviewers: Merge Risk: ⚪ Minimal · up to No merge-blocking issue was identified in the client-only cache or Link prop-generation changes. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
|
View your CI Pipeline Execution ↗ for commit 949e507
☁️ Nx Cloud last updated this comment at |
🚀 Changeset Version Preview7 package(s) bumped directly, 22 bumped as dependents. 🟩 Patch bumps
|
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. |
Merging this PR will improve performance by 5.97%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ⚡ | Simulation | ssr request loop (react) |
171.2 ms | 161.5 ms | +5.97% |
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-ssr-link-performance (98ae4cf) with schiller-manuel-search-middleware-compile-memory (6aec14d)
| } else { | ||
| props.ref = forwardedRef | ||
| Object.assign(props, resolvedStateProps) | ||
| } |
There was a problem hiding this comment.
why does this distinction matter?
| const STATIC_DISABLED_PROPS = { role: 'link', 'aria-disabled': true } | ||
| const STATIC_ACTIVE_PROPS = { 'data-status': 'active', 'aria-current': 'page' } | ||
| // Options consumed by the router and never forwarded to the element. | ||
| const LINK_OPTION_KEYS = /* @__PURE__ */ new Set([ |
There was a problem hiding this comment.
i'm assuming this was verified to only exist in the server build?
There was a problem hiding this comment.
yes. not leaking into the client since only referenced in the server branch from getServerLinkProps
Active and inactive state props overrode `ref` and the event handlers on every link except a blocked one (a destination with a disallowed scheme), where the forwarded ref and the router's handlers won. The exception dates from the hardening change, whose test observed the then-general "router wins" rule on a blocked link; when state props were later allowed to override element props, blocked links were carved out to keep that test green rather than by design. It bought no safety: `href`, `disabled` and `target` are assigned after the state props in both orders, which is what actually keeps a blocked destination inert. It did make the same `inactiveProps` attach their `ref` and `onClick` on one inactive link and silently drop them on another, and it differed from Solid's Link, which applies one rule. Blocked links now follow the same precedence as every other link: state props override element props, `ref` and handlers; the routing attributes always win. The server branch loses its if/else, the client loses the two conditional spreads and the `blockedLink` flag. The blocked-link test now pins the uniform rule together with the routing attributes it already checked. Measurements (macOS arm64, Node 24.8.0, local, against the previous commit): - react-router.minimal gzip 86021 -> 86019 (-2), raw -38, brotli -33. - Link client paired runner (3 repeats): shared-params CPU -11.6% [-15.1, -7.9], encoding -13.2% [-20.2, -5.7]. - Link SSR paired runner: within noise. SSR bundle 189989 -> 189850 bytes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Stacked on #8385.
🎯 Changes
Why
buildLocationcache (staticLocations, from the location-reuse layers) only ever hits on the client: server renders never repeat an options object. Yet every server build still created theWeakMap, looked it up perbuildLocation, and Solid/Vue server Links (which pass a fresh{ _fromLocation, ...options }per render) stored an entry per render.Linkon the server copied its props four times:Linksplit off_asChildwith an object rest,useLinkPropssplit the router options off with another object rest, the result was assembled with spreads, andLinkcopied once more to droptype/disabled. Profiling showed the object rest alone was ~85% of the hook's self time: V8 checks every key of an object rest against the whole 35-entry exclusion list (~470 ns per call vs ~95 ns for a key-set copy).What changed
staticLocationsis client-only. The field is nowWeakMap | undefined; its creation inupdate()andsetRoutes(), the read at the top ofbuildLocation, and the write at its end are all guarded by!(isServer ?? this.isServer).isServerfrom@tanstack/router-core/isServeris a per-bundle constant (falsein browser builds,truein server builds), so bundlers fold the guards: server bundles contain no cache at all, client bundles keep the cache logic unchanged.Linkserver render, no extra client copies.useLinkPropsis now a thin wrapper overuseLinkPropsFor(options, ref, host?).Linkcalls it with its host ('a'or thecreateLinkcomponent), so the hook omitsdisabledfor the anchor host andtypefor both hosts, andLinkpasses the result straight tocreateElement(no copies). The server branch is a separategetServerLinkPropsreferenced only insideif (isServer ?? router.isServer), so it and itsLINK_OPTION_KEYSset are dropped from client bundles. It reads the few options it needs directly, splits element props with the key set, fills the props object in place with the same precedence and attribute order as the previous spreads, and no longer callsuseForwardedRef(that hook now sits after the server return; a server render never re-renders and the returnedrefis the forwarded ref). The client path keeps its original rest destructure (plus_asChild/typeextraction andhost !== 'a'arounddisabled).encodePathLikeUrltests with one merged character class/[\s\u0080-\uFFFF]/instead of the alternation/\s|[^\u0000-\u007F]/. Verified identicaltest()results for every UTF-16 code unit (0x0000–0xFFFF) plus surrogate-pair samples; the replacement regex is unchanged. About 10% cheaper.blockedLinkbranch).activeProps/inactivePropsused to overriderefand the event handlers on every link except a blocked one (destination with a disallowed scheme), where the forwarded ref and the router's handlers won. That exception was a leftover of the hardening change's original "router wins" ordering, kept alive by a test when state props were later allowed to override element props. It bought no safety (href,disabled,targetare assigned after the state props in both orders, which is what keeps a blocked destination inert), it made the sameinactivePropsattach theirref/onClickon one inactive link and silently drop them on another, and it differed from Solid's Link. Blocked links now follow the same rule as every other link; the server loses its if/else, the client loses two conditional spreads and theblockedLinkflag. The blocked-link test pins the uniform rule together with the routing attributes it already checked. Noted in the changeset.Measurements (local, macOS arm64 / Node 24.8.0 — not CI)
Paired-runner and Start SSR numbers for the first commit are the stack author's local measurements:
@benchmarks/react-link-performance:test:perf:stable --mode ssr, ABBA blocks, 4 fresh processes per case) against a dist snapshot of the previous stack top: all 13 cases "faster", CPU −19.5% .. −40.3% (shared-params −39.4%, unique-params −40.3%, param-updaters −36.4%, location-updaters −23.8%, relative −23.6%, middleware −19.5%, numeric-params −37.5%, optional-params −38.0%, splats −31.2%, encoding −21.1%, masks −29.4%, rewrites −20.7%, active −32.9%).--mode client): all 13 cases "faster", CPU −4.6% .. −9.2% (from the removedLinkcopies).benchmarks/ssrreact baseline app, interleaved builds, 4+4 slots, rme < 1.2%): 3.120 → 2.890 ms per 10-request loop (−7.4%), 320.5 → 346.1 hz (+8.0%).Second commit (uniform precedence), paired runner with 3 repeats against the first commit:
Bundle sizes measured in this PR's worktree with the official runner (
benchmark:bundle-size:run, cache-free rebuilds on both sides):react-router.minimalgzip: 86012 → 86019 (+7); raw −31, brotli −55. Per-hunk attribution: the router-core hunks alone are −12 gzip (the guards fold away and the regex is shorter); thelink.tsxchanges cost the remainder (the_asChild/typerest exclusions, thetypere-add for the public hook and thehost !== 'a'guards are not fully paid back by the removedLinkcopies). The second commit is −2 gzip on its own.benchmarks/client-nav/link-performance/dist/ssr/app.js): 197832 → 189850 bytes, because the client-only Link code no longer ships to the server.Bundle-content assertions (verified after
TSR_LINK_PERF=1 pnpm nx run-many --target=build:ssr,build:client --projects=@benchmarks/react-link-performance)dist/ssr/app.js: 0staticLocationsreferences (was 4), containsgetServerLinkPropsand theLINK_OPTION_KEYSset, 0useForwardedRefreferences.dist/client/app.js: 4staticLocationsreferences (unchanged), 0getServerLinkProps, 0"preloadIntentProximity"string literal (the key set is gone; the two remainingpreloadIntentProximityoccurrences are the client rest destructure, as before).Tests run
@tanstack/router-core:test:unit— 133 files, 3303 passed | 4 expected fail@tanstack/react-router:test:unit— 91 files, 1174 passed | 1 skipped (re-run after the second commit)@tanstack/solid-router:test:unit— 942 passed | 1 skipped (+ 51 in the second project)@tanstack/vue-router:test:unit— 968 passed | 1 skippedtest:types+test:eslintfor@tanstack/router-coreand@tanstack/react-router— types clean, eslint 0 errors (only pre-existing warnings, none inlink.tsx, none on changed lines ofrouter.ts/utils.ts)git diff --checkand prettier on all changed files — clean✅ Checklist
🚀 Release Impact
Summary by CodeRabbit
href,disabled, andtargetare applied consistently.