diff --git a/.changeset/poor-tires-tan.md b/.changeset/poor-tires-tan.md new file mode 100644 index 0000000000..f230c6a400 --- /dev/null +++ b/.changeset/poor-tires-tan.md @@ -0,0 +1,7 @@ +--- +'@tanstack/router-core': patch +--- + +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 bf28ff6c67..506e65cce2 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -223,10 +223,11 @@ 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`. - * This can be used for structural sharing between immutable JSON values for example. - * 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, @@ -247,28 +248,30 @@ export function replaceEqualDeep( 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 + if (!array && !(isPlainObject(prev) && isPlainObject(next))) { + return next + } - const prevKeys = Object.keys(prev) - const nextKeys = Object.keys(next) + const prevKeys: Array = Object.keys(prev) + const previousCount = prevKeys.length + const nextKeys: Array = 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.) + // 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 - ? prevKeys.length !== prev.length || + ? previousCount !== prev.length || length !== next.length || - (length && nextKeys[length - 1] !== `${length - 1}`) - : prevKeys.length !== Object.getOwnPropertyNames(prev).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 ) { @@ -276,57 +279,75 @@ export function replaceEqualDeep( } let i = 0 - let n: any - - // 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 child: any + let previous: any + let key: any + if (array) { + // Entries before the first difference already resolve to prev. for (; i < length; i++) { - const p = prev[i] - n = next[i] - if (p !== n && typeof p === 'object') { - n = replaceEqualDeep(p, n, _nullProto, _depth + 1) + 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 } - if (n !== p) break + } + if (i === length && previousCount === length) { + return prev } } else { + let equal = previousCount === length + let unchanged = true 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) - } - // 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 - } + key = nextKeys[i]! + previous = prev[key] + const incoming = next[key] + child = + previous === incoming + ? previous + : typeof previous === 'object' + ? replaceEqualDeep(previous, incoming, _nullProto, _depth) + : incoming + 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) { + return next } } - // 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) : {} + // 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++) { - 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) + 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] } - copy[key] = j < i ? p : n } return copy } @@ -335,14 +356,8 @@ 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) { - 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 + // 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 d6eac3279b..bfcf65feff 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', @@ -68,15 +76,76 @@ 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) 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) +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) @@ -90,7 +159,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), @@ -164,6 +258,61 @@ 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('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) + 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) @@ -176,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) @@ -230,6 +385,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 +442,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..78db619913 100644 --- a/packages/router-core/tests/utils.test.ts +++ b/packages/router-core/tests/utils.test.ts @@ -13,6 +13,226 @@ 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('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).toBe(next) + expect(Object.getPrototypeOf(result)).toBe(Object.prototype) + }) + + it('returns the previous signed zero when strict equality matches', () => { + expect(replaceEqualDeep(-0, 0)).toBe(-0) + expect(replaceEqualDeep(0, -0)).toBe(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', () => { + 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.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('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'])( + '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, + }) + const result = replaceEqualDeep(prev, next) + expect(result).toStrictEqual(next) + expect(Object.getOwnPropertyDescriptor(result, '1')?.enumerable).toBe( + Object.getOwnPropertyDescriptor(next, '1')?.enumerable, + ) + }, + ) + + 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('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.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', () => { const obj = { a: 1, b: 2 } const result = replaceEqualDeep(obj, obj) @@ -138,7 +358,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', () => { @@ -147,7 +366,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', () => { @@ -188,7 +406,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]) }) @@ -267,6 +484,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) @@ -295,7 +519,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) }) @@ -323,7 +546,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]) }) @@ -372,9 +594,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 } @@ -410,7 +630,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', () => { @@ -420,7 +639,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', () => { @@ -434,9 +652,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 } } @@ -449,7 +678,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) }) @@ -562,34 +790,62 @@ describe('replaceEqualDeep', () => { }) describe('nullReplaceEqualDeep', () => { + 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 }] + 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]).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 }) @@ -604,6 +860,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 }], @@ -725,6 +1011,23 @@ describe('isPlainObject', () => { expect(deepEqual({ foo: prev }, { foo: prev })).toBe(true) }) + it.each([null, undefined])( + '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 +1415,80 @@ 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 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('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({ ...result }).toStrictEqual(next) + expect(Object.getOwnPropertySymbols(result)).toEqual([]) + 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('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(prev[sym]).toBe('old') + }) + + 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(prev[sym]).toBe('old') }) })