Skip to content

deepEquals: match a Map entry on its key and value together - #44636

Open
robobun wants to merge 4 commits into
mainfrom
robobun/9af1afae/deep-equals-map-entries
Open

robobun wants to merge 4 commits into
mainfrom
robobun/9af1afae/deep-equals-map-entries

Conversation

@robobun

@robobun robobun commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #34830

Problem

  • new Map([[/a/, "x"], [/a/, "y"]]) is not equal to its own copy (Bun test toEqual incorrect comparison of maps with referential keys. #34830). The Map arm of specialObjectsDequal (src/jsc/bindings/bindings.cpp) compared only the value of the first deep-equal key.
  • The arm read a key that holds undefined as absent, so each such entry walked the other Map: 653 ms for two 10,000-entry Maps through toEqual.
  • A cycle through Set or Map entries only throws RangeError: Maximum call stack size exceeded.

Fix

  • On the entry points that follow Jest, the Map arm looks for an entry with an equal key and an equal value.
  • One lookup (mapValueOrEmpty) tells an absent key from an undefined value. The same Maps take 0.6 ms.
  • A Set or Map entry joins the cycle stack.
  • Verified: test/js/bun/bun-object/deep-equals.test.ts, test/js/bun/test/expect.test.js, test/js/node/assert/deep-equal.test.ts (stock bun fails 62, 6 and 5 new tests), and test/js/node/assert/. Self-reviewed: 9 concerns raised, 9 addressed.

Background

Downsides

Notes

Rules after this PR

  • Map, on Bun.deepEquals, expect() and assert.deepEqual: a left entry is settled when the right Map holds its key with an equal value. If not, the arm walks the right Map for an entry whose key and value are both equal. This is the rule of Jest and Vitest. Before, the walk stopped at the first deep-equal key and compared only that value.
  • Map, on assert.deepStrictEqual and util.isDeepStrictEqual: the answers of main stay. A key that both sides hold with unequal values ends the comparison, and the walk stops at the first deep-equal key. Node pairs entries one-to-one, which a walk cannot do, and the rule above would accept Maps that node rejects. The one change on these entry points is the undefined lookup.
  • Without asymmetric matchers a primitive equals only itself. So a primitive member or key that the lookup misses ends the comparison without a walk. With matchers enabled the walk passes over pairs of primitives.
  • Cycle stack: an entry joins it when it is a Set or a Map. A plain object or an array that is an entry does not join it, as on main.

Measurements (release builds of the merge base 13a98b0 and of commit 0d5f669, the same tree except bindings.cpp)

  • Size (size bun-profile): text 80,759,109 -> 80,761,157 B. nm -S -C: specialObjectsDequal x7 30,485 -> 32,607 B. No other symbol changes size.
  • Two 10,000-entry Map<string, undefined>, CPU time: expect().toEqual 0.6 ms (main 652.6 ms), Bun.deepEquals 0.5 ms (811.7 ms), util.isDeepStrictEqual 1.0 ms (1,651.7 ms).
  • Instructions for one warm call, this branch (main). They are single-stepped in gdb on bun-profile, because perf and valgrind are not available on the machine.
    • No Set or Map, {a: 1, b: {c: 2}}: Bun.deepEquals 1,466 (1,466), util.isDeepStrictEqual 2,280 (2,284).
    • One-object Set: Bun.deepEquals 1,409 (1,391), util.isDeepStrictEqual 4,015 (3,977).
    • Through Bun.deepEquals: reordered 3-object Set 5,337 (5,242), unequal 16-object Sets 12,806 (12,575), same-order 16-object Sets 105,282 (103,424). No mallocs on either build.
    • 100-entry string-keyed Maps whose first value differs: Bun.deepEquals 722 (716), util.isDeepStrictEqual 1,100 (1,103), expect().not.toEqual 9,713 (1,272). With matchers enabled a matcher key of the right Map could still match the left entry, so the arm walks the right Map once before it answers.
    • 8 nested string-keyed Maps: expect().toEqual 7,896 (5,477), util.isDeepStrictEqual 869,525 (692,447). Each nested Map is an entry that joins the cycle stack, and each push searches the stack. The second number is large on both builds because main compares a Set or a Map once per argument order on node's entry points. deepEquals: compare a same-typed pair once, and pair Set and Map entries as node does #45457 removes that.
  • Entries that the lookup settles, instructions per entry (difference of n=96 and n=32, divided by 64), this branch (main):
    • expect().toEqual: Set<string> 225.0 (225.0), Map<string, string> 386.6 (387.5), Map<string, {v}> 840.6 (841.5).
    • util.isDeepStrictEqual: Set<string> 447.8 (457.8), Map<string, string> 734.8 (746.8), Map<string, {v}> 2,404.8 (2,420.8).
  • bench/deepEqual/map.js, new row "deepEqual Map with undefined values": 632 ms -> 476 µs per iteration. The two existing rows move inside the noise of three interleaved runs (557 to 865 µs on both builds).

Differentials (seeded random pairs, both argument orders. The member pool has primitives, plain, dictionary-mode, undefined-property and non-enumerable-property objects, arrays, boxed values, nested Sets and Maps, members shared by identity, and asymmetric matchers for the expect() runs.)

  • Sets through Bun.deepEquals loose and strict, toEqual and toStrictEqual against main: 0 of 4,800,000 verdicts differ.
  • toEqual against Vitest 5.0.3 equals(a, b, [iterableEquality]): Maps 0 of 400,000 differ (main 51,810). Sets 0 of 200,000 (main 0).
  • util.isDeepStrictEqual against main: 0 of 1,200,000 verdicts differ.

Not in this PR

Self-review

The review ran on #38510. It asked for this part to merge first as its own change. Its concerns that apply here: land the #34830 fix alone, name and pin the undefined-value walk, add a bench row for it, cover the two skipPrototype entry points, use a fixture that Jest agrees with for the insertion-order test, pin each of the four places that pass the cycle-stack argument, keep assert.deepEqual out of the rows that pin Jest's answers, change no answer of node's strict entry points to one that node does not give, and state that the n*n walk stays.

…defined values by the key lookup

The Map arm of specialObjectsDequal took the first deep-equal key of the
other Map and compared only the value under that key. It now looks for an
entry whose key and value are both equal, as Jest's iterableEquality does.

A key that holds undefined was read as an absent key, so each such entry
walked the other Map. One lookup now tells the two apart.

A Set or a Map that is an entry joins the cycle stack, so a cycle that runs
only through entries ends. node's entry points record every entry.

Without asymmetric matchers a primitive that the lookup misses ends the
comparison, because a primitive equals only itself.
@robobun

robobun commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced with this script on Bun 1.4.2.

import { expect } from "bun:test";

// #34830: prints "not equal". This branch prints "equal".
const regexKeys = () => new Map([[/a/, "x"], [/a/, "y"]]);
console.log(Bun.deepEquals(regexKeys(), regexKeys()) ? "equal" : "not equal");

// A key that holds undefined walks the other Map: 0.65 to 1.1 s. This branch: 0.6 ms.
const undefinedValues = () => new Map(Array.from({ length: 10000 }, (_, i) => ["k" + i, undefined]));
const a = undefinedValues(), b = undefinedValues();
const start = performance.now();
expect(a).toEqual(b);
console.log((performance.now() - start).toFixed(1), "ms");

// A cycle through entries only: RangeError: Maximum call stack size exceeded. This branch prints true.
const cycle = () => {
  const outer = new Set(), inner = new Set();
  outer.add(inner);
  inner.add(outer);
  return new Set([outer]);
};
console.log(Bun.deepEquals(cycle(), cycle()));

This PR is the first part of #38510. It changes only the Set and Map arms. The node pairing stays in #38510.

@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 00caa856-76b2-4543-8b55-223d8c518c11
📥 Commits

Reviewing files that changed from the base of the PR and between 435d60b and 0d5f669.

📒 Files selected for processing (3)
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/bun-object/deep-equals.test.ts
  • test/js/node/assert/deep-equal.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.

Walkthrough

Map and Set deep comparisons update entry matching and cycle tracking. Map lookup distinguishes an absent key from a key whose value is undefined. Tests cover collection matching across Bun, Jest, and Node entry points.

Changes

Collection deep equality

Layer / File(s) Summary
Map and Set matching
src/jsc/bindings/bindings.cpp
Map comparison distinguishes absent keys from undefined values, compares values for direct key matches, and falls back to matching key-value pairs. Set and Map matching update primitive handling and cycle tracking.
Cross-entry-point tests and benchmark
test/js/bun/bun-object/deep-equals.test.ts, test/js/bun/test/expect.test.js, test/js/node/assert/deep-equal.test.ts, bench/deepEqual/map.js
Tests cover reordered and mismatched entries, duplicate matches, cycles, asymmetric matchers, getter exceptions, and undefined values across comparison entry points. The benchmark compares Maps with undefined values.

Suggested reviewers: cirospaciari

Priority: ➖ Normal

Severity of issue fixed: Low

Merge Risk: ⚪ Minimal · up to 0d5f6

The supplied review evidence identifies no issue that needs correction before merge. Merge after the normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Linked Issues check Passed #34830 requires expect to consider Maps equal when they have referentially distinct but deeply equal RegExp or Date keys and matching values. The Map arm in src/jsc/bindings/bindings.cpp now searc…
Out of Scope Changes check Passed The changes remain within deep comparison of Maps and Sets. Map and Set cycle-stack handling supports recursive entry comparisons. Primitive-candidate skips and the benchmark support the fallback look…
Title check Passed The title clearly and concisely describes the main change: Map entries now match using both their keys and values during deep equality checks.
Description check Passed The description explains the problem, implementation, verification, performance impact, limitations, and follow-up work. It does not use the template headings exactly, but it provides the required inf…
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline finding, I also checked the new Set-arm primitive short-circuit (the hash probe already settles primitive identity when matchers are off, so skipping the walk does not change verdicts), the mapValueOrEmpty empty-vs-undefined distinction (the absent-key path still falls through to the entry walk, so a wrong sentinel would cost time, not correctness), and that every Bun__deepEquals and lookup call in both rewritten arms is followed by RETURN_IF_EXCEPTION under the existing ThrowScope.

Extended reasoning...

The change rewrites the Set and Map arms of specialObjectsDequal in src/jsc/bindings/bindings.cpp, shared by Bun.deepEquals, expect matchers, and node assert/util, and adds tests in three files; it touches no auth, crypto, or input-parsing surface. One verified finding on node-strict Map semantics is posted inline and another verified finding was withheld, so this is not approvable; the note records the adjacent paths that were examined and ruled out.

Comment thread src/jsc/bindings/bindings.cpp Resolved
assert.deepStrictEqual and util.isDeepStrictEqual pair entries one-to-one
in node. The rule that lets two entries match the same entry would accept
Maps that node rejects, so these entry points keep the answer of the first
deep-equal key.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

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.

Bun test toEqual incorrect comparison of maps with referential keys.

2 participants