From e739559e467f71b92d32aea004b13569fa9803cf Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Sat, 5 Apr 2025 10:53:31 +0200 Subject: [PATCH] assert,util: improve deep object comparison performance This improves the performance for almost all objects when comparing them deeply. PR-URL: https://github.com/nodejs/node/pull/57648 Reviewed-By: Jordan Harband Reviewed-By: Bryan English Reviewed-By: Antoine du Hamel --- benchmark/assert/deepequal-map.js | 2 +- .../deepequal-prims-and-objs-big-loop.js | 1 - benchmark/assert/deepequal-set.js | 41 +- benchmark/assert/partial-deep-equal.js | 2 +- lib/internal/util/comparisons.js | 465 ++++++++++++------ test/parallel/test-assert-deep.js | 12 + 6 files changed, 346 insertions(+), 177 deletions(-) diff --git a/benchmark/assert/deepequal-map.js b/benchmark/assert/deepequal-map.js index 4f651551c58..c336a471b25 100644 --- a/benchmark/assert/deepequal-map.js +++ b/benchmark/assert/deepequal-map.js @@ -31,7 +31,7 @@ function benchmark(method, n, values, values2) { } function main({ n, len, method, strict }) { - const array = Array(len).fill(1); + const array = Array.from({ length: len }, () => ''); switch (method) { case 'deepEqual_primitiveOnly': { diff --git a/benchmark/assert/deepequal-prims-and-objs-big-loop.js b/benchmark/assert/deepequal-prims-and-objs-big-loop.js index 1ab4ff4dd81..51fb2732b7d 100644 --- a/benchmark/assert/deepequal-prims-and-objs-big-loop.js +++ b/benchmark/assert/deepequal-prims-and-objs-big-loop.js @@ -14,7 +14,6 @@ const primValues = { 'number': 1_000, 'boolean': true, 'object': { property: 'abcdef' }, - 'object_other_property': { property: 'abcdef' }, 'array': [1, 2, 3], 'set_object': new Set([[1]]), 'set_simple': new Set([1, 2, 3]), diff --git a/benchmark/assert/deepequal-set.js b/benchmark/assert/deepequal-set.js index e771c81928a..2667cf88d73 100644 --- a/benchmark/assert/deepequal-set.js +++ b/benchmark/assert/deepequal-set.js @@ -6,8 +6,9 @@ const { deepEqual, deepStrictEqual, notDeepEqual, notDeepStrictEqual } = const bench = common.createBenchmark(main, { n: [1e3], - len: [5e2], + len: [2, 1e2], strict: [0, 1], + order: ['insert', 'random', 'reversed'], method: [ 'deepEqual_primitiveOnly', 'deepEqual_objectOnly', @@ -16,12 +17,30 @@ const bench = common.createBenchmark(main, { 'notDeepEqual_objectOnly', 'notDeepEqual_mixed', ], +}, { + combinationFilter(p) { + return p.order !== 'random' || p.strict === 1 && p.method !== 'notDeepEqual_objectOnly'; + }, }); -function benchmark(method, n, values, values2) { +function shuffleArray(array) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const temp = array[i]; + array[i] = array[j]; + array[j] = temp; + } +} + +function benchmark(method, n, values, values2, order) { const actual = new Set(values); // Prevent reference equal elements - const deepCopy = JSON.parse(JSON.stringify(values2 ? values2 : values)); + let deepCopy = JSON.parse(JSON.stringify(values2)); + if (order === 'reversed') { + deepCopy = deepCopy.reverse(); + } else if (order === 'random') { + shuffleArray(deepCopy); + } const expected = new Set(deepCopy); bench.start(); for (let i = 0; i < n; ++i) { @@ -30,39 +49,39 @@ function benchmark(method, n, values, values2) { bench.end(n); } -function main({ n, len, method, strict }) { - const array = Array(len).fill(1); +function main({ n, len, method, strict, order }) { + const array = Array.from({ length: len }, () => ''); switch (method) { case 'deepEqual_primitiveOnly': { const values = array.map((_, i) => `str_${i}`); - benchmark(strict ? deepStrictEqual : deepEqual, n, values); + benchmark(strict ? deepStrictEqual : deepEqual, n, values, values, order); break; } case 'deepEqual_objectOnly': { const values = array.map((_, i) => [`str_${i}`, null]); - benchmark(strict ? deepStrictEqual : deepEqual, n, values); + benchmark(strict ? deepStrictEqual : deepEqual, n, values, values, order); break; } case 'deepEqual_mixed': { const values = array.map((_, i) => { return i % 2 ? [`str_${i}`, null] : `str_${i}`; }); - benchmark(strict ? deepStrictEqual : deepEqual, n, values); + benchmark(strict ? deepStrictEqual : deepEqual, n, values, values, order); break; } case 'notDeepEqual_primitiveOnly': { const values = array.map((_, i) => `str_${i}`); const values2 = values.slice(0); values2[Math.floor(len / 2)] = 'w00t'; - benchmark(strict ? notDeepStrictEqual : notDeepEqual, n, values, values2); + benchmark(strict ? notDeepStrictEqual : notDeepEqual, n, values, values2, order); break; } case 'notDeepEqual_objectOnly': { const values = array.map((_, i) => [`str_${i}`, null]); const values2 = values.slice(0); values2[Math.floor(len / 2)] = ['w00t']; - benchmark(strict ? notDeepStrictEqual : notDeepEqual, n, values, values2); + benchmark(strict ? notDeepStrictEqual : notDeepEqual, n, values, values2, order); break; } case 'notDeepEqual_mixed': { @@ -71,7 +90,7 @@ function main({ n, len, method, strict }) { }); const values2 = values.slice(); values2[0] = 'w00t'; - benchmark(strict ? notDeepStrictEqual : notDeepEqual, n, values, values2); + benchmark(strict ? notDeepStrictEqual : notDeepEqual, n, values, values2, order); break; } default: diff --git a/benchmark/assert/partial-deep-equal.js b/benchmark/assert/partial-deep-equal.js index cdda4006874..6e479115050 100644 --- a/benchmark/assert/partial-deep-equal.js +++ b/benchmark/assert/partial-deep-equal.js @@ -62,7 +62,7 @@ function createSets(length, extraProps, depth = 0) { number: i, }, ['array', 'with', 'values'], - !depth ? new Set([1, 2, { nested: i }]) : new Set(), + !depth ? new Set([1, { nested: i }]) : new Set(), !depth ? createSets(2, extraProps, depth + 1) : null, ])); } diff --git a/lib/internal/util/comparisons.js b/lib/internal/util/comparisons.js index b7e7b9d92c4..3120c902905 100644 --- a/lib/internal/util/comparisons.js +++ b/lib/internal/util/comparisons.js @@ -9,6 +9,7 @@ const { DatePrototypeGetTime, Error, NumberPrototypeValueOf, + ObjectGetOwnPropertyDescriptor, ObjectGetOwnPropertySymbols: getOwnSymbols, ObjectGetPrototypeOf, ObjectIs, @@ -163,18 +164,6 @@ function isEnumerableOrIdentical(val1, val2, prop, mode, memos, method) { innerDeepEqual(val1[prop], val2[prop], mode, memos); } -// Notes: Type tags are historical [[Class]] properties that can be set by -// FunctionTemplate::SetClassName() in C++ or Symbol.toStringTag in JS -// and retrieved using Object.prototype.toString.call(obj) in JS -// See https://tc39.github.io/ecma262/#sec-object.prototype.tostring -// for a list of tags pre-defined in the spec. -// There are some unspecified tags in the wild too (e.g. typed array tags). -// Since tags can be altered, they only serve fast failures -// -// For strict comparison, objects should have -// a) The same built-in type tag. -// b) The same prototypes. - function innerDeepEqual(val1, val2, mode, memos) { // All identical values are equivalent, as determined by ===. if (val1 === val2) { @@ -191,8 +180,7 @@ function innerDeepEqual(val1, val2, mode, memos) { if (typeof val2 !== 'object' || typeof val1 !== 'object' || val1 === null || - val2 === null || - (mode === kStrict && ObjectGetPrototypeOf(val1) !== ObjectGetPrototypeOf(val2))) { + val2 === null) { return false; } } else { @@ -206,6 +194,17 @@ function innerDeepEqual(val1, val2, mode, memos) { return false; } } + return objectComparisonStart(val1, val2, mode, memos); +} + +function objectComparisonStart(val1, val2, mode, memos) { + if (mode === kStrict && + (val1.constructor !== val2.constructor || + (val1.constructor === undefined && + ObjectGetPrototypeOf(val1) !== ObjectGetPrototypeOf(val2)))) { + return false; + } + const val1Tag = ObjectPrototypeToString(val1); const val2Tag = ObjectPrototypeToString(val2); @@ -214,7 +213,6 @@ function innerDeepEqual(val1, val2, mode, memos) { } if (ArrayIsArray(val1)) { - // Check for sparse arrays and general fast path if (!ArrayIsArray(val2) || (val1.length !== val2.length && (mode !== kPartial || val1.length < val2.length))) { return false; @@ -316,6 +314,10 @@ function innerDeepEqual(val1, val2, mode, memos) { isNativeError(val2) || val2 instanceof Error) { return false; + } else if (isURL(val1)) { + if (!isURL(val2) || val1.href !== val2.href) { + return false; + } } else if (isKeyObject(val1)) { if (!isKeyObject(val2) || !val1.equals(val2)) { return false; @@ -332,10 +334,6 @@ function innerDeepEqual(val1, val2, mode, memos) { } } else if (isWeakMap(val1) || isWeakSet(val1)) { return false; - } else if (isURL(val1)) { - if (!isURL(val2) || val1.href !== val2.href) { - return false; - } } return keyCheck(val1, val2, mode, memos, kNoIterator); @@ -345,6 +343,21 @@ function getEnumerables(val, keys) { return ArrayPrototypeFilter(keys, (key) => hasEnumerable(val, key)); } +function partialSymbolEquiv(val1, val2, keys2) { + const symbolKeys = getOwnSymbols(val2); + if (symbolKeys.length !== 0) { + for (const key of symbolKeys) { + if (hasEnumerable(val2, key)) { + if (!hasEnumerable(val1, key)) { + return false; + } + ArrayPrototypePush(keys2, key); + } + } + } + return true; +} + function keyCheck(val1, val2, mode, memos, iterationType, keys2) { // For all remaining Object pairs, including Array, objects and Maps, // equivalence is determined by having: @@ -358,31 +371,15 @@ function keyCheck(val1, val2, mode, memos, iterationType, keys2) { if (keys2 === undefined) { keys2 = ObjectKeys(val2); } - - // Cheap key test - if (keys2.length > 0) { - for (const key of keys2) { - if (!hasEnumerable(val1, key)) { - return false; - } - } - } + let keys1; if (!isArrayLikeObject) { // The pair must have the same number of owned properties. if (mode === kPartial) { - const symbolKeys = getOwnSymbols(val2); - if (symbolKeys.length !== 0) { - for (const key of symbolKeys) { - if (hasEnumerable(val2, key)) { - if (!hasEnumerable(val1, key)) { - return false; - } - ArrayPrototypePush(keys2, key); - } - } + if (!partialSymbolEquiv(val1, val2, keys2)) { + return false; } - } else if (keys2.length !== ObjectKeys(val1).length) { + } else if (keys2.length !== (keys1 = ObjectKeys(val1)).length) { return false; } else if (mode === kStrict) { const symbolKeysA = getOwnSymbols(val1); @@ -421,6 +418,13 @@ function keyCheck(val1, val2, mode, memos, iterationType, keys2) { return true; } + if (memos === null) { + return objEquiv(val1, val2, mode, keys1, keys2, memos, iterationType); + } + return handleCycles(val1, val2, mode, keys1, keys2, memos, iterationType); +} + +function handleCycles(val1, val2, mode, keys1, keys2, memos, iterationType) { // Use memos to handle cycles. if (memos === undefined) { memos = { @@ -431,7 +435,7 @@ function keyCheck(val1, val2, mode, memos, iterationType, keys2) { d: undefined, deep: false, }; - return objEquiv(val1, val2, mode, keys2, memos, iterationType); + return objEquiv(val1, val2, mode, keys1, keys2, memos, iterationType); } if (memos.set === undefined) { @@ -445,7 +449,7 @@ function keyCheck(val1, val2, mode, memos, iterationType, keys2) { memos.c = val1; memos.d = val2; memos.deep = true; - const result = objEquiv(val1, val2, mode, keys2, memos, iterationType); + const result = objEquiv(val1, val2, mode, keys1, keys2, memos, iterationType); memos.deep = false; return result; } @@ -465,7 +469,7 @@ function keyCheck(val1, val2, mode, memos, iterationType, keys2) { return originalSize === set.size; } - const areEq = objEquiv(val1, val2, mode, keys2, memos, iterationType); + const areEq = objEquiv(val1, val2, mode, keys1, keys2, memos, iterationType); set.delete(val1); set.delete(val2); @@ -473,18 +477,6 @@ function keyCheck(val1, val2, mode, memos, iterationType, keys2) { return areEq; } -function setHasEqualElement(set, val1, mode, memo) { - for (const val2 of set) { - if (innerDeepEqual(val1, val2, mode, memo)) { - // Remove the matching element to make sure we do not check that again. - set.delete(val2); - return true; - } - } - - return false; -} - // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness#Loose_equality_using // Sadly it is not possible to detect corresponding values properly in case the // type is a string, number, bigint or boolean. The reason is that those values @@ -533,157 +525,269 @@ function mapMightHaveLoosePrim(a, b, prim, item2, memo) { return !b.has(altValue) && innerDeepEqual(item1, item2, kLoose, memo); } -function partialObjectSetEquiv(a, b, mode, set, memo) { +function partialObjectSetEquiv(array, a, b, mode, memo) { let aPos = 0; - for (const val of a) { + let direction = 1; + let start = 0; + let end = array.length - 1; + for (const val1 of a) { aPos++; - if (!b.has(val) && setHasEqualElement(set, val, mode, memo) && set.size === 0) { - return true; + if (!b.has(val1)) { + let innerStart = start; + if (direction === 1) { + if (innerDeepEqual(val1, array[start], mode, memo)) { + if (start === end) { + return true; + } + start += 1; + continue; + } + if (start === end) { + // The last element of set b might match a later element in set a. + continue; + } + direction = -1; + innerStart += 1; + } + let matched = true; + if (!innerDeepEqual(val1, array[end], mode, memo)) { + direction = 1; + matched = arrayHasEqualElement(array, val1, mode, memo, innerDeepEqual, innerStart, end); + } + if (matched) { + if (start === end) { + return true; + } + end -= 1; + } } - if (a.size - aPos < set.size) { + if (a.size - aPos <= end - start) { return false; } } - /* c8 ignore next */ - assert.fail('Unreachable code'); + return false; } -function setObjectEquiv(a, b, mode, set, memo) { - // Fast path for objects only - if (mode !== kLoose && set.size === a.size) { - for (const val of a) { - if (!setHasEqualElement(set, val, mode, memo)) { - return false; - } +function arrayHasEqualElement(array, val1, mode, memo, comparator, start, end) { + let matched = false; + for (let i = end - 1; i >= start; i--) { + if (comparator(val1, array[i], mode, memo)) { + // Remove the matching element to make sure we do not check that again. + array.splice(i, 1); + matched = true; + break; } - return true; - } - if (mode === kPartial) { - return partialObjectSetEquiv(a, b, mode, set, memo); } + return matched; +} - for (const val of a) { - // Primitive values have already been handled above. - if (typeof val === 'object') { - if (!b.has(val) && !setHasEqualElement(set, val, mode, memo)) { +function setObjectEquiv(array, a, b, mode, memo) { + let direction = 1; + let start = 0; + let end = array.length - 1; + const comparator = mode !== kLoose ? objectComparisonStart : innerDeepEqual; + const extraChecks = mode === kLoose || array.length !== a.size; + for (const val1 of a) { + if (extraChecks) { + if (typeof val1 === 'object') { + if (b.has(val1)) { + continue; + } + } else if (mode !== kLoose || b.has(val1)) { + continue; + } + } + + let innerStart = start; + if (direction === 1) { + if (comparator(val1, array[start], mode, memo)) { + start += 1; + continue; + } + if (start === end) { return false; } - } else if (mode === kLoose && - !b.has(val) && - !setHasEqualElement(set, val, mode, memo)) { - return false; + direction = -1; + innerStart += 1; } + if (!comparator(val1, array[end], mode, memo)) { + direction = 1; + if (!arrayHasEqualElement(array, val1, mode, memo, comparator, innerStart, end)) { + return false; + } + } + end -= 1; } - return set.size === 0; + return true; +} + +function compareSmallSets(a, b, val, iteratorB, mode, memo) { + const iteratorA = a.values(); + const firstA = iteratorA.next().value; + const first = innerDeepEqual(firstA, val, mode, memo); + if (first) { + if (b.size === 1) { // Partial mode && a.size === 1 || b.size === 1 + return true; + } + const secondA = iteratorA.next().value; + return b.has(secondA) || innerDeepEqual(secondA, iteratorB.next().value, mode, memo); + } + return a.size !== 1 && innerDeepEqual(iteratorA.next().value, val, mode, memo) && ( + b.size === 1 || // Partial mode + b.has(firstA) || // Primitive or reference equal + innerDeepEqual(firstA, iteratorB.next().value, mode, memo) + ); } function setEquiv(a, b, mode, memo) { // This is a lazily initiated Set of entries which have to be compared // pairwise. - let set = null; - for (const val of b) { + let array; + + const iteratorB = b.values(); + for (const val of iteratorB) { if (!a.has(val)) { if ((typeof val !== 'object' || val === null) && (mode !== kLoose || !setMightHaveLoosePrim(a, b, val))) { return false; } - if (set === null) { - if (a.size === 1) { - return innerDeepEqual(a.values().next().value, val, mode, memo); + if (array === undefined) { + if (a.size < 3) { + return compareSmallSets(a, b, val, iteratorB, mode, memo); } - set = new SafeSet(); + array = []; } // If the specified value doesn't exist in the second set it's a object // (or in loose mode: a non-matching primitive). Find the // deep-(mode-)equal element in a set copy to reduce duplicate checks. - set.add(val); + array.push(val); } } - if (set !== null) { - return setObjectEquiv(a, b, mode, set, memo); - } - - return true; -} - -function mapHasEqualEntry(set, map, key1, item1, mode, memo) { - // To be able to handle cases like: - // Map([[{}, 'a'], [{}, 'b']]) vs Map([[{}, 'b'], [{}, 'a']]) - // ... we need to consider *all* matching keys, not just the first we find. - for (const key2 of set) { - if (innerDeepEqual(key1, key2, mode, memo) && - innerDeepEqual(item1, map.get(key2), mode, memo)) { - set.delete(key2); - return true; - } - } - - return false; -} - -function partialObjectMapEquiv(a, b, mode, set, memo) { - let aPos = 0; - for (const { 0: key1, 1: item1 } of a) { - aPos++; - if (typeof key1 === 'object' && - key1 !== null && - mapHasEqualEntry(set, b, key1, item1, mode, memo) && - set.size === 0) { - return true; - } - if (a.size - aPos < set.size) { - return false; - } - } - /* c8 ignore next */ - assert.fail('Unreachable code'); -} - -function mapObjectEquivalence(a, b, mode, set, memo) { - // Fast path for objects only - if (mode !== kLoose && set.size === a.size) { - for (const { 0: key1, 1: item1 } of a) { - if (!mapHasEqualEntry(set, b, key1, item1, mode, memo)) { - return false; - } - } + if (array === undefined) { return true; } if (mode === kPartial) { - return partialObjectMapEquiv(a, b, mode, set, memo); + return partialObjectSetEquiv(array, a, b, mode, memo); } + return setObjectEquiv(array, a, b, mode, memo); +} + +function partialObjectMapEquiv(array, a, b, mode, memo) { + let aPos = 0; + let direction = 1; + let start = 0; + let end = array.length - 1; for (const { 0: key1, 1: item1 } of a) { + aPos++; if (typeof key1 === 'object' && key1 !== null) { - if (!mapHasEqualEntry(set, b, key1, item1, mode, memo)) - return false; - } else if (set.size === 0) { - return true; - } else if (mode === kLoose && - (!b.has(key1) || - !innerDeepEqual(item1, b.get(key1), mode, memo)) && - !mapHasEqualEntry(set, b, key1, item1, mode, memo)) { + let innerStart = start; + if (direction === 1) { + const key2 = array[start]; + if (objectComparisonStart(key1, key2, mode, memo) && innerDeepEqual(item1, b.get(key2), mode, memo)) { + if (start === end) { + return true; + } + start += 1; + continue; + } + if (start === end) { + // The last element of map b might match a later element in map a. + continue; + } + direction = -1; + innerStart += 1; + } + let matched = true; + const key2 = array[end]; + if (!objectComparisonStart(key1, key2, mode, memo) || !innerDeepEqual(item1, b.get(key2), mode, memo)) { + direction = 1; + matched = arrayHasEqualMapElement(array, key1, item1, b, mode, memo, objectComparisonStart, innerStart, end); + } + if (matched) { + if (start === end) { + return true; + } + end -= 1; + } + } + if (a.size - aPos <= end - start) { return false; } } - return set.size === 0; + return false; +} + +function arrayHasEqualMapElement(array, key1, item1, b, mode, memo, comparator, start, end) { + let matched = false; + for (let i = end - 1; i >= start; i--) { + const key2 = array[i]; + if (comparator(key1, key2, mode, memo) && + innerDeepEqual(item1, b.get(key2), mode, memo)) { + // Remove the matching element to make sure we do not check that again. + array.splice(i, 1); + matched = true; + break; + } + } + return matched; +} + +function mapObjectEquiv(array, a, b, mode, memo) { + let direction = 1; + let start = 0; + let end = array.length - 1; + const comparator = mode !== kLoose ? objectComparisonStart : innerDeepEqual; + const extraChecks = mode === kLoose || array.length !== a.size; + + for (const { 0: key1, 1: item1 } of a) { + if (extraChecks && + (typeof key1 !== 'object' || key1 === null) && + (mode !== kLoose || + (b.has(key1) && innerDeepEqual(item1, b.get(key1), mode, memo)))) { // Mixed mode + continue; + } + + let innerStart = start; + if (direction === 1) { + const key2 = array[start]; + if (comparator(key1, key2, mode, memo) && innerDeepEqual(item1, b.get(key2), mode, memo)) { + start += 1; + continue; + } + if (start === end) { + return false; + } + direction = -1; + innerStart += 1; + } + const key2 = array[end]; + if ((!comparator(key1, key2, mode, memo) || !innerDeepEqual(item1, b.get(key2), mode, memo))) { + direction = 1; + if (!arrayHasEqualMapElement(array, key1, item1, b, mode, memo, comparator, innerStart, end)) { + return false; + } + } + end -= 1; + } + return true; } function mapEquiv(a, b, mode, memo) { - let set = null; + let array; for (const { 0: key2, 1: item2 } of b) { if (typeof key2 === 'object' && key2 !== null) { - if (set === null) { + if (array === undefined) { if (a.size === 1) { const { 0: key1, 1: item1 } = a.entries().next().value; return innerDeepEqual(key1, key2, mode, memo) && - innerDeepEqual(item1, item2, mode, memo); + innerDeepEqual(item1, item2, mode, memo); } - set = new SafeSet(); + array = []; } - set.add(key2); + array.push(key2); } else { // By directly retrieving the value we prevent another b.has(key2) check in // almost all possible cases. @@ -696,19 +800,23 @@ function mapEquiv(a, b, mode, memo) { // keys. if (!mapMightHaveLoosePrim(a, b, key2, item2, memo)) return false; - if (set === null) { - set = new SafeSet(); + if (array === undefined) { + array = []; } - set.add(key2); + array.push(key2); } } } - if (set !== null) { - return mapObjectEquivalence(a, b, mode, set, memo); + if (array === undefined) { + return true; } - return true; + if (mode === kPartial) { + return partialObjectMapEquiv(array, a, b, mode, memo); + } + + return mapObjectEquiv(array, a, b, mode, memo); } function partialSparseArrayEquiv(a, b, mode, memos, startA, startB) { @@ -770,11 +878,31 @@ function sparseArrayEquiv(a, b, mode, memos, i) { return true; } -function objEquiv(a, b, mode, keys2, memos, iterationType) { +function objEquiv(a, b, mode, keys1, keys2, memos, iterationType) { // The pair must have equivalent values for every corresponding key. if (keys2.length > 0) { - for (const key of keys2) { - if (!innerDeepEqual(a[key], b[key], mode, memos)) { + let i = 0; + // Ordered keys + if (keys1 !== undefined) { + for (; i < keys2.length; i++) { + const key = keys2[i]; + if (keys1[i] !== key) { + break; + } + if (!innerDeepEqual(a[key], b[key], mode, memos)) { + return false; + } + } + } + // Unordered keys + for (; i < keys2.length; i++) { + const key = keys2[i]; + // It is faster to get the whole descriptor and to check it's enumerable + // property in V8 13.0 compared to calling Object.propertyIsEnumerable() + // and accessing the property regularly. + const descriptor = ObjectGetOwnPropertyDescriptor(a, key); + if (!descriptor?.enumerable || + !innerDeepEqual(descriptor.value !== undefined ? descriptor.value : a[key], b[key], mode, memos)) { return false; } } @@ -807,14 +935,25 @@ function objEquiv(a, b, mode, keys2, memos, iterationType) { return true; } +// Only handle cycles when they are detected. +// eslint-disable-next-line func-style +let detectCycles = function(val1, val2, mode) { + try { + return innerDeepEqual(val1, val2, mode, null); + } catch { + detectCycles = innerDeepEqual; + return innerDeepEqual(val1, val2, mode, undefined); + } +}; + module.exports = { isDeepEqual(val1, val2) { - return innerDeepEqual(val1, val2, kLoose); + return detectCycles(val1, val2, kLoose); }, isDeepStrictEqual(val1, val2) { - return innerDeepEqual(val1, val2, kStrict); + return detectCycles(val1, val2, kStrict); }, isPartialStrictEqual(val1, val2) { - return innerDeepEqual(val1, val2, kPartial); + return detectCycles(val1, val2, kPartial); }, }; diff --git a/test/parallel/test-assert-deep.js b/test/parallel/test-assert-deep.js index c53ce8f4cc6..c6326fa26f6 100644 --- a/test/parallel/test-assert-deep.js +++ b/test/parallel/test-assert-deep.js @@ -406,6 +406,18 @@ test('es6 Maps and Sets', () => { new Set([xarray, ['y']]), new Set([xarray, ['y']]) ); + assertDeepAndStrictEqual( + new Set([2, xarray, ['y'], 1]), + new Set([xarray, ['y'], 1, 2]) + ); + assertDeepAndStrictEqual( + new Set([{ a: 1 }, { a: 3 }, { a: 2 }, { a: 4 }]), + new Set([{ a: 2 }, { a: 1 }, { a: 4 }, { a: 3 }]) + ); + assertNotDeepOrStrict( + new Set([{ a: 1 }, { a: 2 }, { a: 3 }, { a: 4 }]), + new Set([{ a: 1 }, { a: 2 }, { a: 3 }, { a: 5 }]) + ); assertOnlyDeepEqual( new Set([null, '', 1n, 5, 2n, false]), new Set([undefined, 0, 5n, true, '2', '-000'])