SIGN IN SIGN UP

perf(react-router): bail out of Link re-renders when href and active state are unchanged (#7952)

* perf(react-router): bail out of Link re-renders when href and active state are unchanged

useLinkProps subscribes to the location store with an identity selector and an
href comparator, then derives href and isActive from the published location in
downstream memos. The comparator can only ask "is this a different URL?", never
"does this link care?", so every Link on the page re-renders on every navigation.

Move the location-derived values into the selector and compare them, so a link
whose resolved href and active state are unaffected by a navigation bails out.
buildLocation still runs once per link per location change; what goes away is the
React render and the host reconciliation under it.

doPreload no longer pre-supplies _builtLocation, because the built location is no
longer kept in render state. preloadRoute already falls back to building it, which
is what handleClick has always relied on for router.navigate.

The isActive and externalLink bodies move to module-level helpers unchanged so the
selector stays readable; activeOptions is spread into its four primitive fields in
the dependency list because callers routinely pass an inline object literal.

* refactor: trim redundant comments and brace single-line bodies

The comments explained the bail-out rationale twice — once on the LinkState type
and again above the selector — and two helper docblocks restated their function
names. The rationale now appears once, where a reader meets the selector; the
detail belongs in the PR description rather than the source.

Also braces the three single-line if bodies, per the AGENTS.md rule that if/else
bodies always use curly braces.

* refactor: publish link state as a tuple

The type is erased either way, but the object literal's property names survive
minification and a tuple's positions don't — so this drops three property names
from the selector's return plus the three property reads in compareLinkState.

Measured on the unminified build: -54 bytes in dist/esm/link.js and the same in
dist/cjs/link.cjs.

* test: assert the published link state, and drop a redundant cast

The render-count assertions proved the bail-out but not that the selector still
publishes correct values, so a selector returning a constant could have passed.
The test now also asserts that the link gaining active state carries
`data-status="active"` afterwards (and does not beforehand), and that the
unaffected link keeps its href and stays inactive.

Checked by sabotaging the selector: returning a constant tuple with a wrong href
but a correct active state now fails, where previously it passed.

Also drops `as any` from `new URL(to)` in resolveExternalLink — the guard above
already narrows `to` to string.

* refactor: pass activeOptions through instead of destructuring

Depends on the four fields rather than the object, with an exhaustive-deps
disable: callers routinely pass an inline literal, which would otherwise rebuild
the selector every render. resolveIsActive reads only those four fields, so the
disable is not hiding a live dependency.

-269 bytes on each of dist/esm/link.js and dist/cjs/link.cjs (unminified).

* perf: memoize the href derivation on the built href

The `useMemo` chain this replaced keyed `getHrefOption` and the external-link
resolution on the href string, so a navigation that left a link's href alone
skipped both. Deriving everything in the selector ran them on every location
notification instead, which showed up as a ~10% regression on the client-nav
rewrites benchmark, where rewrite handling makes `getHrefOption` expensive.

Cache both on the built href inside the selector closure. Measured on a
five-link root layout, per navigation: getHrefOption drops from 5 calls back to
0, matching the pre-change profile, with buildLocation and the active-state
derivation unchanged at 5.

* perf: keep _options referentially stable while its contents are equal

Links commonly pass inline `params` / `search` object literals. Those change
identity on every parent render, which rebuilt `_options`, which changed the
store selector's identity, which discarded useSyncExternalStoreWithSelector's
memoized selection. buildLocation then ran twice per navigation: once in the
notification check and once in the render-phase selection.

Measured on a replica of the client-nav rewrites scenario (six links, root
subscribed to the pathname via useLocation), buildLocation per navigation:
base 7, before this commit 12, after 7.

* revert: drop the href memoization, it measured no benefit

Reverts 409371b. It did cut getHrefOption from 5 calls per navigation to 0,
matching the pre-change profile, but that is not where the time went: on the
rewrites scenario it moved the number by 0.05% (medians 245.33 vs 245.21 hz over
four interleaved rounds). Not worth ~15 lines of mutable closure state.

The rewrites regression is fixed by the _options stabilisation instead.

* docs: explain why useValueStable exists

Replaces a leftover scratch note.

* perf: cut the bundle cost of the link state selector

Stabilise `activeOptions` with the same helper used for `search` / `params`,
so the selector depends on one value instead of four destructured fields. That
also makes the dependency array honest, so the exhaustive-deps disable goes.

Drop the reference-equality guard in useValueStable: deepEqual already
short-circuits on `a === b`, so the guard only saved a function call that
returns immediately.

Measured with benchmarks/bundle-size against this branch's base, gzip delta
across the eight React scenarios moves from +27/-7 to +11/-24, and raw bytes go
uniformly negative (-37 to -45 on every scenario).

* perf: pass _options to preloadRoute without the shallow clone

The spread existed to add _builtLocation, which is gone, and nothing on the
preload path mutates the options object: preloadRoute only reads
opts._builtLocation, build() only reads dest fields, and the search middleware
chain only reads dest.search. Search middlewares themselves receive
{ search, next }, never dest.

Saves an object allocation per hover. Bytes are unchanged on gzip, -5 raw.

* fix: do not collapse explicit-undefined params when stabilising link options

useValueStable compared with deepEqual's default ignoreUndefined: true, which
skips undefined-valued keys on both sides, so `{}` and `{ category: undefined }`
compared equal. Those build different locations: an explicit undefined clears an
inherited optional param while an empty object inherits it. A Link whose params
changed from one to the other kept publishing the stale href, and click and
preload used the stale options too.

Compare with ignoreUndefined: false. Adds a regression test covering the
/posts/tech -> /posts transition, which fails before this commit and passes
after, and passes on the pre-PR baseline.

* chore: empty commit to re-trigger CI

* chore: add changeset

---------

Co-authored-by: Flo <me@florianpellet.com>
M
Mat Clayton committed
95dec51018d01483949c6f1fe3094b019ccf3b4f
Parent: 6bede65
Committed by GitHub <noreply@github.com> on 8/7/2026, 3:37:30 PM