98ae4cf2 perf(router): keep the location cache client-only and slim Link SSR
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> 068750d2 fix(router-core): keep sparse arrays with extra keys opaque in replaceEqualDeep
The plain-array check compares `Object.keys(array).length` with
`array.length`. A hole and an extra enumerable key cancel out in that
count, so `Object.assign([1, ,], { extra: 'x' })` was admitted: the
index-based copy dropped `extra` and turned the hole into an explicit
`undefined`, and an index-wise equal `prev` was returned for it. The
same blind spot exists in the exported `isPlainArray`, which the
previous implementation used for this check.
Extra string keys always sort after the index keys, so once the count
matches, `next` is a dense index-only array exactly when its last key
is its last index. That is one string comparison per array instead of
a per-key validation, and it stays inline with the existing count
check. `prev` keeps the count check only: a result is either a copy
built from `next`'s entries or `prev` itself, which the caller already
holds.
Symbol keys on arrays stay admitted, as before this stack: checking
them costs an `Object.getOwnPropertySymbols` call per array pair, which
measured ~8% on the equal nested search shape (a record holding a
four-element array), and `react-router.minimal` +3 B on top of the
+13 B gzip of this fix.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> d39fb6d6 perf(router-core): split replaceEqualDeep into an equality scan and a copy phase
`replaceEqualDeep` compared and copied in one loop. Every entry, also on
the common all-equal path, paid for the copy bookkeeping (`copy ??=`,
`if (copy) copy[key] = n`), an `equal` flag and — for objects — a
`hasOwn.call(prev, key)` lookup, while arrays and objects shared the
same keyed accesses through `array ? i : keys[i]`, so every access site
saw both element and named keys.
The function now runs in two phases:
- Scan: walk `next` up to the first difference, sharing equal children
on the way. Arrays and objects have their own loop, so each keyed
access only ever sees one kind of key and nothing is allocated while
entries keep matching. A key at the same position of both key lists
is proven to be `prev`'s own key; only reordered keys still need
`hasOwn`. When everything matched and the key counts agree, `prev` is
returned.
- Copy: equality is ruled out, so the loop only builds the result. The
scanned prefix is shared from `prev`, the first differing entry keeps
the value the scan already computed, and the rest is resolved without
any equality bookkeeping.
Only an object entry of `prev` can share anything, so the recursion is
guarded by a single `typeof` check; `null` and mismatched types return
`next`'s entry through the entry checks. `isPlainArray` is no longer
used here: the array check reuses the `Object.keys` result that the
scan needs anyway. Semantics are unchanged: pass-through for
non-enumerable keys and for symbol keys on `next`, null-prototype
copies, the depth limit and the SSR early return all stay as they were,
and the tests pass against both implementations.
Bundle size (`benchmark:bundle-size`, gzip): react-router.minimal -1 B,
react-router.full -18 B, all 18 scenarios net -110 B (13 down, 5 up,
max +20 B on react-start.full for identical code).
Paired runs against the previous version (hz medians of 3 runs,
1000 calls per iteration; the machine was under load, so single-digit
differences are noise):
equal flat search 5.96k -> 6.98k (+17%)
equal nested search 2.47k -> 2.83k (+14%)
equal array of objects 524 -> 579 (+10%)
equal long primitive array (1024) 49 -> 69 (+39%)
long primitive array, last item changed 37 -> 41 (+11%)
wider flat object (64 keys), last changed 218 -> 229 (+5%)
flat search, one changed leaf 5.31k -> 5.59k (+5%)
array of objects, one changed item 483 -> 513 (+6%)
flat search, all leaves changed 5.29k -> 5.35k (+1%)
array of shared objects, one changed 1.61k -> 1.44k (-10%, ±29%)
Entries that change between an object and a primitive now pay one
recursive call instead of an inline four-way type check; guarding both
sides costs more bundle bytes than this branch is worth.
Adds tests for the scan/copy boundary (equal children and next-only
keys after the first difference, reordered keys, object/primitive
transitions, shorter and longer arrays, a change in the last of 1024
entries, the depth limit) and bench cases for the shapes that told the
candidate implementations apart.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> 5bfd250c perf(router-core): skip symbol enumeration for plain replaceEqualDeep inputs
Every object pair `replaceEqualDeep` visited paid for three native key
lookups per side: `Object.keys`, `Object.getOwnPropertyNames` (to detect
non-enumerable props) and `Object.getOwnPropertySymbols`. Measured on a
4-key literal the first two cost 7.6 ns and 10 ns, the symbol lookup
34 ns — 31 ns even on `{}` — so two thirds of the enumeration cost went
into a key type that router data never carries. (`Reflect.ownKeys` as a
single replacement call was tried and is 100 ns.)
Symbol keys and non-enumerable keys still have to be handled correctly:
Apollo's `preloadQuery` refs carry symbol keys and broke under
`defaultStructuralSharing` when copies dropped them (#4237). Instead of
comparing and copying such objects key by key, they are now opaque: an
object with non-enumerable own keys on either side, or symbol keys on
`next`, makes `next` pass through untouched. That keeps both guarantees
that matter — no partial copy, no stale `prev` returned for it — and only
gives up structural sharing for those objects.
`prev` is not scanned for symbols on purpose. It is normally an earlier
`next`, so a symbol-carrying `prev` meets a symbol-carrying `next` and the
`next` check fires; a symbol-carrying `prev` with a symbol-free, otherwise
equal `next` returns `prev` (covered by a test). Plain objects are now
compared and copied by `Object.keys` alone, and `getEnumerableOwnKeys`
is gone.
Paired runs against the previous version (hz, 1000 calls per iteration):
equal empty objects 14.8k -> 18.3k (+24%)
equal empty null-proto objects 5.1k -> 6.3k (+24%)
equal flat search 7.3k -> 8.2k (+12%)
flat, one changed leaf 6.1k -> 6.9k (+12%)
equal nested search 3.0k -> 3.1k (+4%)
nested, sharing subtrees 2.6k -> 2.7k (+7%)
equal array of objects 609 -> 729 (+20%)
array, one changed item 551 -> 640 (+16%)
wide (12 keys) changed, null-proto 1.8k -> 2.0k (+9%)
Tests now document the pass-through contract for symbol-keyed objects,
including the deliberate `prev`-only asymmetry.