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>