Skip to content

deepEquals: compare a same-typed pair once, and pair Set and Map entries as node does - #45457

Draft
robobun wants to merge 7 commits into
mainfrom
robobun/9af1afae/deep-equals-one-pass
Draft

robobun wants to merge 7 commits into
mainfrom
robobun/9af1afae/deep-equals-one-pass

Conversation

@robobun

@robobun robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #28760, related to #34830

Problem

Fix

  • Bun__deepEquals swaps the operands only when their internal types (JSType) differ.
  • On node's entry points, nodeSetEquiv and nodeMapEquiv settle the entries that both sides hold. NodeUnorderedPairing pairs the rest one-to-one. Those Sets take 63.9 µs.
  • Correct: the answer equals a search over every pairing (seeded test). 0 of 800,000 random Map verdicts differ from node (main: 95,508).
  • Verified: test/js/node/assert/deep-equal.test.ts, test/js/bun/bun-object/deep-equals.test.ts (main fails 113 tests), test/js/node/assert/, expect.test.js. Self-reviewed: 38 concerns raised, 37 addressed. Not done: sharing the rule with assert.partialDeepStrictEqual.

Background

  • checkPrototypes marks the two Bun__deepEquals instantiations behind node's entry points. The other five follow Jest.
  • Weighed: a flag in the Set and Map comparisons (other types run twice), and pairing for Bun.deepEquals (its answer would depend on insertion order).

Downsides

  • Release text grows by 6,120 B.
  • Not equal for a changed copy of a 1,000-key Map takes 7.3 µs (main: 83 ns, node: 143 µs).
  • Not-equal Sets of 16 objects take 1.1 µs (main: 941 ns).
Notes

Repro

import util from "node:util";
const answer = compare => {
  try {
    return String(compare());
  } catch (error) {
    return error.name;
  }
};

// 1. Reads of one value under 1, 4 and 10 nested Maps.
let reads = 0;
const nested = depth => {
  let value = { get leaf() { reads++; return 1; } };
  for (let i = 0; i < depth; i++) value = new Map([["k", value]]);
  return value;
};
const counts = [1, 4, 10].map(depth => {
  reads = 0;
  util.isDeepStrictEqual(nested(depth), nested(depth));
  return reads;
});
console.log("1.", counts.join(" / "));

// 2. A Map that holds itself, against a copy.
const selfHolding = () => {
  const map = new Map();
  return map.set("self", map);
};
console.log("2.", answer(() => util.isDeepStrictEqual(selfHolding(), selfHolding())));

// 3. Two Maps with the same entries in another order. One key is the same object on both sides.
const key = { id: 1 };
console.log("3.", answer(() => util.isDeepStrictEqual(new Map([[key, "x"], [{ id: 1 }, "y"]]), new Map([[{ id: 1 }, "y"], [key, "x"]]))));

// 4. Sets that hold deep-equal members in different counts.
console.log("4.", answer(() => util.isDeepStrictEqual(new Set([{ a: 1 }, { a: 1 }, { a: 2 }]), new Set([{ a: 1 }, { a: 2 }, { a: 2 }]))));

// 5. Two Sets of 1,000 objects, 20 comparisons.
const set = () => new Set(Array.from({ length: 1000 }, (_, id) => ({ id })));
const a = set();
const b = set();
const start = performance.now();
for (let i = 0; i < 20; i++) util.isDeepStrictEqual(a, b);
console.log("5.", ((performance.now() - start) / 20).toFixed(2), "ms");
1 2 3 4 5
bun 1.3.14 2 / 2 / 2 true true true 65.39 ms
bun 1.4.3 4 / 32 / 2048 RangeError false true 52.43 ms
main (a407eae) 4 / 32 / 2048 RangeError false true 51.12 ms
node v26.3.0 2 / 2 / 2 true true false 1.08 ms
this PR 2 / 2 / 2 true true false 0.14 ms

Rows 1 to 3 are regressions of #34660. Rows 4 and 5 are older than that. Column 5 is the median of 3 runs of the script, and a run includes the first call. The bench below has the warm numbers. No user reported the slowdown. It was found by measurement.

What changes in bindings.cpp

  • Dispatch (Bun__deepEquals, all seven instantiations): after specialObjectsDequal(c1, c2) answers std::nullopt, the swapped call specialObjectsDequal(c2, c1) runs only when the two cells have different JSTypes. The swapped call exists so that a special right operand can reject a left operand of another type (fix: Bun.deepEquals on empty objects with the same prototype #16894). For two cells of one type it ran every arm that ends in std::nullopt a second time.
  • Three comparisons covered only one direction for a pair of one type, and the swapped call covered the other. Each now covers both, with no change of verdict: the wrapper test for a boxed Symbol or BigInt, URLSearchParams, and Headers. A test pins each pair in both argument orders on all seven entry points.
  • An unreachable copy of the wrapper test in specialObjectsDequalSlow is gone.
  • Error arm, on node's two entry points only: the names that only the right error enumerates are compared too. On main the arm takes its names from the left error. A Set or a Map compared its members in both orders through the second pass, so the pairing would lose that. At top level and in an array main answered by the left names only, and 21 of 22,780 verdicts of a sweep move to node's answer (below).
  • Set and Map arms, on node's two entry points only: the rules below. The loops that the five Jest-rule instantiations compile keep their text.

Rules of the Set and Map arms on node's entry points

  • First loop (nodeSetEquiv and nodeMapEquiv, for node's setEquiv and mapEquiv): walk the right collection and look each entry up in the left one. A Set member that the left Set holds is settled. A Map key that the left Map holds is settled when the two values are deep-equal. A collection that this loop settles completely never reaches the pairing.
  • A right entry that is not settled and whose key is not an object (node's typeof) ends the comparison: nothing else on the left equals it. Every other such entry becomes a candidate. Its key goes into a MarkedArgumentBuffer.
  • A Map key that both sides hold with unequal values (a "twin" in the code) is a candidate too, as in node: its partner can be an entry under a deep-equal key. Before it is queued, addTwin looks for a left entry that can be its partner. Maps that differ in one value have none, and the comparison ends there.
  • Second loop (claimCandidates, for node's setObjectEquiv and mapObjectEquiv): walk the left collection. Every object entry that the right side does not settle claims one candidate through Bun::PendingEntries::claimWith: front guess, back guess, then a scan from the back with a swap-remove. The value of a candidate is read from the right Map at each probe, as in node.
  • Node skips the lookups of the second loop when the first loop settled nothing (extraChecks). So does this PR. A left entry that is not an object is not looked up at all: when the right side does not hold it, a candidate stays open at the end and the answer is not equal.
  • When the last candidate is claimed, the answer is equal. Node walks on, but every other left entry is one that the right side holds.
  • A callable entry pairs only by identity, as in node.
  • The arm then ends in break, and the generic walk compares the own properties once.
  • A static_assert keeps a loose node mode and asymmetric matchers out of this pairing.
  • The cycle stack is as on main: an entry of a Set or a Map does not join it.

Verdicts

  • The five entry points that follow Jest (Bun.deepEquals loose, strict and strict with skipPrototype, expect().toEqual, expect().toStrictEqual): 0 of 57 rows of a sweep change in either argument order. assert.deepEqual uses the loose instantiation.
  • Node's two entry points, random Sets and Maps (next section): 124,248 of 1,600,000 verdicts change, and every one changes to node's answer.
  • Node's two entry points, a sweep of 68 kinds of value in pairs, at top level and as an array element, a Set member, a Map value and a Map key (22,780 verdicts): main differs from node on 31, this PR on 10. 21 change, all to node's answer. The 10 are one pair of Errors (an own enumerable name against an own enumerable message) in the five positions, which the Error arm accepts on main too.
  • A Map or a Set that holds itself now equals its copy, as in bun 1.3.14 and node. A collection that holds itself and is reached only as an entry of another collection still throws a RangeError, as on main and 1.3.14.
  • Node is order-dependent for an object with an own non-enumerable constructor against {}, and for Object.create(null) against a Proxy whose getPrototypeOf answers null. Inside a Map or a Set, bun now gives node's answer in each order. On main it answered not equal in both.

Measurements (release builds of main a407eae and of this PR at 297815a, which is that tree with the bindings.cpp and PendingEntries.h of this PR. The branch merged main d940fbe after that, and the merge does not touch the code of this PR.)

  • Size (size bun-profile): text 106,033,357 -> 106,039,477 B. nm -S -C: +7,354 B over 25 symbols. New: nodeMapEquiv 3,776, nodeSetEquiv 1,955, claimCandidates 1,940, mapValueOrUndefined 1,436, holds 1,241, mapHolds 1,172, a WTF::Vector<size_t, 8> growth function 447, entriesEqual 363, addCandidate 331, an outlined URLSearchParams::get 180. specialObjectsDequalSlow grows from 10,945 to 12,134. The seven Bun__deepEquals grow by 6 to 129 each. The two node instantiations of specialObjectsDequal shrink from 4,525 to 933 each, and the five others by 4 each.
  • bench/snippets/assert.mjs, median of 5 interleaved rounds. On the rows below 2 µs, two runs of one binary differ by up to 25% on this machine.
row main this PR bun 1.4.3 bun 1.3.14 node v26.3.0
deepStrictEqual, two-key object 93 ns 94 ns 93 ns 155 ns 129 ns
deepStrictEqual, 10 nested Maps 172 µs 780 ns 177 µs 409 ns 1.2 µs
deepStrictEqual, Map of 10,000 entries 326 µs 155 µs 343 µs 175 µs 242 µs
deepStrictEqual, Map subclass of 10,000 entries 328 µs 154 µs 339 µs 173 µs 244 µs
deepStrictEqual, Set of 10,000 strings 225 µs 101 µs 224 µs 130 µs 49.8 µs
deepStrictEqual, Set of 1,000 objects 51.5 ms 63.9 µs 51.4 ms 64.6 ms 137 µs
deepStrictEqual, the same Set in a scattered order 51.1 ms 12.4 ms 51.3 ms 64.8 ms 29.9 ms
deepStrictEqual, Map of 1,000 object keys 51.5 ms 80.0 µs 51.9 ms 64.3 ms 176 µs
deepStrictEqual, Set of 2,000 held and 8 other objects 3.8 ms 36.3 µs 3.9 ms 2.1 ms 22.5 µs
deepStrictEqual, 10 nested Sets of two objects 436 µs 1.9 µs 449 µs 2.3 µs 2.8 µs
notDeepStrictEqual, Sets of 16 objects, none equal 941 ns 1.1 µs 975 ns 2.1 µs 2.4 µs
notDeepStrictEqual, Maps of 16 object keys, none equal 945 ns 1.1 µs 980 ns 2.1 µs 2.3 µs
notDeepStrictEqual, changed copy of a Map of 1,000 object keys 83 ns 7.3 µs 81 ns 49 ns 143 µs
  • Instructions for one warm call, main -> this PR. They are single-stepped in gdb on bun-profile, because perf and valgrind are not available on the machine. A malloc that takes its slow path adds about 90 to a count.
    • No Set or Map, {a: 1, b: {c: 2}}: Bun.deepEquals 1,466 -> 1,386, strict 3,767 -> 3,673, strict with skipPrototype 1,468 -> 1,386, expect().toEqual 2,047 -> 1,965, expect().toStrictEqual 4,356 -> 4,263, util.isDeepStrictEqual 2,286 -> 2,162, with skipPrototype 1,777 -> 1,664.
    • Other pairs of one type through Bun.deepEquals: [1, 2, 3] 1,639 -> 1,594, Intl.Collator 733 -> 617, Object(Symbol.iterator) 779 -> 623, Bun.Glob 977 -> 710. Through util.isDeepStrictEqual: Date 1,113 -> 981, RegExp 1,872 -> 1,625, boxed Number 1,104 -> 966, Error 7,306 -> 7,311, Uint8Array of 64 bytes 4,151 -> 4,149.
    • Pairs of two types, which pay for the new test: {a: 1} against [1] 411 -> 416, Proxy of {a: 1} against {a: 1} 4,206 -> 4,211, {a: 1} against new Map() 350 -> 354, the reverse 301 -> 302 (all Bun.deepEquals). Through util.isDeepStrictEqual the same four are 891 -> 890, 3,581 -> 3,582, 743 -> 742 and 743 -> 742.
    • expect().toEqual on collections: Set of 96 strings 22,429 -> 22,429, Map of 96 strings 38,024 -> 38,024, Map of 96 strings to {v} 81,698 -> 77,570, one-object Set 1,968 -> 1,925. Bun.deepEquals on 16-object Sets 103,380 -> 98,165.
    • util.isDeepStrictEqual, settled by the first loop: Set of 96 strings 44,918 -> 21,826. Map of 96 strings 72,652 -> 35,810. Map of 96 strings to {v} 233,728 -> 110,396. 96 object keys that both sides hold 41,596 -> 20,562. 4 and 8 nested string-keyed Maps 41,742 and 693,125 -> 5,810 and 10,518.
    • Paired, 16-object Sets: same order 316,644 -> 21,827, reversed 316,692 -> 22,949, rotated by one 316,644 -> 89,792, shuffled 316,644 -> 88,793. 16-object-key Maps 317,868 -> 25,322. Two-object Set 8,584 -> 3,847. One-object Set 3,980 -> 2,672. 4 nested one-member Sets 41,648 -> 7,231. More than 8 candidates cost 2 mallocs (main: 0).
    • 96 strings and one object: Set 77,760 -> 31,820, Map 88,766 -> 37,329.
    • Not equal at a string that only one side holds: first of 100 Set members 20,146 -> 968, first of 100 Map keys 19,785 -> 1,067. Not equal at the last of 16 objects: 158,643 -> 21,678.
    • Rows above main. Not equal at the first value of a string-keyed Map: 1,103 -> 1,145. Not equal, one object each: 2,281 -> 2,523. Not equal, no member shared: 16-object Sets 19,398 -> 21,646, 16-object-key Maps 19,376 -> 22,884. The first loop queues every right entry before the first comparison.
    • Rows above main, a key that both Maps hold with unequal values, 96 object keys. First value differs, the other values identical (a Map and its changed copy): 2,032 -> 19,866. Number values: 1,036 -> 16,102. Distinct but equal object values: 2,032 -> 116,308. The same with the last value different: 109,648 -> 217,317. Per other entry, addTwin costs about 160 to 185 instructions in the first two cases and one key comparison (about 1,200) in the third.
  • Getter reads through util.isDeepStrictEqual equal node v26.3.0 in 105 of 105 rows: Set members, Map keys and Map values under deep-equal keys, n = 3, 4, 8, 16 and 64, in seven orders. For n = 64: same order 128, reversed 130, rotated by one 2,176 (main: 8,320 in every order).
  • A leaf getter under 10 nested collections is read twice in 15 shapes on each of node's seven spellings (three times through assert.notDeepStrictEqual, as in node), and twice in 7 shapes on the six Jest-rule spellings. Main reads 2,048 on node's spellings.
  • A user toString on a boxed String with an own property runs 2 times per comparison on all seven entry points (main: 4 on the five strict ones, bun 1.3.14: 2). Node does not call it.

Differential against node v26.3.0 (util.isDeepStrictEqual, 8 seeded configurations of 100,000 random pairs, both argument orders. Of the pairs, 30% are a copy of the left side in another order, 25% are such a copy in which one entry is a second copy of another entry, and 45% are built independently. The member pool has primitives, plain, dictionary-mode, undefined-property and non-enumerable-property objects, arrays, boxed values, nested Sets and Maps, and members shared by identity. null and undefined members are out, because node v26.3.0 throws a TypeError on some collections that hold them.)

differs from node on Sets (800,000) Maps (800,000)
bun 1.3.14 92,726 113,977
bun 1.4.3 and main 29,266 95,508
this PR 526 0
  • Every one of the 526 is a pair of Sets of fewer than three members, where node takes its compareSmallSets shortcut.
  • With skipPrototype: main 29,878 and 97,966, this PR 556 and 0.
  • The release build of this PR gives the answer of a brute-force search over every pairing on 1,600,000 more verdicts, with null, undefined, functions, symbols and -0 in the pool.
The script
// Prints the verdicts of util.isDeepStrictEqual on seeded random pairs of Sets or Maps, in both argument orders.
// usage: <runtime> differential.mjs <set|map> <seed> <pairs> <maxSize> <sharedRate> [dense] [skipPrototype]
// Run it under node and under bun with the same arguments, and compare the two outputs line by line.
import util from "node:util";

const [kind, seed, pairs, maxSize, sharedRate, dense, skipPrototype] = process.argv.slice(2);

function rng(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const dictionary = props => {
  const object = { ...props, temporary: 1 };
  delete object.temporary;
  return object;
};
const hidden = (props, name) => Object.defineProperty({ ...props }, name, { value: 1, enumerable: false });
const factories = {
  one: () => 1, two: () => 2, strA: () => "a", strB: () => "b", nan: () => NaN, tru: () => true, big: () => 1n,
  a1: () => ({ a: 1 }), a2: () => ({ a: 2 }), a1bU: () => ({ a: 1, b: undefined }), aU: () => ({ a: undefined }), empty: () => ({}),
  arr1: () => [1], arr12: () => [1, 2], arr0: () => [], nested: () => ({ a: { b: 1 } }),
  dictA1: () => dictionary({ a: 1 }), dictA1bU: () => dictionary({ a: 1, b: undefined }), dictXA: () => dictionary({ x: 5, a: 1 }),
  hiddenA1: () => hidden({ a: 1 }, "h"), hiddenB: () => hidden({}, "b"),
  num1: () => new Number(1), strObj: () => new String("a"), date: () => new Date(0),
  set1: () => new Set([1]), setA1: () => new Set([{ a: 1 }]), setA1A2: () => new Set([{ a: 1 }, { a: 2 }]), setA2A1: () => new Set([{ a: 2 }, { a: 1 }]),
  map1: () => new Map([[1, { a: 1 }]]), mapObj: () => new Map([[{ a: 1 }, 1]]),
  nullProto: () => Object.assign(Object.create(null), { a: 1 }),
};
// The dense pool has few shapes, so that many entries are deep-equal to each other.
const densePool = ["a1", "a2", "a1bU", "aU", "empty", "dictA1", "hiddenA1", "arr1", "one", "strA", "setA1", "mapObj"];
const pool = dense === "dense" ? densePool : Object.keys(factories);

function clone(value) {
  if (value === null || typeof value !== "object") return value;
  if (Array.isArray(value)) return value.map(clone);
  if (value instanceof Set) return new Set([...value].map(clone));
  if (value instanceof Map) return new Map([...value].map(([k, v]) => [clone(k), clone(v)]));
  if (value instanceof Date) return new Date(value.getTime());
  if (value instanceof Number) return new Number(value.valueOf());
  if (value instanceof String) return new String(value.valueOf());
  const out = Object.create(Object.getPrototypeOf(value));
  for (const key of Reflect.ownKeys(value)) Object.defineProperty(out, key, Object.getOwnPropertyDescriptor(value, key));
  return out;
}

const random = rng(Number(seed));
const share = Number(sharedRate);
function makePair() {
  // Objects that both sides can hold by identity.
  const shared = [{ a: 1 }, { a: 2 }, [1], new Set([{ a: 1 }]), { a: 1, b: undefined }, dictionary({ a: 1 })];
  const pick = () => (random() < share ? shared[Math.floor(random() * shared.length)] : factories[pool[Math.floor(random() * pool.length)]]());
  // A copy of an entry: the same object when both sides can hold it (7 times of 10), or an equal new object.
  const refresh = value => {
    if (shared.includes(value) && random() < 0.7) return value;
    if (value === null || typeof value !== "object" || random() < 0.1) return value;
    return clone(value);
  };
  const size = 1 + Math.floor(random() * Number(maxSize));
  const build = () => {
    const entries = new Map();
    for (let tries = 0; entries.size < size && tries < 50; tries++) entries.set(pick(), kind === "map" ? pick() : undefined);
    return [...entries];
  };
  const left = build();
  let right;
  const mode = random();
  if (mode < 0.55) {
    // A copy of the left side in another order.
    right = left.map(([key, value]) => [refresh(key), refresh(value)]);
    for (let i = right.length - 1; i > 0; i--) {
      const j = Math.floor(random() * (i + 1));
      [right[i], right[j]] = [right[j], right[i]];
    }
    // A near miss: one entry of the copy becomes a second copy of another entry, so the counts differ.
    if (mode >= 0.3 && right.length > 1) {
      const i = Math.floor(random() * right.length);
      const j = (i + 1 + Math.floor(random() * (right.length - 1))) % right.length;
      right[i] = [clone(right[j][0]), random() < 0.5 ? right[i][1] : clone(right[j][1])];
    }
  } else {
    right = build();
  }
  const collection = entries => (kind === "map" ? new Map(entries) : new Set(entries.map(([key]) => key)));
  return [collection(left), collection(right)];
}

const verdict = (a, b) => {
  try {
    return (skipPrototype === "skipPrototype" ? util.isDeepStrictEqual(a, b, true) : util.isDeepStrictEqual(a, b)) ? "1" : "0";
  } catch (error) {
    return error instanceof RangeError ? "R" : "T";
  }
};
const lines = [];
for (let i = 0; i < Number(pairs); i++) {
  const [a, b] = makePair();
  lines.push(verdict(a, b) + verdict(b, a) + " " + a.size);
}
console.log(lines.join("\n"));

The 8 configurations are set 1 100000 5 0.25 all, set 2 100000 8 0.5 all, set 3 100000 9 0.3 dense, set 4 100000 10 0.5 dense, map 5 100000 5 0.25 all, map 6 100000 8 0.5 all, map 7 100000 9 0.3 dense and map 8 100000 10 0.5 dense. Each runtime runs each configuration in one process.

Answers that differ from node v26.3.0, each pinned by a test

  • new Set([shared, {a: 1}]) against a Set of the same shape, where shared is {a: 1}: equal here. Node's compareSmallSets compares the shared member with the copy first and answers not equal.
  • new Set([shared, undefined]) against new Set([shared, {a: 1}]): not equal here. The same shortcut reads past the end of the other Set.
  • A Set or a Map with a null or undefined entry against one of objects only: not equal here in both argument orders. Node throws a TypeError in one order (assert.deepStrictEqual / util.isDeepStrictEqual throw TypeError on Maps with null keys nodejs/node#64433, fixed in v26.10.0).
  • A Map key that both sides hold and that is a Promise or a WeakMap: the entry is equal here. Node compares the key with itself structurally, and to node such an object equals nothing.
  • A key that both sides hold is settled by identity, so its getters are not read. Node compares it with itself.

A getter that changes a collection during the comparison

The candidates are copies of the right keys, and the right values are read when an entry is compared. 23 test rows hold node's answer and bun's for a getter that changes the left or the right collection during the pairing. The two are the same in the 16 rows where node has an answer. In the other 7 rows node throws a TypeError: the left side gains an entry, and node reads past the end of its list. There bun answers whether the right side holds every left entry that has no partner yet. 24 more rows do the same for a getter that runs during the first loop: bun equals node in 22, and in 2 a Map gains an own property, which node compares before the entries. Two rows differ because node compares a one-entry Map and a Set of fewer than three members with shortcuts. One test clears a right Set of 40 members and runs a GC during the pairing, so that only the candidate buffer keeps the members alive.

Tests of the tests

38 single-clause changes of the code of this PR were built, and each one ran against the two test files: 5 changes of the dispatch test and the three repairs, 3 of the reverse walk of the Error arm, and 30 of the Set and Map rules. 35 fail a test (one of them on an assertion of the debug build). 2 change no answer: they remove an optimization (the lookups that extraChecks skips, and the return when the last candidate is claimed). 1 makes the entries of a Set or a Map join the cycle stack, which this PR leaves as on main.

Other PRs

Not in this PR

Questions for a maintainer

Self-review

A review of the first version of this PR (the dispatch change with a second walk in the two node arms) raised 38 concerns. Its main finding: that interim rule accepted a four-entry Map pair that node and main reject, and it left Sets and Maps of two or more objects at 2^depth. The pairing of #38510 replaces it, and a test row pins that pair. The other concerns and what was done:

  • The node-mode change of the Error arm made an ERR_* error equal a bare error of its class and message. It is out. The reverse walk that replaces it keeps main's answer, and two rows pin it.
  • After the dispatch change an arm must answer for both argument orders. A comment says so, and a row for global objects fails for an arm that does not.
  • The boxed String count was a plain row in the node file, where node's count is 0. It is now a row per strict entry point in deep-equals.test.ts, and it fails on main for all five.
  • Read counts were pinned for Sets and Maps on one spelling. They are now a table of shapes by entry point.
  • The comments name node's functions with permalinks and mark each place where bun differs on purpose.
  • The differential had no pairs with a duplicated entry and could not be rerun. It has them now, with the script above and columns for 1.3.14 and 1.4.3.
  • The bench has rows that this PR loses, and columns for 1.3.14 and 1.4.3.
  • Problem names the RangeError and the Map verdict as regressions of node compat batch: callback-throw dispatch, Assert class + native deep-equality parity, Intl gate + URL/buffer fallout, compile cache, watch kill-signal, profilers (+98 tests) #34660, and the range is 1.4.0 to 1.4.3.
  • Not done: one implementation shared with assert.partialDeepStrictEqual (last item of "Not in this PR").

A second review, of the folded diff, is not done yet. The PR stays a draft until it is.

Bun__deepEquals called specialObjectsDequal a second time, with the
operands swapped, when the first call answered std::nullopt. That call
lets a special right operand reject a left operand of another type. For
a pair of one JSType it ran the same arm again. On node's entry points
the Set and Map arms answer std::nullopt after they compared every
entry, so each level of nesting doubled the work.

- The dispatch makes the swapped call only when the two JSTypes differ.
- The comparisons that covered one direction now cover both: boxed
  Symbol and BigInt, URLSearchParams and Headers.
- node's Set and Map arms walk the right collection and look each entry
  up in the left one, as node does. An entry without an identical
  counterpart matches only an entry that the other side lacks. When two
  or more entries needed such a match, the walk also runs in the other
  direction.
- A Map key that holds undefined is settled by has(), without a scan.
- On node's entry points the Error arm leaves the own enumerable
  properties to the generic walk.
- The copy of the wrapper block in specialObjectsDequalSlow was
  unreachable and is removed.
@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced with this script. It prints four results of util.isDeepStrictEqual and one time.

import util from "node:util";
const answer = compare => {
  try {
    return String(compare());
  } catch (error) {
    return error.name;
  }
};

// 1. Reads of one value under 1, 4 and 10 nested Maps.
let reads = 0;
const nested = depth => {
  let value = { get leaf() { reads++; return 1; } };
  for (let i = 0; i < depth; i++) value = new Map([["k", value]]);
  return value;
};
const counts = [1, 4, 10].map(depth => {
  reads = 0;
  util.isDeepStrictEqual(nested(depth), nested(depth));
  return reads;
});
console.log("1.", counts.join(" / "));

// 2. A Map that holds itself, against a copy.
const selfHolding = () => {
  const map = new Map();
  return map.set("self", map);
};
console.log("2.", answer(() => util.isDeepStrictEqual(selfHolding(), selfHolding())));

// 3. Two Maps with the same entries in another order. One key is the same object on both sides.
const key = { id: 1 };
console.log("3.", answer(() => util.isDeepStrictEqual(new Map([[key, "x"], [{ id: 1 }, "y"]]), new Map([[{ id: 1 }, "y"], [key, "x"]]))));

// 4. Sets that hold deep-equal members in different counts.
console.log("4.", answer(() => util.isDeepStrictEqual(new Set([{ a: 1 }, { a: 1 }, { a: 2 }]), new Set([{ a: 1 }, { a: 2 }, { a: 2 }]))));

// 5. Two Sets of 1,000 objects, 20 comparisons.
const set = () => new Set(Array.from({ length: 1000 }, (_, id) => ({ id })));
const a = set();
const b = set();
const start = performance.now();
for (let i = 0; i < 20; i++) util.isDeepStrictEqual(a, b);
console.log("5.", ((performance.now() - start) / 20).toFixed(2), "ms");
1 2 3 4 5
bun 1.3.14 2 / 2 / 2 true true true 65.39 ms
bun 1.4.3 4 / 32 / 2048 RangeError false true 52.43 ms
main (a407eae) 4 / 32 / 2048 RangeError false true 51.12 ms
node v26.3.0 2 / 2 / 2 true true false 1.08 ms
this PR 2 / 2 / 2 true true false 0.14 ms

Rows 1 to 3 are regressions of #34660. Rows 4 and 5 are older than that. Column 5 is the median of 3 runs of the script, and a run includes the first call.

#38510 is folded into this PR. This PR is a draft until the self-review of the folded diff is done.

@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:05 PM PT - Oct 16th, 2026

❌ @robobun, your commit 6a0989c has 1 failures in Build #126854 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 45457

That installs a local version of the PR into your bun-45457 executable, so you can run:

bun-45457 --bun

A failing expect() matcher prints a diff of both values. The negated matcher passes for an unequal pair, so the test no longer prints one.
…y points

assert.deepStrictEqual and util.isDeepStrictEqual accepted Sets that hold
deep-equal objects in different counts, and rejected Maps whose deep-equal
keys hold different values. Each entry that the lookup missed scanned the
other collection.

The two arms now walk the right collection and settle each entry that the
left one holds. The entries that are left pair one-to-one, in the probe
order of node's setObjectEquiv and mapObjectEquiv: the first open
candidate, then the last one, then the rest from the back.

The entry points that follow Jest keep their code.
@robobun robobun changed the title deepEquals: compare a same-typed pair once, not once per argument order deepEquals: compare a same-typed pair once, and pair Set and Map entries as node does Oct 16, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants