From 665c81b726106f3a74a4c068c9c131521ba73498 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 13 Sep 2026 17:13:10 +0200 Subject: [PATCH 1/3] perf(router-core): reuse key buffers in replaceEqualDeep --- .changeset/poor-tires-tan.md | 5 + packages/router-core/src/utils.ts | 174 ++++++------ .../tests/replace-equal-deep.bench.ts | 190 +++++++++++++ packages/router-core/tests/utils.test.ts | 254 +++++++++++++++++- 4 files changed, 534 insertions(+), 89 deletions(-) create mode 100644 .changeset/poor-tires-tan.md diff --git a/.changeset/poor-tires-tan.md b/.changeset/poor-tires-tan.md new file mode 100644 index 0000000000..a3f9401871 --- /dev/null +++ b/.changeset/poor-tires-tan.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Reimplement `replaceEqualDeep` with temporary key lists for resolved children. Speed up equal arrays and shared array prefixes while retaining recursive sharing of changed children and consistent signed-zero behavior. Correctly remove previous symbol properties and keep sparse arrays with extra keys and built-in objects with an own `constructor` opaque. diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index bf28ff6c67..f6f6bfe332 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -223,124 +223,136 @@ export const nullReplaceEqualDeep: typeof replaceEqualDeep = (prev, next) => replaceEqualDeep(prev, next, true) /** - * This function returns `prev` if `next` is deeply equal. - * If not, it will replace any deeply equal children of `b` with those of `a`. + * Returns `prev` when the supported values are deeply equal; otherwise copies + * `next` with its equal children shared from `prev`. * This can be used for structural sharing between immutable JSON values for example. + * Own `__proto__` keys are unsupported when copying ordinary objects. * Do not use this with signals */ export function replaceEqualDeep( prev: any, next: T, _nullProto?: boolean, - _depth?: number, -): T -export function replaceEqualDeep( - prev: any, - next: any, - _nullProto?: boolean, _depth = 0, -): any { +): T { if (isServer) { return next } if (prev === next) { return prev } + if (_depth > 500) { + return next + } - if (_depth > 500) return next - - const array = Array.isArray(prev) && Array.isArray(next) - - if (!array && !(isPlainObject(prev) && isPlainObject(next))) return next - - const prevKeys = Object.keys(prev) - const nextKeys = Object.keys(next) - const length = nextKeys.length - // Holes or extra keys on an array, non-enumerable keys on an object, or symbol - // keys on `next` make a value opaque: it passes through untouched rather than - // being compared or copied by its indices or string keys only. A hole and an - // extra key cancel out in the key count, but extra keys sort after the indices, - // so only a dense index-only `next` has its last index as its last key. - // (`getOwnPropertySymbols` is ~4x the cost of the other two, so `prev` — normally an - // earlier `next` — is not checked for symbols, and arrays are not checked at all.) + const value = next as any + const array = Array.isArray(value) if ( - array - ? prevKeys.length !== prev.length || - length !== next.length || - (length && nextKeys[length - 1] !== `${length - 1}`) - : prevKeys.length !== Object.getOwnPropertyNames(prev).length || - length !== Object.getOwnPropertyNames(next).length || - Object.getOwnPropertySymbols(next).length + array ? !Array.isArray(prev) : !isPlainObject(prev) || !isPlainObject(value) ) { return next } - let i = 0 - let n: any + const previousKeys = Object.keys(prev) + const keys = Object.keys(value) + const count = keys.length + if (array) { + // Reject holes and extra enumerable string keys before using indices. + if ( + previousKeys.length !== prev.length || + count !== value.length || + (previousKeys.length > 0 && + previousKeys[previousKeys.length - 1] !== String(prev.length - 1)) || + (count > 0 && keys[count - 1] !== String(count - 1)) + ) { + return next + } + } else if ( + previousKeys.length !== Object.getOwnPropertyNames(prev).length || + count !== Object.getOwnPropertyNames(value).length || + Object.getOwnPropertySymbols(value).length > 0 + ) { + return next + } - // Most calls find `next` deeply equal, so nothing is allocated while entries - // keep matching: scan up to the first difference, sharing equal children on - // the way. Arrays and objects get their own loop so that each keyed access - // only ever sees one kind of key. Only an object entry of `prev` can share - // anything; the recursive call returns `next`'s entry for `null` and for - // mismatched types. When the key counts differ, the scan still runs: every - // child it resolves is exactly what the copy below shares. + let equal = previousKeys.length === count + // The key lists are private scratch space. Arrays reuse next's exact-size + // list as their result; objects keep next's keys and overwrite prev's list + // with resolved children after each ownership check. + const children: Array = array ? keys : previousKeys if (array) { - for (; i < length; i++) { - const p = prev[i] - n = next[i] - if (p !== n && typeof p === 'object') { - n = replaceEqualDeep(p, n, _nullProto, _depth + 1) + // An identical array needs no child resolution or buffer writes. If an + // entry differs, copy the known-identical prefix directly from prev. + let start = 0 + if (equal) { + while (start < count && prev[start] === value[start]) { + start++ } - if (n !== p) break - } - } else { - for (; i < length; i++) { - const key = nextKeys[i]! - const p = prev[key] - n = next[key] - if (p !== n && typeof p === 'object') { - n = replaceEqualDeep(p, n, _nullProto, _depth + 1) + if (start === count) { + return prev } - // Equal key counts can still hide a key that only `next` has (as `undefined`). - // The same key at the same position of both key lists proves it is `prev`'s - // own key; only reordered keys need the `hasOwn` lookup. - if (n !== p || (prevKeys[i] !== key && !hasOwn.call(prev, key))) { - break + } + // Filling lets numeric results use packed numeric storage in V8. + if (count > 0 && typeof value[0] === 'number') { + children.fill(0) + } + for (let index = 0; index < start; index++) { + children[index] = prev[index] + } + for (let index = start; index < count; index++) { + const previous = prev[index] + const incoming = value[index] + const child = + previous === incoming + ? previous + : typeof previous === 'object' + ? replaceEqualDeep(previous, incoming, _nullProto, _depth + 1) + : incoming + children[index] = child + if (child !== previous) { + equal = false } } + return (equal ? prev : children) as T } - // Everything in `next` matched and `prev` has no additional keys. - if (i === length && prevKeys.length === length) return prev - - // Equality is ruled out from here on, so this loop only builds the result: - // the scanned prefix is shared from `prev`, entry `i` keeps the value the scan - // already computed, and the rest is resolved without any equality bookkeeping. - const copy: any = array ? [] : _nullProto ? Object.create(null) : {} - for (let j = 0; j < length; j++) { - const key = array ? j : nextKeys[j]! - const p = prev[key] - if (j > i) { - n = next[key] - if (p !== n && typeof p === 'object') { - n = replaceEqualDeep(p, n, _nullProto, _depth + 1) - } + + for (let index = 0; index < count; index++) { + const key = keys[index]! + const previous = prev[key] + const incoming = value[key] + const child = + previous === incoming + ? previous + : typeof previous === 'object' + ? replaceEqualDeep(previous, incoming, _nullProto, _depth + 1) + : incoming + if ( + equal && + (child !== previous || + (previousKeys[index] !== key && !hasOwn.call(prev, key))) + ) { + equal = false } - copy[key] = j < i ? p : n + children[index] = child + } + if (equal) { + return Object.getOwnPropertySymbols(prev).length ? next : prev } - return copy + const result = _nullProto ? Object.create(null) : {} + for (let index = 0; index < count; index++) { + result[keys[index]!] = children[index] + } + return result } export function isPlainObject(o: unknown): boolean { if (!o || typeof o !== 'object') { return false } - // Literals inherit `Object`; null-prototype records have no constructor at all. - if ((o.constructor ?? Object) === Object) { + // An own constructor is data and cannot identify the object's prototype. + if (o.constructor === Object && !hasOwn.call(o, 'constructor')) { return true } - // An own `constructor` key (`?constructor=foo`) hides the inherited one, so - // only the prototype can tell such a record from a class instance. const proto = Object.getPrototypeOf(o) return proto === null || proto.constructor === Object } diff --git a/packages/router-core/tests/replace-equal-deep.bench.ts b/packages/router-core/tests/replace-equal-deep.bench.ts index d6eac3279b..97c47493d3 100644 --- a/packages/router-core/tests/replace-equal-deep.bench.ts +++ b/packages/router-core/tests/replace-equal-deep.bench.ts @@ -68,9 +68,57 @@ const wider = Object.fromEntries( Array.from({ length: 64 }, (_, index) => [`key${index}`, `value${index}`]), ) const widerChangedLast = { ...wider, key63: 'changed' } +const widerChangedFirst = { ...wider, key0: 'changed' } +const widerAllChanged = Object.fromEntries( + Object.keys(wider).map((key) => [key, 'changed']), +) +const widerSubset = Object.fromEntries(Object.entries(wider).slice(0, 8)) const numbers = Array.from({ length: 1024 }, (_, index) => index) const numbersCopy = [...numbers] const numbersChangedLast = numbers.map((n, index) => (index === 1023 ? -1 : n)) +const numbersChangedFirst = numbers.map((n, index) => (index === 0 ? -1 : n)) +const numbersChangedMiddle = numbers.map((n, index) => (index === 512 ? -1 : n)) +const numbersAllChanged = numbers.map((n) => -n - 1) +const numbersShorter = numbers.slice(0, 512) +const numbersLonger = [...numbers, undefined, undefined] +const smallNumbers = [1, 2, 3, 4] +const smallNumbersChanged = [9, 2, 3, 4] +const consumedNumbers = replaceEqualDeep(numbers, numbersChangedLast) +const longList = Array.from({ length: 1024 }, (_, id) => ({ id })) +const longListCopy = [...longList] +const longListClonedLast = [...longList] +longListClonedLast[1023] = { id: 1023 } +const longListChangedLast = [...longList] +longListChangedLast[1023] = { id: -1 } +const longListClonedThenChanged = [...longListChangedLast] +longListClonedThenChanged[1022] = { id: 1022 } +const numericCases = [ + ['fractional', Array.from({ length: 1024 }, (_, index) => index + 0.5)], + [ + 'large integer', + Array.from({ length: 1024 }, (_, index) => 2 ** 40 + index), + ], +] as const +const numericResults = numericCases.map(([name, prev]) => { + const next = [...prev] + next[1023] = -1.5 + const result = replaceEqualDeep(prev, next) + expect(result).toStrictEqual(next) + return { name, prev, next, result } +}) +const mixedPairs: Array<[unknown, unknown]> = [ + [search, search], + [search, searchCopy], + [search, searchChanged], + [search, searchSubset], + [searchSubset, search], + [nested, nestedCopy], + [list, listChanged], + [list, listShared], + [smallNumbers, smallNumbersChanged], + [emptyNull, createNull()], + [searchWithConstructorKey, searchWithConstructorKeyCopy], +] expect(replaceEqualDeep(empty, {})).toBe(empty) expect(replaceEqualDeep(search, searchCopy)).toBe(search) @@ -90,7 +138,32 @@ expect(replaceEqualDeep(wider, widerChangedLast)).toStrictEqual( widerChangedLast, ) expect(replaceEqualDeep(numbers, numbersCopy)).toBe(numbers) +expect(replaceEqualDeep(longList, longListCopy)).toBe(longList) +expect(replaceEqualDeep(longList, longListClonedLast)).toBe(longList) +expect(replaceEqualDeep(longList, longListChangedLast)).toStrictEqual( + longListChangedLast, +) +const sharedLongList = replaceEqualDeep(longList, longListClonedThenChanged) +expect(sharedLongList).toStrictEqual(longListClonedThenChanged) +expect(sharedLongList[1022]).toBe(longList[1022]) +expect(replaceEqualDeep(widerSubset, wider)).toStrictEqual(wider) +expect(replaceEqualDeep(wider, widerChangedFirst)).toStrictEqual( + widerChangedFirst, +) +expect(replaceEqualDeep(wider, widerAllChanged)).toStrictEqual(widerAllChanged) expect(replaceEqualDeep(numbers, numbersChangedLast)[1023]).toBe(-1) +for (const [prev, next] of mixedPairs) { + expect(replaceEqualDeep(prev, next)).toStrictEqual(next) +} +for (const next of [ + numbersChangedFirst, + numbersChangedMiddle, + numbersAllChanged, + numbersShorter, + numbersLonger, +]) { + expect(replaceEqualDeep(numbers, next)).toStrictEqual(next) +} expect(nullReplaceEqualDeep(emptyNull, {})).toBe(emptyNull) expect( nullReplaceEqualDeep(searchWithConstructorKey, searchWithConstructorKeyCopy), @@ -230,6 +303,52 @@ describe('replaceEqualDeep', () => { } }) + bench('flat object growing from 8 to 64 keys', () => { + for (let i = 0; i < iterations; i++) { + sink = replaceEqualDeep(widerSubset, wider) + } + }) + + for (const [name, next] of [ + ['first leaf changed', widerChangedFirst], + ['all leaves changed', widerAllChanged], + ] as const) { + bench(`wider flat object (64 keys) with ${name}`, () => { + for (let i = 0; i < iterations; i++) { + sink = replaceEqualDeep(wider, next) + } + }) + } + + bench('equal long array of shared objects', () => { + for (let i = 0; i < iterations; i++) { + sink = replaceEqualDeep(longList, longListCopy) + } + }) + + for (const [name, next] of [ + ['cloned but equal', longListClonedLast], + ['changed', longListChangedLast], + ['changed after a cloned but equal item', longListClonedThenChanged], + ] as const) { + bench(`long array of shared objects with the last item ${name}`, () => { + for (let i = 0; i < iterations; i++) { + sink = replaceEqualDeep(longList, next) + } + }) + } + + for (const length of [0, 1, 4]) { + const prev = Array.from({ length }, (_, index) => index) + const next = [...prev] + expect(replaceEqualDeep(prev, next)).toBe(prev) + bench(`equal primitive array of length ${length}`, () => { + for (let i = 0; i < iterations; i++) { + sink = replaceEqualDeep(prev, next) + } + }) + } + bench('equal long primitive array', () => { for (let i = 0; i < iterations; i++) { sink = replaceEqualDeep(numbers, numbersCopy) @@ -241,6 +360,77 @@ describe('replaceEqualDeep', () => { sink = replaceEqualDeep(numbers, numbersChangedLast) } }) + + for (const [name, next] of [ + ['first item changed', numbersChangedFirst], + ['middle item changed', numbersChangedMiddle], + ['all items changed', numbersAllChanged], + ['shortened', numbersShorter], + ['appended undefined values', numbersLonger], + ] as const) { + bench(`long primitive array with ${name}`, () => { + for (let i = 0; i < iterations; i++) { + sink = replaceEqualDeep(numbers, next) + } + }) + } + + bench('small primitive array with the first item changed', () => { + for (let i = 0; i < iterations; i++) { + sink = replaceEqualDeep(smallNumbers, smallNumbersChanged) + } + }) + + bench('mixed search and array updates', () => { + for (let i = 0; i < iterations; i++) { + const [prev, next] = mixedPairs[i % mixedPairs.length]! + sink = replaceEqualDeep(prev, next) + } + }) + + for (const { name, prev, next } of numericResults) { + bench(`long ${name} array with the last item changed`, () => { + for (let i = 0; i < iterations; i++) { + sink = replaceEqualDeep(prev, next) + } + }) + } +}) + +describe('consume structurally shared arrays', () => { + bench('sum changed primitive array by index', () => { + for (let i = 0; i < iterations; i++) { + let sum = 0 + for (let j = 0; j < consumedNumbers.length; j++) { + sum += consumedNumbers[j]! + } + sink = sum + } + }) + + bench('reduce changed primitive array', () => { + for (let i = 0; i < iterations; i++) { + sink = consumedNumbers.reduce((sum, value) => sum + value, 0) + } + }) + + for (const { name, result } of numericResults) { + bench(`sum changed ${name} array by index`, () => { + for (let i = 0; i < iterations; i++) { + let sum = 0 + for (let j = 0; j < result.length; j++) { + sum += result[j]! + } + sink = sum + } + }) + + bench(`reduce changed ${name} array`, () => { + for (let i = 0; i < iterations; i++) { + sink = result.reduce((sum, value) => sum + value, 0) + } + }) + } }) describe('deepEqual', () => { diff --git a/packages/router-core/tests/utils.test.ts b/packages/router-core/tests/utils.test.ts index e93c2889b5..69df1458ed 100644 --- a/packages/router-core/tests/utils.test.ts +++ b/packages/router-core/tests/utils.test.ts @@ -13,6 +13,147 @@ import { import { decode } from '../src/qss' describe('replaceEqualDeep', () => { + it('returns the previous signed zero when strict equality matches', () => { + expect(replaceEqualDeep(-0, 0)).toBe(-0) + expect(replaceEqualDeep(0, -0)).toBe(0) + }) + + it('uses the same signed-zero sharing rule for every child', () => { + const prev = { first: -0, changed: 1, last: -0 } + const next = { first: 0, changed: 2, last: 0 } + const result = replaceEqualDeep(prev, next) + expect(result.first).toBe(-0) + expect(result.changed).toBe(2) + expect(result.last).toBe(-0) + expect(replaceEqualDeep([-0, 1, -0], [0, 2, 0])).toStrictEqual([-0, 2, -0]) + }) + + // Known unsupported edge case for ordinary object copies. + it.fails( + 'copies an own __proto__ property as data in the incoming key order', + () => { + const prev = JSON.parse('{"first":1,"__proto__":{"shared":1},"last":1}') + const next = JSON.parse('{"first":2,"__proto__":{"shared":1},"last":1}') + const result = replaceEqualDeep(prev, next) + expect(Object.getPrototypeOf(result)).toBe(Object.prototype) + expect(hasOwn.call(result, '__proto__')).toBe(true) + expect(result.__proto__).toBe(prev.__proto__) + expect(Object.keys(result)).toEqual(Object.keys(next)) + expect(result).toStrictEqual(next) + }, + ) + + it.fails.each([undefined, { shared: 1 }])( + 'preserves a newly added own __proto__ property with value %j', + (value) => { + const prev = { first: 1, last: 1 } + const next = { first: 1, ['__proto__']: value, last: 1 } + const result = replaceEqualDeep(prev, next) + expect(Object.getPrototypeOf(result)).toBe(Object.prototype) + expect(hasOwn.call(result, '__proto__')).toBe(true) + expect(result.__proto__).toBe(value) + expect(Object.keys(result)).toEqual(Object.keys(next)) + expect(result).toStrictEqual(next) + }, + ) + + it('shares children without changing recursively frozen inputs', () => { + const prev = Object.freeze({ + items: Object.freeze([ + Object.freeze({ id: 1 }), + Object.freeze({ id: 2 }), + ]), + page: 1, + }) + const next = Object.freeze({ + items: Object.freeze([ + Object.freeze({ id: 1 }), + Object.freeze({ id: 3 }), + ]), + page: 2, + }) + const result = replaceEqualDeep(prev, next) + expect(result).toStrictEqual(next) + expect(result.items[0]).toBe(prev.items[0]) + expect(next.items[0]).not.toBe(prev.items[0]) + expect(prev.items[1]?.id).toBe(2) + expect(next.items[1]?.id).toBe(3) + }) + + it('preserves special numbers and explicit undefined in changed frozen arrays', () => { + const prev = Object.freeze([-0, NaN, Infinity, 2 ** 40, 1.5, undefined, 1]) + const next = Object.freeze([0, NaN, Infinity, 2 ** 40, 1.5, undefined, 2]) + const result = replaceEqualDeep(prev, next) + expect(result).toStrictEqual([ + -0, + NaN, + Infinity, + 2 ** 40, + 1.5, + undefined, + 2, + ]) + expect(hasOwn.call(result, 5)).toBe(true) + expect(next[0]).toBe(0) + expect(prev[6]).toBe(1) + }) + + it('does not consider separate NaN children strictly equal', () => { + const prev = { value: NaN } + const next = { value: NaN } + const result = replaceEqualDeep(prev, next) + expect(result).not.toBe(prev) + expect(result).toStrictEqual(next) + }) + + it.each([0, 1, 8, 16])( + 'resolves deeply equal children after %i identical array entries', + (length) => { + const prefix = Array.from({ length }, (_, index) => index) + const shared = { child: { value: 1 } } + const prev = [...prefix, shared, { version: 1 }] + const equal = [...prefix, { child: { value: 1 } }, { version: 1 }] + expect(replaceEqualDeep(prev, equal)).toBe(prev) + + const next = [...prefix, { child: { value: 1 } }, { version: 2 }] + const result = replaceEqualDeep(prev, next) + expect(result).toStrictEqual(next) + expect(result).not.toBe(prev) + expect(result).not.toBe(next) + expect(result[length]).toBe(shared) + }, + ) + + it('shares through depth 500 and stops at depth 501', () => { + const prev = { value: 1 } + const next = { value: 1 } + expect(replaceEqualDeep(prev, next, false, 500)).toBe(prev) + expect(replaceEqualDeep(prev, next, false, 501)).toBe(next) + expect(replaceEqualDeep(prev, prev, false, 501)).toBe(prev) + const result = replaceEqualDeep( + { child: prev }, + { child: next }, + false, + 500, + ) + expect(result.child).toBe(next) + }) + + it('keeps identical array entries at the depth limit but stops sharing copied children', () => { + const shared = { value: 1 } + const child = { value: 2 } + const nextChild = { value: 2 } + const prev = [shared, child] + const next = [shared, nextChild] + const result = replaceEqualDeep(prev, next, false, 500) + expect(result).not.toBe(prev) + expect(result[0]).toBe(shared) + expect(result[1]).toBe(nextChild) + expect(replaceEqualDeep(prev, next, false, 501)).toBe(next) + const identical = [...prev] + expect(replaceEqualDeep(prev, identical, false, 501)).toBe(identical) + }) + it('should return the same object if the input objects are equal', () => { const obj = { a: 1, b: 2 } const result = replaceEqualDeep(obj, obj) @@ -267,6 +408,13 @@ describe('replaceEqualDeep', () => { expect(replaceEqualDeep([1, undefined], next)).toBe(next) }) + it('does not reuse a sparse previous array with a compensating extra key', () => { + const prev = Object.assign([1, ,], { extra: 'old' }) + const next = [1, undefined] + expect(Object.keys(prev)).toHaveLength(prev.length) + expect(replaceEqualDeep(prev, next)).toBe(next) + }) + it('still shares dense arrays', () => { const prev = [1, 2, undefined] expect(replaceEqualDeep(prev, [1, 2, undefined])).toBe(prev) @@ -372,9 +520,7 @@ describe('replaceEqualDeep', () => { expect(replaceEqualDeep(current, next)).toEqual(next) }) - // The copy is only allocated at the first difference; everything compared - // before that point has to be carried over from `prev`. - describe('lazy copy', () => { + describe('sharing children in changed containers', () => { it('keeps earlier equal entries, including explicit undefined, when a later key differs', () => { const prev = { a: { x: 1 }, b: undefined, c: 1 } const next = { a: { x: 1 }, b: undefined, c: 2 } @@ -434,9 +580,20 @@ describe('replaceEqualDeep', () => { }) }) - // The scan stops at the first difference; everything after it is resolved by - // the copy loop, which must share and recurse exactly like the scan does. - describe('split-phase copy', () => { + describe('changed keys and recursive sharing', () => { + it('copies an empty array after a nonempty array', () => { + const prev = [1] + const result = replaceEqualDeep(prev, []) + expect(result).toStrictEqual([]) + expect(result).not.toBe(prev) + }) + + it('appends multiple explicit undefined array entries', () => { + const result = replaceEqualDeep([1], [1, undefined, undefined]) + expect(result).toStrictEqual([1, undefined, undefined]) + expect(hasOwn.call(result, 1)).toBe(true) + expect(hasOwn.call(result, 2)).toBe(true) + }) it('returns prev for equal objects whose keys are ordered differently', () => { const prev = { a: 1, b: { x: 1 }, c: 3 } const next = { c: 3, a: 1, b: { x: 1 } } @@ -562,6 +719,18 @@ describe('replaceEqualDeep', () => { }) describe('nullReplaceEqualDeep', () => { + it('keeps a shared array prefix and creates null-prototype copies for changed children', () => { + const shared = { id: 1 } + const prev = [shared, { id: 2 }] + const next = [shared, { id: 3 }] + const result = nullReplaceEqualDeep(prev, next) + expect(Array.isArray(result)).toBe(true) + expect(result[0]).toBe(shared) + expect(Object.getPrototypeOf(result[0])).toBe(Object.prototype) + expect(result[1]).toEqual({ id: 3 }) + expect(Object.getPrototypeOf(result[1])).toBeNull() + }) + it('creates null-prototype copies on the first difference', () => { const result = nullReplaceEqualDeep({ a: 1, b: 2 }, { a: 1, b: 3 }) expect(Object.getPrototypeOf(result)).toBeNull() @@ -725,6 +894,23 @@ describe('isPlainObject', () => { expect(deepEqual({ foo: prev }, { foo: prev })).toBe(true) }) + it.each([null, undefined, Object])( + 'keeps built-ins opaque with an own constructor valued %s', + (constructor) => { + for (const [prev, next] of [ + [new Date(0), new Date(1)], + [new Map([['value', 0]]), new Map([['value', 1]])], + [/before/, /after/], + ]) { + Object.assign(prev!, { constructor }) + Object.assign(next!, { constructor }) + expect(isPlainObject(prev)).toBe(false) + expect(deepEqual(prev, next)).toBe(false) + expect(replaceEqualDeep(prev, next)).toBe(next) + } + }, + ) + it('treats null-prototype and literal objects alike', () => { const nullProto = Object.assign(Object.create(null), { a: 1 }) expect(deepEqual(nullProto, { a: 1 })).toBe(true) @@ -1112,11 +1298,63 @@ describe('key handling (via replaceEqualDeep)', () => { }) it('does not share a symbol-keyed prev when next has no symbols', () => { - // Only `next` is checked for symbols; a former `next` returned as-is is the usual prev. const sym = Symbol('test') const prev = { a: 1, [sym]: 1 } const next = { a: 1 } - expect(replaceEqualDeep(prev, next)).toBe(prev) + expect(replaceEqualDeep(prev, next)).toBe(next) + }) + + it('removes a previous non-enumerable symbol', () => { + const sym = Symbol('hidden') + const prev = Object.defineProperty({ a: 1 }, sym, { value: 'old' }) + const next = { a: 1 } + expect(replaceEqualDeep(prev, next)).toBe(next) + }) + + it.each(['first', 'last'])( + 'removes nested symbols in the %s entry', + (position) => { + const sym = Symbol('metadata') + const removed = { a: 1, [sym]: 'old' } + const shared = { b: 2 } + const prev = + position === 'first' + ? { removed, shared, changed: 0 } + : { changed: 0, shared, removed } + const next = + position === 'first' + ? { removed: { a: 1 }, shared: { b: 2 }, changed: 1 } + : { changed: 1, shared: { b: 2 }, removed: { a: 1 } } + const result = replaceEqualDeep(prev, next) + expect(result).toStrictEqual(next) + expect(result.removed).toBe(next.removed) + expect(result.shared).toBe(shared) + }, + ) + + it('removes symbols when creating null-prototype copies', () => { + const sym = Symbol('metadata') + const prev = Object.assign(Object.create(null), { a: 1, [sym]: 'old' }) + const next = Object.assign(Object.create(null), { a: 1 }) + expect(nullReplaceEqualDeep(prev, next)).toBe(next) + }) + + it('removes symbols alongside changed string keys while sharing children', () => { + const sym = Symbol('metadata') + const prev = { changed: 0, child: { a: 1 }, [sym]: 'old' } + const next = { changed: 1, child: { a: 1 } } + const result = replaceEqualDeep(prev, next) + expect(result).toStrictEqual(next) + expect(result.child).toBe(prev.child) + }) + + it('removes symbols alongside removed string keys while sharing children', () => { + const sym = Symbol('metadata') + const prev = { removed: 0, child: { a: 1 }, [sym]: 'old' } + const next = { child: { a: 1 } } + const result = replaceEqualDeep(prev, next) + expect(result).toStrictEqual(next) + expect(result.child).toBe(prev.child) }) }) From 0df6c3010f7785e6d781df822bf920359c62953b Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 13 Sep 2026 22:43:30 +0200 Subject: [PATCH 2/3] perf(router-core): reuse unchanged incoming objects --- .changeset/poor-tires-tan.md | 2 +- packages/router-core/src/utils.ts | 185 +++++++++--------- .../tests/replace-equal-deep.bench.ts | 60 ++++++ packages/router-core/tests/utils.test.ts | 147 +++++++++++--- 4 files changed, 279 insertions(+), 115 deletions(-) diff --git a/.changeset/poor-tires-tan.md b/.changeset/poor-tires-tan.md index a3f9401871..37f32b0eb7 100644 --- a/.changeset/poor-tires-tan.md +++ b/.changeset/poor-tires-tan.md @@ -2,4 +2,4 @@ '@tanstack/router-core': patch --- -Reimplement `replaceEqualDeep` with temporary key lists for resolved children. Speed up equal arrays and shared array prefixes while retaining recursive sharing of changed children and consistent signed-zero behavior. Correctly remove previous symbol properties and keep sparse arrays with extra keys and built-in objects with an own `constructor` opaque. +Reduce structural-sharing allocations by reusing array key storage and returning incoming objects when their children need no replacements. Preserve signed zero consistently, remove stale symbol properties, and keep sparse arrays with extra keys and built-ins with an own `constructor` opaque. diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index f6f6bfe332..afc864c361 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -223,138 +223,141 @@ export const nullReplaceEqualDeep: typeof replaceEqualDeep = (prev, next) => replaceEqualDeep(prev, next, true) /** - * Returns `prev` when the supported values are deeply equal; otherwise copies - * `next` with its equal children shared from `prev`. - * This can be used for structural sharing between immutable JSON values for example. - * Own `__proto__` keys are unsupported when copying ordinary objects. - * Do not use this with signals + * Reuse equal children between immutable plain objects and dense arrays. + * Return `prev` for deeply equal values; otherwise return an object already + * containing the resolved children or create a copy with the shared children. + * Getters and own `__proto__` keys in ordinary object copies are unsupported. + * Do not use this with signals. */ export function replaceEqualDeep( prev: any, next: T, _nullProto?: boolean, + _depth?: number, +): T +export function replaceEqualDeep( + prev: any, + next: any, + _nullProto?: boolean, _depth = 0, -): T { +): any { if (isServer) { return next } if (prev === next) { return prev } - if (_depth > 500) { + + if (_depth++ > 500) { return next } - const value = next as any - const array = Array.isArray(value) - if ( - array ? !Array.isArray(prev) : !isPlainObject(prev) || !isPlainObject(value) - ) { + const array = Array.isArray(prev) && Array.isArray(next) + + if (!array && !(isPlainObject(prev) && isPlainObject(next))) { return next } - const previousKeys = Object.keys(prev) - const keys = Object.keys(value) - const count = keys.length - if (array) { - // Reject holes and extra enumerable string keys before using indices. - if ( - previousKeys.length !== prev.length || - count !== value.length || - (previousKeys.length > 0 && - previousKeys[previousKeys.length - 1] !== String(prev.length - 1)) || - (count > 0 && keys[count - 1] !== String(count - 1)) - ) { - return next - } - } else if ( - previousKeys.length !== Object.getOwnPropertyNames(prev).length || - count !== Object.getOwnPropertyNames(value).length || - Object.getOwnPropertySymbols(value).length > 0 + const prevKeys: Array = Object.keys(prev) + const previousCount = prevKeys.length + const nextKeys: Array = Object.keys(next) + const length = nextKeys.length + // Hidden object keys and symbols on next make the value opaque. Arrays + // must have only dense enumerable indices: a hole and an extra key can + // cancel out in the count, so also check the last key of both arrays. + if ( + array + ? previousCount !== prev.length || + length !== next.length || + (previousCount && last(prevKeys) !== `${previousCount - 1}`) || + (length && last(nextKeys) !== `${length - 1}`) + : previousCount !== Object.getOwnPropertyNames(prev).length || + length !== Object.getOwnPropertyNames(next).length || + Object.getOwnPropertySymbols(next).length ) { return next } - let equal = previousKeys.length === count - // The key lists are private scratch space. Arrays reuse next's exact-size - // list as their result; objects keep next's keys and overwrite prev's list - // with resolved children after each ownership check. - const children: Array = array ? keys : previousKeys + let i = 0 + let child: any + let previous: any + let key: any + if (array) { - // An identical array needs no child resolution or buffer writes. If an - // entry differs, copy the known-identical prefix directly from prev. - let start = 0 - if (equal) { - while (start < count && prev[start] === value[start]) { - start++ - } - if (start === count) { - return prev + // Entries before the first difference already resolve to prev. + for (; i < length; i++) { + key = i + previous = prev[key] + child = next[key] + child = + previous === child + ? previous + : typeof previous === 'object' + ? replaceEqualDeep(previous, child, _nullProto, _depth) + : child + if (child !== previous) { + break } } - // Filling lets numeric results use packed numeric storage in V8. - if (count > 0 && typeof value[0] === 'number') { - children.fill(0) - } - for (let index = 0; index < start; index++) { - children[index] = prev[index] + if (i === length && previousCount === length) { + return prev } - for (let index = start; index < count; index++) { - const previous = prev[index] - const incoming = value[index] - const child = + } else { + let equal = previousCount === length + let unchanged = true + for (; i < length; i++) { + key = nextKeys[i]! + previous = prev[key] + const incoming = next[key] + child = previous === incoming ? previous : typeof previous === 'object' - ? replaceEqualDeep(previous, incoming, _nullProto, _depth + 1) + ? replaceEqualDeep(previous, incoming, _nullProto, _depth) : incoming - children[index] = child - if (child !== previous) { - equal = false - } + equal &&= + child === previous && (prevKeys[i] === key || hasOwn.call(prev, key)) + unchanged &&= Object.is(child, incoming) + // This key has been checked; its private slot can hold the result. + prevKeys[i] = child + } + if (equal) { + return Object.getOwnPropertySymbols(prev).length ? next : prev + } + if (unchanged && (!_nullProto || !Object.getPrototypeOf(next))) { + return next } - return (equal ? prev : children) as T } - for (let index = 0; index < count; index++) { - const key = keys[index]! - const previous = prev[key] - const incoming = value[key] - const child = - previous === incoming - ? previous - : typeof previous === 'object' - ? replaceEqualDeep(previous, incoming, _nullProto, _depth + 1) - : incoming - if ( - equal && - (child !== previous || - (previousKeys[index] !== key && !hasOwn.call(prev, key))) - ) { - equal = false + // Reuse the validated array key list; it is private and has the right length. + const copy: any = array ? nextKeys.fill(0) : _nullProto ? createNull() : {} + for (let j = 0; j < length; j++) { + key = array ? j : nextKeys[j]! + if (array) { + previous = prev[key] + if (j > i) { + child = next[key] + child = + previous === child + ? previous + : typeof previous === 'object' + ? replaceEqualDeep(previous, child, _nullProto, _depth) + : child + } + copy[key] = j < i ? previous : child + } else { + copy[key] = prevKeys[j] } - children[index] = child - } - if (equal) { - return Object.getOwnPropertySymbols(prev).length ? next : prev - } - const result = _nullProto ? Object.create(null) : {} - for (let index = 0; index < count; index++) { - result[keys[index]!] = children[index] } - return result + return copy } export function isPlainObject(o: unknown): boolean { if (!o || typeof o !== 'object') { return false } - // An own constructor is data and cannot identify the object's prototype. - if (o.constructor === Object && !hasOwn.call(o, 'constructor')) { - return true - } - const proto = Object.getPrototypeOf(o) - return proto === null || proto.constructor === Object + // An own constructor is data, so classify the actual prototype. + return (Object.getPrototypeOf(o)?.constructor ?? Object) === Object } /** diff --git a/packages/router-core/tests/replace-equal-deep.bench.ts b/packages/router-core/tests/replace-equal-deep.bench.ts index 97c47493d3..192ca80b0a 100644 --- a/packages/router-core/tests/replace-equal-deep.bench.ts +++ b/packages/router-core/tests/replace-equal-deep.bench.ts @@ -13,6 +13,14 @@ const emptyNull = createNull() const search = { tab: 'specs', page: 2, sort: 'newest', filter: 'available' } const searchCopy = { ...search } const searchChanged = { ...search, page: 3 } +// parseSearch and path extraction produce null-prototype records. Exercise +// these separately from ordinary selector objects: their property storage differs. +const nullSearch = Object.assign(createNull(), search) +const nullSearchCopy = Object.assign(createNull(), searchCopy) +const nullSearchChanged = Object.assign(createNull(), searchChanged) +const decimalRecord = { x: 0.5, y: 1.5, width: 640.5, height: 480.5 } +const decimalRecordCopy = { ...decimalRecord } +const decimalRecordChanged = { ...decimalRecord, x: 1.5 } // `?constructor=foo` decodes to an own `constructor` key on a null-proto record. const searchWithConstructorKey = Object.assign(createNull(), { constructor: 'foo', @@ -123,6 +131,15 @@ const mixedPairs: Array<[unknown, unknown]> = [ expect(replaceEqualDeep(empty, {})).toBe(empty) expect(replaceEqualDeep(search, searchCopy)).toBe(search) expect(replaceEqualDeep(search, searchChanged)).toStrictEqual(searchChanged) +expect(nullReplaceEqualDeep(nullSearch, nullSearchCopy)).toBe(nullSearch) +expect(nullReplaceEqualDeep(nullSearch, searchCopy)).toBe(nullSearch) +expect(replaceEqualDeep(decimalRecord, decimalRecordCopy)).toBe(decimalRecord) +expect(replaceEqualDeep(decimalRecord, decimalRecordChanged)).toStrictEqual( + decimalRecordChanged, +) +expect(nullReplaceEqualDeep(nullSearch, nullSearchChanged)).toStrictEqual( + nullSearchChanged, +) expect(replaceEqualDeep(nested, nestedCopy)).toBe(nested) expect(replaceEqualDeep(nested, nestedLeafChanged).ids).toBe(nested.ids) expect(replaceEqualDeep(list, listCopy)).toBe(list) @@ -237,6 +254,49 @@ describe('replaceEqualDeep', () => { } }) + bench('equal parsed null-proto search', () => { + for (let i = 0; i < iterations; i++) { + sink = nullReplaceEqualDeep(nullSearch, nullSearchCopy) + } + }) + + bench('equal ordinary search against a null-proto previous value', () => { + for (let i = 0; i < iterations; i++) { + sink = nullReplaceEqualDeep(nullSearch, searchCopy) + } + }) + + bench('parsed null-proto search with one changed leaf', () => { + for (let i = 0; i < iterations; i++) { + sink = nullReplaceEqualDeep(nullSearch, nullSearchChanged) + } + }) + + bench('search update requiring a null-proto copy', () => { + for (let i = 0; i < iterations; i++) { + sink = nullReplaceEqualDeep(nullSearch, searchChanged) + } + }) + + bench('changed selector followed by the same incoming reference', () => { + for (let i = 0; i < iterations; i++) { + const current = replaceEqualDeep(search, searchChanged) + sink = replaceEqualDeep(current, searchChanged) + } + }) + + bench('equal record with decimal values', () => { + for (let i = 0; i < iterations; i++) { + sink = replaceEqualDeep(decimalRecord, decimalRecordCopy) + } + }) + + bench('changed record with decimal values', () => { + for (let i = 0; i < iterations; i++) { + sink = replaceEqualDeep(decimalRecord, decimalRecordChanged) + } + }) + bench('equal nested search', () => { for (let i = 0; i < iterations; i++) { sink = replaceEqualDeep(nested, nestedCopy) diff --git a/packages/router-core/tests/utils.test.ts b/packages/router-core/tests/utils.test.ts index 69df1458ed..4246a47b62 100644 --- a/packages/router-core/tests/utils.test.ts +++ b/packages/router-core/tests/utils.test.ts @@ -13,6 +13,31 @@ import { import { decode } from '../src/qss' describe('replaceEqualDeep', () => { + it('reuses the next object when its children need no replacements', () => { + const shared = Object.freeze({ id: 1 }) + const prev = Object.freeze({ shared, page: 1 }) + const next = Object.freeze({ shared, page: 2 }) + + expect(replaceEqualDeep(prev, next)).toBe(next) + }) + + it('reuses a changed null-prototype search record', () => { + const prev = decode('page=1&sort=newest') + const next = decode('page=2&sort=newest') + + expect(nullReplaceEqualDeep(prev, next)).toBe(next) + }) + + it('copies a changed ordinary object when null-prototype output is required', () => { + const prev = decode('page=1&sort=newest') + const next = { page: '2', sort: 'newest' } + const result = nullReplaceEqualDeep(prev, next) + + expect(result).not.toBe(next) + expect(Object.getPrototypeOf(result)).toBeNull() + expect({ ...result }).toStrictEqual(next) + }) + it('returns the previous signed zero when strict equality matches', () => { expect(replaceEqualDeep(-0, 0)).toBe(-0) expect(replaceEqualDeep(0, -0)).toBe(0) @@ -29,21 +54,18 @@ describe('replaceEqualDeep', () => { }) // Known unsupported edge case for ordinary object copies. - it.fails( - 'copies an own __proto__ property as data in the incoming key order', - () => { - const prev = JSON.parse('{"first":1,"__proto__":{"shared":1},"last":1}') - const next = JSON.parse('{"first":2,"__proto__":{"shared":1},"last":1}') - const result = replaceEqualDeep(prev, next) - expect(Object.getPrototypeOf(result)).toBe(Object.prototype) - expect(hasOwn.call(result, '__proto__')).toBe(true) - expect(result.__proto__).toBe(prev.__proto__) - expect(Object.keys(result)).toEqual(Object.keys(next)) - expect(result).toStrictEqual(next) - }, - ) + it.skip('copies an own __proto__ property as data in the incoming key order', () => { + const prev = JSON.parse('{"first":1,"__proto__":{"shared":1},"last":1}') + const next = JSON.parse('{"first":2,"__proto__":{"shared":1},"last":1}') + const result = replaceEqualDeep(prev, next) + expect(Object.getPrototypeOf(result)).toBe(Object.prototype) + expect(hasOwn.call(result, '__proto__')).toBe(true) + expect(result.__proto__).toBe(prev.__proto__) + expect(Object.keys(result)).toEqual(Object.keys(next)) + expect(result).toStrictEqual(next) + }) - it.fails.each([undefined, { shared: 1 }])( + it.skip.each([undefined, { shared: 1 }])( 'preserves a newly added own __proto__ property with value %j', (value) => { const prev = { first: 1, last: 1 } @@ -98,6 +120,45 @@ describe('replaceEqualDeep', () => { expect(prev[6]).toBe(1) }) + it('ignores auxiliary array metadata without using its constructor or iterator', () => { + const fail = () => { + throw new Error('array metadata must not be invoked') + } + const decorate = (values: Array) => + Object.freeze( + Object.defineProperties(values, { + metadata: { value: 'ignored' }, + constructor: { value: { [Symbol.species]: fail } }, + [Symbol.iterator]: { value: fail }, + }), + ) + const child = Object.freeze({ id: 1 }) + const prev = decorate([child, 1]) + const equal = decorate([{ id: 1 }, 1]) + const next = decorate([{ id: 1 }, 2]) + + expect(replaceEqualDeep(prev, equal)).toBe(prev) + const result = replaceEqualDeep(prev, next) + expect(result).toStrictEqual([child, 2]) + expect(result[0]).toBe(child) + expect(Object.keys(result)).toEqual(['0', '1']) + expect(Object.getOwnPropertySymbols(result)).toEqual([]) + expect(prev[1]).toBe(1) + expect(next[0]).not.toBe(child) + }) + + it.each(['prev', 'next'])( + 'keeps an array with a non-enumerable index on %s opaque', + (side) => { + const prev = [{ id: 1 }, 1] + const next = [{ id: 1 }, 2] + Object.defineProperty(side === 'prev' ? prev : next, '1', { + enumerable: false, + }) + expect(replaceEqualDeep(prev, next)).toBe(next) + }, + ) + it('does not consider separate NaN children strictly equal', () => { const prev = { value: NaN } const next = { value: NaN } @@ -279,7 +340,6 @@ describe('replaceEqualDeep', () => { const result = replaceEqualDeep(prev, next) expect(result).toEqual(next) expect(result).not.toBe(prev) - expect(result).not.toBe(next) }) it('should return a copy when the previous value is a different array superset', () => { @@ -288,7 +348,6 @@ describe('replaceEqualDeep', () => { const result = replaceEqualDeep(prev, next) expect(result).toEqual(next) expect(result).not.toBe(prev) - expect(result).not.toBe(next) }) it('should return the previous value when the next value is an equal empty array', () => { @@ -329,7 +388,6 @@ describe('replaceEqualDeep', () => { expect(result).not.toBe(next) expect(result[0]).toBe(prev[0]) expect(result[1]).toBe(prev[1]) - expect(result[2]).not.toBe(next[2]) expect(result[2].b.b).toBe(next[2].b.b) expect(result[3]).toBe(prev[3]) }) @@ -443,7 +501,6 @@ describe('replaceEqualDeep', () => { expect(result.todo).not.toBe(next.todo) expect(result.todo.id).toBe(next.todo.id) expect(result.todo.meta).toBe(prev.todo.meta) - expect(result.todo.state).not.toBe(next.todo.state) expect(result.todo.state.done).toBe(next.todo.state.done) expect(result.otherTodo).toBe(prev.otherTodo) }) @@ -471,7 +528,6 @@ describe('replaceEqualDeep', () => { expect(result.todos[0]).not.toBe(next.todos[0]) expect(result.todos[0]?.id).toBe(next.todos[0]?.id) expect(result.todos[0]?.meta).toBe(prev.todos[0]?.meta) - expect(result.todos[0]?.state).not.toBe(next.todos[0]?.state) expect(result.todos[0]?.state.done).toBe(next.todos[0]?.state.done) expect(result.todos[1]).toBe(prev.todos[1]) }) @@ -556,7 +612,6 @@ describe('replaceEqualDeep', () => { const result = replaceEqualDeep(prev, next) expect(result).toStrictEqual(next) expect(result).not.toBe(prev) - expect(result).not.toBe(next) }) it('returns a longer array when next appends an explicit undefined', () => { @@ -566,7 +621,6 @@ describe('replaceEqualDeep', () => { expect(result).toStrictEqual(next) expect(result).toHaveLength(2) expect(result).not.toBe(prev) - expect(result).not.toBe(next) }) it('shares equal array entries before and after the first difference', () => { @@ -606,7 +660,6 @@ describe('replaceEqualDeep', () => { const result = replaceEqualDeep(prev, next) expect(result).toStrictEqual(next) expect(result).not.toBe(prev) - expect(result).not.toBe(next) expect('c' in result).toBe(true) expect('a' in result).toBe(false) }) @@ -773,6 +826,36 @@ describe('nullReplaceEqualDeep', () => { }) describe('isPlainObject', () => { + it.each([ + [ + 'null-prototype record', + Object.assign(Object.create(null), { inherited: 1 }), + ], + ['null constructor', { constructor: null }], + ['undefined constructor', { constructor: undefined }], + ])('shares records inheriting from a %s', (_name, proto) => { + const prev = Object.assign(Object.create(proto), { + child: { id: 1 }, + page: 1, + }) + const equal = Object.assign(Object.create(proto), { + child: { id: 1 }, + page: 1, + }) + const next = Object.assign(Object.create(proto), { + child: { id: 1 }, + page: 2, + }) + + expect(isPlainObject(prev)).toBe(true) + expect(deepEqual(prev, equal)).toBe(true) + expect(replaceEqualDeep(prev, equal)).toBe(prev) + const result = replaceEqualDeep(prev, next) + expect(result.child).toBe(prev.child) + expect(result.page).toBe(2) + expect(Object.keys(result)).toEqual(['child', 'page']) + }) + it.each([ ['object literal', {}], ['object literal with keys', { a: 1 }], @@ -1332,13 +1415,31 @@ describe('key handling (via replaceEqualDeep)', () => { }, ) - it('removes symbols when creating null-prototype copies', () => { + it('removes symbols by reusing a null-prototype next object', () => { const sym = Symbol('metadata') const prev = Object.assign(Object.create(null), { a: 1, [sym]: 'old' }) const next = Object.assign(Object.create(null), { a: 1 }) expect(nullReplaceEqualDeep(prev, next)).toBe(next) }) + it('removes symbols from a null-prototype copy while sharing children', () => { + const sym = Symbol('metadata') + const prev = { child: { a: 1 }, page: 1, [sym]: 'old' } + const next = { child: { a: 1 }, page: 2 } + const result = nullReplaceEqualDeep(prev, next) + expect(Object.getPrototypeOf(result)).toBeNull() + expect(Object.getOwnPropertySymbols(result)).toEqual([]) + expect(result.child).toBe(prev.child) + expect(result.page).toBe(2) + }) + + it('keeps an opaque next object unchanged in null-prototype mode', () => { + const next = { child: { a: 1 }, [Symbol('metadata')]: 'kept' } + const result = nullReplaceEqualDeep({ child: { a: 1 } }, next) + expect(result).toBe(next) + expect(Object.getPrototypeOf(result)).toBe(Object.prototype) + }) + it('removes symbols alongside changed string keys while sharing children', () => { const sym = Symbol('metadata') const prev = { changed: 0, child: { a: 1 }, [sym]: 'old' } From 935bfb35adec1d4cb18112d12b100c02345a7921 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 14 Sep 2026 00:19:18 +0200 Subject: [PATCH 3/3] perf(router-core): reuse existing search objects --- .changeset/poor-tires-tan.md | 2 + packages/router-core/src/utils.ts | 2 +- .../tests/replace-equal-deep.bench.ts | 24 ++- packages/router-core/tests/utils.test.ts | 161 +++++++++++------- 4 files changed, 123 insertions(+), 66 deletions(-) diff --git a/.changeset/poor-tires-tan.md b/.changeset/poor-tires-tan.md index 37f32b0eb7..f230c6a400 100644 --- a/.changeset/poor-tires-tan.md +++ b/.changeset/poor-tires-tan.md @@ -3,3 +3,5 @@ --- Reduce structural-sharing allocations by reusing array key storage and returning incoming objects when their children need no replacements. Preserve signed zero consistently, remove stale symbol properties, and keep sparse arrays with extra keys and built-ins with an own `constructor` opaque. + +Null-prototype mode now applies only when constructing a copy; existing incoming objects can be reused with their original prototype. diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index afc864c361..506e65cce2 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -324,7 +324,7 @@ export function replaceEqualDeep( if (equal) { return Object.getOwnPropertySymbols(prev).length ? next : prev } - if (unchanged && (!_nullProto || !Object.getPrototypeOf(next))) { + if (unchanged) { return next } } diff --git a/packages/router-core/tests/replace-equal-deep.bench.ts b/packages/router-core/tests/replace-equal-deep.bench.ts index 192ca80b0a..bfcf65feff 100644 --- a/packages/router-core/tests/replace-equal-deep.bench.ts +++ b/packages/router-core/tests/replace-equal-deep.bench.ts @@ -142,6 +142,10 @@ expect(nullReplaceEqualDeep(nullSearch, nullSearchChanged)).toStrictEqual( ) expect(replaceEqualDeep(nested, nestedCopy)).toBe(nested) expect(replaceEqualDeep(nested, nestedLeafChanged).ids).toBe(nested.ids) +const nullNestedResult = nullReplaceEqualDeep(nested, nestedLeafChanged) +expect(nullNestedResult).toEqual(nestedLeafChanged) +expect(nullNestedResult.ids).toBe(nested.ids) +expect(Object.getPrototypeOf(nullNestedResult)).toBeNull() expect(replaceEqualDeep(list, listCopy)).toBe(list) expect(replaceEqualDeep(list, listChanged)[3]).toBe(list[3]) expect(replaceEqualDeep(wide, wideChanged)).toStrictEqual(wideChanged) @@ -272,12 +276,24 @@ describe('replaceEqualDeep', () => { } }) - bench('search update requiring a null-proto copy', () => { + bench('ordinary search update in null-proto mode', () => { for (let i = 0; i < iterations; i++) { sink = nullReplaceEqualDeep(nullSearch, searchChanged) } }) + bench('equal ordinary search in null-proto mode', () => { + for (let i = 0; i < iterations; i++) { + sink = nullReplaceEqualDeep(search, searchCopy) + } + }) + + bench('successive ordinary search update in null-proto mode', () => { + for (let i = 0; i < iterations; i++) { + sink = nullReplaceEqualDeep(search, searchChanged) + } + }) + bench('changed selector followed by the same incoming reference', () => { for (let i = 0; i < iterations; i++) { const current = replaceEqualDeep(search, searchChanged) @@ -309,6 +325,12 @@ describe('replaceEqualDeep', () => { } }) + bench('nested search sharing unchanged subtrees in null-proto mode', () => { + for (let i = 0; i < iterations; i++) { + sink = nullReplaceEqualDeep(nested, nestedLeafChanged) + } + }) + bench('equal array of objects', () => { for (let i = 0; i < iterations; i++) { sink = replaceEqualDeep(list, listCopy) diff --git a/packages/router-core/tests/utils.test.ts b/packages/router-core/tests/utils.test.ts index 4246a47b62..78db619913 100644 --- a/packages/router-core/tests/utils.test.ts +++ b/packages/router-core/tests/utils.test.ts @@ -28,14 +28,13 @@ describe('replaceEqualDeep', () => { expect(nullReplaceEqualDeep(prev, next)).toBe(next) }) - it('copies a changed ordinary object when null-prototype output is required', () => { + it('reuses a changed ordinary object in null-prototype mode', () => { const prev = decode('page=1&sort=newest') const next = { page: '2', sort: 'newest' } const result = nullReplaceEqualDeep(prev, next) - expect(result).not.toBe(next) - expect(Object.getPrototypeOf(result)).toBeNull() - expect({ ...result }).toStrictEqual(next) + expect(result).toBe(next) + expect(Object.getPrototypeOf(result)).toBe(Object.prototype) }) it('returns the previous signed zero when strict equality matches', () => { @@ -43,15 +42,24 @@ describe('replaceEqualDeep', () => { expect(replaceEqualDeep(0, -0)).toBe(0) }) - it('uses the same signed-zero sharing rule for every child', () => { - const prev = { first: -0, changed: 1, last: -0 } - const next = { first: 0, changed: 2, last: 0 } - const result = replaceEqualDeep(prev, next) - expect(result.first).toBe(-0) - expect(result.changed).toBe(2) - expect(result.last).toBe(-0) - expect(replaceEqualDeep([-0, 1, -0], [0, 2, 0])).toStrictEqual([-0, 2, -0]) - }) + it.each([false, true])( + 'uses the same signed-zero sharing rule for every child (nullProto=%s)', + (nullProto) => { + const prev = { first: -0, changed: 1, last: -0 } + const next = { first: 0, changed: 2, last: 0 } + const result = replaceEqualDeep(prev, next, nullProto) + expect(result).not.toBe(next) + expect(Object.getPrototypeOf(result)).toBe( + nullProto ? null : Object.prototype, + ) + expect(result.first).toBe(-0) + expect(result.changed).toBe(2) + expect(result.last).toBe(-0) + expect(replaceEqualDeep([-0, 1, -0], [0, 2, 0], nullProto)).toStrictEqual( + [-0, 2, -0], + ) + }, + ) // Known unsupported edge case for ordinary object copies. it.skip('copies an own __proto__ property as data in the incoming key order', () => { @@ -148,14 +156,18 @@ describe('replaceEqualDeep', () => { }) it.each(['prev', 'next'])( - 'keeps an array with a non-enumerable index on %s opaque', + 'preserves incoming values with a non-enumerable array index on %s', (side) => { const prev = [{ id: 1 }, 1] const next = [{ id: 1 }, 2] Object.defineProperty(side === 'prev' ? prev : next, '1', { enumerable: false, }) - expect(replaceEqualDeep(prev, next)).toBe(next) + const result = replaceEqualDeep(prev, next) + expect(result).toStrictEqual(next) + expect(Object.getOwnPropertyDescriptor(result, '1')?.enumerable).toBe( + Object.getOwnPropertyDescriptor(next, '1')?.enumerable, + ) }, ) @@ -185,34 +197,40 @@ describe('replaceEqualDeep', () => { }, ) - it('shares through depth 500 and stops at depth 501', () => { - const prev = { value: 1 } - const next = { value: 1 } - expect(replaceEqualDeep(prev, next, false, 500)).toBe(prev) - expect(replaceEqualDeep(prev, next, false, 501)).toBe(next) - expect(replaceEqualDeep(prev, prev, false, 501)).toBe(prev) - const result = replaceEqualDeep( - { child: prev }, - { child: next }, - false, - 500, - ) - expect(result.child).toBe(next) + it('bounds recursion for cyclic records', () => { + const prev: any = { value: 1 } + const next: any = { value: 2 } + prev.self = prev + next.self = next + const result = replaceEqualDeep(prev, next) + expect(result.value).toBe(2) + expect(result.self).toBe(next) }) - it('keeps identical array entries at the depth limit but stops sharing copied children', () => { - const shared = { value: 1 } - const child = { value: 2 } - const nextChild = { value: 2 } - const prev = [shared, child] - const next = [shared, nextChild] - const result = replaceEqualDeep(prev, next, false, 500) - expect(result).not.toBe(prev) - expect(result[0]).toBe(shared) - expect(result[1]).toBe(nextChild) - expect(replaceEqualDeep(prev, next, false, 501)).toBe(next) - const identical = [...prev] - expect(replaceEqualDeep(prev, identical, false, 501)).toBe(identical) + it.each([0, 1])( + 'bounds recursion for cyclic arrays with %s preceding values', + (index) => { + const prev: Array = index ? [1] : [] + const next: Array = index ? [2] : [] + prev.push(prev) + next.push(next) + const result = replaceEqualDeep(prev, next) + let tail = result + for (let i = 0; i < 1000 && tail !== next; i++) { + tail = tail[index] + } + expect(tail).toBe(next) + if (index) { + expect(result[0]).toBe(2) + } + expect(prev[index]).toBe(prev) + expect(next[index]).toBe(next) + }, + ) + + it('preserves identity even when the recursion budget is exhausted', () => { + const value = [{ id: 1 }] + expect(replaceEqualDeep(value, value, false, 10_000)).toBe(value) }) it('should return the same object if the input objects are equal', () => { @@ -772,7 +790,7 @@ describe('replaceEqualDeep', () => { }) describe('nullReplaceEqualDeep', () => { - it('keeps a shared array prefix and creates null-prototype copies for changed children', () => { + it('keeps a shared array prefix and reuses unchanged child results', () => { const shared = { id: 1 } const prev = [shared, { id: 2 }] const next = [shared, { id: 3 }] @@ -780,38 +798,54 @@ describe('nullReplaceEqualDeep', () => { expect(Array.isArray(result)).toBe(true) expect(result[0]).toBe(shared) expect(Object.getPrototypeOf(result[0])).toBe(Object.prototype) - expect(result[1]).toEqual({ id: 3 }) - expect(Object.getPrototypeOf(result[1])).toBeNull() + expect(result[1]).toBe(next[1]) + expect(Object.getPrototypeOf(result[1])).toBe(Object.prototype) }) it('creates null-prototype copies on the first difference', () => { - const result = nullReplaceEqualDeep({ a: 1, b: 2 }, { a: 1, b: 3 }) + const prev = { a: { x: 1 }, b: 2 } + const result = nullReplaceEqualDeep(prev, { a: { x: 1 }, b: 3 }) expect(Object.getPrototypeOf(result)).toBeNull() - expect(result.a).toBe(1) + expect(result.a).toBe(prev.a) expect(result.b).toBe(3) }) it('creates null-prototype copies when only the key sets differ', () => { - const result = nullReplaceEqualDeep({ a: 1 }, { a: 1, b: undefined }) + const prev = { a: { x: 1 } } + const result = nullReplaceEqualDeep(prev, { a: { x: 1 }, b: undefined }) expect(Object.getPrototypeOf(result)).toBeNull() - expect(result.a).toBe(1) + expect(result.a).toBe(prev.a) expect('b' in result).toBe(true) }) - it('creates null-prototype copies for nested objects', () => { + it('reuses a changed child when only the root needs a null-prototype copy', () => { const prev = { shared: { x: 1 }, changed: { y: 1 } } - const result = nullReplaceEqualDeep(prev, { - shared: { x: 1 }, - changed: { y: 2 }, - }) + const next = { shared: { x: 1 }, changed: { y: 2 } } + const result = nullReplaceEqualDeep(prev, next) expect(Object.getPrototypeOf(result)).toBeNull() - expect(Object.getPrototypeOf(result.changed)).toBeNull() + expect(result.changed).toBe(next.changed) expect(result.shared).toBe(prev.shared) }) + it('creates null-prototype copies at every level that needs replacements', () => { + const prev = { outer: { shared: { id: 1 }, page: 1 } } + const next = { outer: { shared: { id: 1 }, page: 2 } } + const result = nullReplaceEqualDeep(prev, next) + expect(Object.getPrototypeOf(result)).toBeNull() + expect(Object.getPrototypeOf(result.outer)).toBeNull() + expect(result.outer.shared).toBe(prev.outer.shared) + expect(result.outer.page).toBe(2) + expect(prev).toStrictEqual({ outer: { shared: { id: 1 }, page: 1 } }) + expect(next).toStrictEqual({ outer: { shared: { id: 1 }, page: 2 } }) + }) + it('stores an own __proto__ key instead of changing the prototype', () => { - const next = JSON.parse('{"__proto__":{"isAdmin":true},"name":"Alice"}') - const result = nullReplaceEqualDeep({ name: 'Bob' }, next) + const prev = { name: 'Bob', shared: { id: 1 } } + const next = JSON.parse( + '{"__proto__":{"isAdmin":true},"name":"Alice","shared":{"id":1}}', + ) + const result = nullReplaceEqualDeep(prev, next) + expect(result.shared).toBe(prev.shared) expect(Object.getPrototypeOf(result)).toBeNull() expect(hasOwn.call(result, '__proto__')).toBe(true) expect(result['__proto__']).toEqual({ isAdmin: true }) @@ -977,7 +1011,7 @@ describe('isPlainObject', () => { expect(deepEqual({ foo: prev }, { foo: prev })).toBe(true) }) - it.each([null, undefined, Object])( + it.each([null, undefined])( 'keeps built-ins opaque with an own constructor valued %s', (constructor) => { for (const [prev, next] of [ @@ -1422,14 +1456,13 @@ describe('key handling (via replaceEqualDeep)', () => { expect(nullReplaceEqualDeep(prev, next)).toBe(next) }) - it('removes symbols from a null-prototype copy while sharing children', () => { + it('preserves incoming values when previous symbols exist in null-prototype mode', () => { const sym = Symbol('metadata') const prev = { child: { a: 1 }, page: 1, [sym]: 'old' } const next = { child: { a: 1 }, page: 2 } const result = nullReplaceEqualDeep(prev, next) - expect(Object.getPrototypeOf(result)).toBeNull() + expect({ ...result }).toStrictEqual(next) expect(Object.getOwnPropertySymbols(result)).toEqual([]) - expect(result.child).toBe(prev.child) expect(result.page).toBe(2) }) @@ -1440,22 +1473,22 @@ describe('key handling (via replaceEqualDeep)', () => { expect(Object.getPrototypeOf(result)).toBe(Object.prototype) }) - it('removes symbols alongside changed string keys while sharing children', () => { + it('preserves incoming values when previous symbols accompany changed string keys', () => { const sym = Symbol('metadata') const prev = { changed: 0, child: { a: 1 }, [sym]: 'old' } const next = { changed: 1, child: { a: 1 } } const result = replaceEqualDeep(prev, next) expect(result).toStrictEqual(next) - expect(result.child).toBe(prev.child) + expect(prev[sym]).toBe('old') }) - it('removes symbols alongside removed string keys while sharing children', () => { + it('preserves incoming values when previous symbols accompany removed string keys', () => { const sym = Symbol('metadata') const prev = { removed: 0, child: { a: 1 }, [sym]: 'old' } const next = { child: { a: 1 } } const result = replaceEqualDeep(prev, next) expect(result).toStrictEqual(next) - expect(result.child).toBe(prev.child) + expect(prev[sym]).toBe('old') }) })