Skip to content

node:assert: read compared properties the way node's objEquiv does in strict deep equality - #47806

Draft
robobun wants to merge 3 commits into
mainfrom
robobun/b7864dd1/node-objequiv-reads
Draft

robobun wants to merge 3 commits into
mainfrom
robobun/b7864dd1/node-objequiv-reads

Conversation

@robobun

@robobun robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Draft: waits for #47605, which rewrites the same lines of BunCompiledObjectEquality.h.

Problem

Fix

  • NodeObjEquiv::ownPropertiesEqual is Node's key loop: [[Get]] on the second value, and on the first while the key orders agree, then its descriptor.
  • NodeObjEquiv::arrayElementsEqual is Node's element loop, for plain arrays and for arrays behind a Proxy.
  • Verified: test/js/node/assert/deep-equal.test.ts (226 of 1,052 tests fail on main). It now runs under node --test. Also vendored test-assert*.js and expect.test.js.
  • Self-reviewed twice: 74 concerns raised, 64 addressed. Notes lists the others.

Background

  • A Proxy's get and getOwnPropertyDescriptor traps can answer differently, so the trap that a comparison calls decides the answer.
  • Considered a second [[Get]] read only when a Proxy answered: two traps per key where Node calls one, and a node:vm global stays wrong.

Downsides

  • Pass to throw, as on Node: assert.deepStrictEqual(reactive, literal) when trap and target disagree and the key orders differ. Bun 1.4.1 to 1.4.4 pass it.
  • Pairs that hold a Proxy: +3,000 instructions for 8 keys (29%), Node's traps. Arrays with holes and a named key: +42%. Binary: +16,384 bytes.
  • Not changed: loose assert.deepEqual, Bun.deepEquals, expect(), assert.partialDeepStrictEqual, Map and Set entries, an Error's own keys. Notes lists every site.
Notes

Commits. The first moves deep-equal.test.ts from bun:test to node:test and node:assert, with no test added, removed or renamed (478 tests, green on main and on Node). The second is the change and its 574 tests. The third adds bench rows.

Order of landing.

  1. AOT: hangs, wrong answers, failed builds; bun test --aot follows #44833's mocks #47605 first. It turns the compiled-object walk to creation order and rewrites compareCompiledObjectProperties, where this PR adds 5 lines. After it merges I rebase and put Node's key order (the keys of the second object, strings before symbols) inside that walk, so that a pair of compiled objects with reordered keys no longer falls back to the generic walk.
  2. require.cache is a Proxy in Bun, and its getOwnPropertyDescriptor trap reports no value (src/js/builtins/CommonJS.ts:352). Node's loop reads the first value from its descriptor once the key orders differ, so util.isDeepStrictEqual(require.cache, copyWithReversedKeys, true) is true on Node and on main and false here. The two rows with the cache as second value go the other way (wrong on main, right here). Without skipPrototype Node answers false on all four rows, because the cache has a null prototype, which Bun does not see through the Proxy: main answers true on two of them and this PR on three. The trap has to report the module, which is a change of its own. AOT: hangs, wrong answers, failed builds; bun test --aot follows #44833's mocks #47605 rewrites that trap too, so it goes after it.
  3. assert.partialDeepStrictEqual is not in this PR. An earlier draft moved it onto the same reader, and that made assert.partialDeepStrictEqual(require.cache, { [file]: module }) throw. It follows once step 2 is in.

No issue reports this. It came out of a differential run against Node. Related only: #9103 reports a Vue reactive array under expect().toStrictEqual, which is another walk that this PR does not change (#45251 and #32948 are open on it). The repro of #28647 (a Proxy of an array against the array) passes before and after. #33068 (a JS port of Node's algorithm) is an older open attempt at the same answers.

Repro (no dependency, the Proxy unwraps a ref the way a Vue reactive object does):

import assert from "node:assert";
import util from "node:util";
const ref = value => ({ isRef: true, value });
const reactive = target =>
  new Proxy(target, {
    get(t, k, r) {
      const v = Reflect.get(t, k, r);
      return v !== null && typeof v === "object" && v.isRef === true ? v.value : v;
    },
  });
const lying = () => new Proxy({ a: 1 }, { get: (t, k, r) => (k === "a" ? 2 : Reflect.get(t, k, r)) });
const r = () => reactive({ count: ref(1), name: "x" });
assert.deepStrictEqual(r(), r()); // (1)
assert.deepStrictEqual({ a: 1 }, lying()); // (2)
util.isDeepStrictEqual({ a: 1 }, lying()), util.isDeepStrictEqual(lying(), { a: 1 }); // (3)
assert.deepStrictEqual(r(), { name: "x", count: 1 }); // (4)
assert.deepStrictEqual(new Proxy({ a: 1 }, {}), { a: 1 }); // (5)
Runtime (release binaries, linux x64) (1) (2) (3) (4) (5)
Node 26.3.0, Node 26.12.1 passes throws [false, false] throws passes
Bun 1.3.14 passes throws [false, false] throws throws
Bun 1.4.0 throws throws [false, false] throws throws
Bun 1.4.1, 1.4.2, 1.4.3, 1.4.4, main throws passes [true, false] passes passes
this PR passes throws [false, false] throws passes

Only (1) is a regression from a release that was right. Up to 1.4.0 Bun rejected every Proxy against a plain object (row 5), so its answers to (2) to (4) were Node's by accident. (2) and (3) are wrong since 1.4.1.

What changes, stated once. For plain objects, class instances, arrays, and Proxies of those, strict deep equality now lists the keys that Node lists and reads the values that Node reads (get traps and getters), as often and in the same order, and so gives Node's answer. An answer can still depend on the argument order where Node's does: of 15,563 seeded pairs that hold a Proxy, the two orders differ on 1,858 on Node, on 6,026 on main and on 1,634 here. The checks that run before the keys (constructor, the prototype, Symbol.toStringTag) and the arms for other kinds of object are as on main. The list is below.

What Node does (lib/internal/util/comparisons.js, v26.12.1, the version Bun reports): keyCheck takes Object.keys of the second value, then of the first, compares the counts, then adds the enumerable symbol keys of the first and of the second. objEquiv walks the second list. While keys1[i] === keys2[i] it compares a[key] with b[key]. After the first difference it takes Object.getOwnPropertyDescriptor(a, key), needs it to be enumerable, and compares descriptor.writable !== undefined ? descriptor.value : a[key] with b[key]. Arrays pass no keys1, so each of their other keys goes the descriptor way. Then, for i < a.length (read again each time): b[i], then sparseArrayEquiv from the first index that b does not own, else a[i], and both again to compare them.

What the diff does, by read site (checkPrototypes is the template flag of the two Node instantiations):

  • Generic walk: listOwnKeys, then ownPropertiesEqual. Two ordinary objects list their keys once each. A type with its own listing (a Proxy) is listed as Node lists it, strings of the second, strings of the first, symbols of the first, symbols of the second, so the same traps run in the same order.
  • Two objects of one structure: values are still read by offset. Where the structure can hold a symbol key (!isQuickPropertyAccessAllowedForEnumeration()), string-keyed pairs are compared before symbol-keyed pairs. Plain Bun compared them in insertion order and the compiled-object walk compared strings first, so the two printed different things on main. The new AOT program fails on main for this reason.
  • Arrays: nodeArraysEqual. The other keys are compared first, and only when a structure has a named property, so a plain array no longer builds two empty key lists. Then arrayElementsEqual, which reads an accessor element with [[Get]] (main treats it as a hole), reads the length of the first array again for each element, and goes to sparseArraysEqual at the first hole of the second array. That walk keeps the index keys of an array of plain elements as numbers.
  • Arrays that hold a Proxy: proxiedArraysEqual, the same element loop with a trap for every read. It reads length with [[Get]] (the change of node:assert: compare a proxied array's length through its get trap #41221, which can close when this merges), asks a Proxy for no descriptor of an index, and with no other key reads length and size of the second array once more, as keyCheck does. [1, 2] against a Proxy of [3, 4] that answers 0 for size is equal on Node, and here.
  • Named keys of typed arrays, DataViews and the like: nonIndexOwnPropertiesEqual calls the reader with no keys1.
  • internalBinding('util').getOwnNonIndexProperties (UtilInspect.cpp) and the Proxy arm share one lister, Bun::getOwnNonIndexPropertyNames.
  • A function or a Proxy of one that reaches the generic walk is equal to nothing but itself. Node returns false for anything that typeof does not call an object.
  • Compiled objects (BunCompiledObjectEquality.h): in Node mode the walk returns to the generic path unless both objects list the same keys in the same order. See "Order of landing".
  • The read that 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 added for the second value (its own-property slot, so that an inherited key could not stand in for an own one) is gone. Node has that guarantee on the first value, and the reader keeps it there.

Measurements. Release builds of main 3f3a9ff and of this diff on 271b65e (one docs commit later). perf and valgrind cannot be installed in my container. Instruction counts come from the same counter (instructions:u through perf_event_open, inherited by threads, exact on a calibrated loop): 2,000,000 util.isDeepStrictEqual calls per case, median of 5 runs, PR minus main per call. Spread between runs is under 6 per call.

Pair main PR minus main
8 keys, one structure 2,270 +2 (inside the spread)
8 keys, one structure, a symbol key 2,270 -11
8 keys, one structure, a non-enumerable key 2,190 -43
8 keys, JSON.parse output and a literal 5,900 -515
32 keys, JSON.parse output and a literal 19,340 -2,259
8 keys, reversed key order 5,890 -452
32 keys, reversed key order 19,170 -2,063
[1, 2, 3] 2,090 -674
[1, 2, 3] with 1 named key 2,630 -236
[1, 2, 3] with 8 named keys 6,120 -481
two RegExp match arrays 5,130 -175
[[1, 2], [3, 4], [5, 6], [7, 8]] 9,490 -2,950
[1, , 3, , 5] 2,520 -422
[1, , 3, , 5] with 1 named key 3,070 +1,280
Uint8Array(3) 4,550 +10
Uint8Array(3) with 1 named key 5,110 -24
Uint8Array(3) with 8 named keys 8,910 -268
8 keys, a literal and a Proxy of one 10,520 +3,005
8 keys, two Proxies 15,980 +3,240
1,000 elements, a Proxy with no trap, per element 1,730 +67
1,000 elements, second array behind a get trap, per element 1,520 +1,249
  • The rows that cost more are the ones where Node's own calls run: two ownKeys calls for each Proxy, a get trap twice per element, a second read of the named keys after a hole.
  • One [1, 2, 3] call single-stepped in gdb: mi_malloc calls 2 to 0, indirect calls 4 to 2. An 8-key pair of different structures: indirect calls 12 to 4, mi_malloc 2 to 2.
  • Wall clock from bench/snippets/assert.mjs, ns per call, one run each (Node 26.12.1 / Bun 1.3.14 / main / PR): parsed and literal 200 / 299 / 451 / 360. Different key order 297 / 209 / 449 / 373. Arrays 118 / 86 / 108 / 73. Arrays with holes and a named key 496 / 107 / 154 / 214. 1e6 elements and one hole 87 ms / 8.8 ms / 8.6 ms / 6.1 ms. 1,000 elements behind a get trap 239 µs / (throws) / 112 µs / 213 µs.
  • Release text (nm -S): the two Node instantiations 17,621 to 18,587 and 13,255 to 14,044 bytes. New: proxiedArraysEqual 5,411, sparseArraysEqual 3,323, nodeArraysEqual 1,152 twice, the shared lister 1,278 (the host function that held it shrinks by 307). The five other instantiations of Bun__deepEquals are 5,242, 5,682, 5,825, 5,030 and 5,985 bytes on both builds. .text grows by 17,728 bytes and the stripped binary by 16,384 (140,474,656 to 140,491,040).

Answers against Node.

  • Node's comparisons.js v26.12.1 runs unmodified inside Bun behind a shim of its bindings, so the native answer and Node's algorithm can be compared in one process. 200,000 seeded pairs, both orders, strict: main differs on 14,727 of 400,000 cells, this PR on 1,917. Of those, 1,891 hold a Map or a Set, and 26 are two objects of one structure whose nested getter changes a later key of the parent (below). The log of getters and get traps differs on 18 cells of 200,000 that hold no Map or Set, the same class.
  • Against the Node binary, 60,000 seeded pairs, both orders: 3,791 strict cells go from wrong to right and 5 from right to wrong. The 5 hold a Map whose value is an array with a getter that changes that array (below). assert.partialDeepStrictEqual changes on 0 of 120,000 cells.
  • A second seeded corpus of 15,563 pairs that hold a Proxy (31,126 cells): main differs from Node on 9,271, this PR on 233, all with a Map or a Set. Of the 9,053 cells that change, 1,686 go from not equal to equal, 3,238 from equal to not equal, 2,938 from an answer to a thrown error and 1,069 from a thrown error to an answer. 400,000 cells of flat objects with own getters that delete, add, hide or throw: main differs on 42,103, this PR on 0.
  • BUN_JSC_validateExceptionChecks=1 on deep-equal.test.ts: 1,052 pass, no unchecked site.

Answers that change with no Proxy involved, all to Node's:

  • A getter that deletes a later key: { a: 1, b: undefined } and { get a() { delete this.b; return 1 }, b: 2 }, and the same on the named keys of an array and of a Uint8Array. Node lists the keys before it reads a value.
  • { a: 1, b: 2 } and { get a() { throw }, [sym]: 2 }: false, because the string-key counts differ. Main throws the getter's error.
  • The global of a node:vm context over a sandbox that refers to itself, against a plain object that refers to itself.
  • An array with an element that is an accessor: [1, 2, 3] is equal to [1, <getter that answers 2>, 3], and two arrays whose accessor elements differ are not equal. Main answers false and true.
  • An array whose named getter changes its length: [1, 2, 3] with a getter x that pushes a fourth element is not equal to [1, 2, 3] with x: 1.
  • [1] with x: {} against [2] with a throwing getter x throws, because the other keys of an array come before its elements.
  • Two distinct Proxies of functions are not equal.

Read sites in strict mode that this PR leaves as they are. Each has a row or a test in deep-equal.test.ts that is an expected failure on Bun, unless noted.

Not strict mode, unchanged: loose assert.deepEqual (#23877, and the known differences in docs/runtime/nodejs-compat.mdx), assert.partialDeepStrictEqual (#47675 and #45244 are open on it), Bun.deepEquals and expect() (#9103, #45251, #32948, #42238, #37797). The matrix records what loose mode answers for every new row.

Self-review. Two rounds on two versions of the diff: 32 and 42 concerns. Not done, with the reason:

  • The compiled-object walk still yields for reordered keys, which costs about twice the instructions under --aot for such a pair. It waits for AOT: hangs, wrong answers, failed builds; bun test --aot follows #44833's mocks #47605 (Order of landing, 1).
  • The require.cache descriptor (Order of landing, 2). It was in an earlier draft and was taken out because it is a change to module loading.
  • Map and Set own keys before entries, the same-structure snapshot, and the Error arm: named above and pinned, not converted. Each is a walk of its own.
  • A structure walk in Node's key order for two data-only objects of different structures would be faster than the by-name reader (Node takes 200 ns where this PR takes 360 and main 451). It is a speed change with no answer in it.
  • The callable check sits in the generic walk and not at the top of Bun__deepEquals: at the top it costs every compared object about 13 instructions.
  • Mutation testing of the new clauses was done on the earlier version, and the four rows it asked for are added. It was not run again on this version.

Node 26.3.0 and 26.12.1 agree on every test in the file except one: the global of a node:vm context over a Proxy sandbox lists the sandbox keys on 26.12.1 and none on 26.3.0. The test asserts what 26.12.1 does.

Suites run. Debug and ASAN build: test/js/node/assert/ (1,158 pass), 34 vendored files (test-assert*, test-util-isDeepStrictEqual, test-util-inspect*, test-repl-tab-complete*, test-whatwg-url-custom-deepequal), test/js/bun/bun-object/deep-equals*.test.ts, test/js/node/util/node-inspect-tests/, and the AOT program built by hand (a debug build is no canary, so run-parallel.ts skips it). Release build: the same plus test/js/bun/test/expect.test.js (464 pass), test/js/node/vm/vm.test.ts, test/js/node/util/, test/cli/test/test-aot-deep-equality.test.ts, and test/aot/run-parallel.ts on the new program, typed-objects/deep-equality/main.ts and library-modules/assertions.js. node --test test/js/node/assert/deep-equal.test.ts on Node 26.12.1: 1,052 pass. test/aot/builtins/native-contract.test.ts needs the engine's source folder, which my container does not have. It fails the same way on main.


[human-review] gate passed · iteration 4 · 9 files touched

fails on main (without fix)
ASAN without fix: 226 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/assert/deep-equal.test.ts
bun test v1.4.4 (367d939d9)

test/js/node/assert/deep-equal.test.ts:
(pass) assert.deepStrictEqual > rejects 0 and -0 [71.52ms]
(pass) assert.deepStrictEqual > accepts NaN and NaN [4.35ms]
(pass) assert.deepStrictEqual > rejects [0] and [-0] [33.80ms]
(pass) assert.deepStrictEqual > rejects '1' and 1 [7.59ms]
(pass) assert.deepStrictEqual > rejects ['1'] and [1] [4.43ms]
(pass) assert.deepStrictEqual > rejects '+00000000' and false [3.21ms]
(pass) assert.deepStrictEqual > rejects '' and false [3.14ms]
(pass) assert.deepStrictEqual > rejects null and undefined [3.60ms]
(pass) assert.deepStrictEqual > rejects { a: -0 } and { a: 0 } [7.55ms]
(pass) assert.deepStrictEqual > rejects 1n and 1 [4.49ms]
(pass) assert.deepStrictEqual > rejects new String('a') and 'a' [6.55ms]
(pass) assert.deepStrictEqual > accepts two boxed equal strings [4.12ms]
(pass) assert.deepStrictEqual > accepts two boxed equal numbers [4.70ms]
(pass) assert.deepStrictEqual > rejects boxed -0 and boxed 0 [5.44ms]
(pass) assert.deepS
... (truncated)

release without fix: all passed
bun test v1.4.4-canary.1 (c4405bc7e)

test/js/node/assert/deep-equal.test.ts:
(pass) assert.deepStrictEqual > rejects 0 and -0 [4.63ms]
(pass) assert.deepStrictEqual > accepts NaN and NaN [0.14ms]
(pass) assert.deepStrictEqual > rejects [0] and [-0] [1.07ms]
(pass) assert.deepStrictEqual > rejects '1' and 1 [0.23ms]
(pass) assert.deepStrictEqual > rejects ['1'] and [1] [0.07ms]
(pass) assert.deepStrictEqual > rejects '+00000000' and false [0.04ms]
(pass) assert.deepStrictEqual > rejects '' and false [0.06ms]
(pass) assert.deepStrictEqual > rejects null and undefined [0.05ms]
(pass) assert.deepStrictEqual > rejects { a: -0 } and { a: 0 } [0.20ms]
(pass) assert.deepStrictEqual > rejects 1n and 1 [0.09ms]
(pass) assert.deepStrictEqual > rejects new String('a') and 'a' [0.14ms]
(pass) assert.deepStrictEqual > accepts two boxed equal strings [0.14ms]
(pass) assert.deepStrictEqual > accepts two boxed equal numbers [0.06ms]
(pass) assert.deepStrictEqual > rejects boxed -0 and boxed 0 [0.05ms]
(pass) assert.deepStrictEqual > accepts two boxed booleans [0.08ms]
(pass) assert.deepStrictEqual > accepts two boxed symbols [0.12ms]
(pass) assert.deepStrictEqual > accepts two boxe
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/assert/deep-equal.test.ts
bun test v1.4.4 (367d939d9)

test/js/node/assert/deep-equal.test.ts:
(pass) assert.deepStrictEqual > rejects 0 and -0 [58.14ms]
(pass) assert.deepStrictEqual > accepts NaN and NaN [3.97ms]
(pass) assert.deepStrictEqual > rejects [0] and [-0] [31.54ms]
(pass) assert.deepStrictEqual > rejects '1' and 1 [7.16ms]
(pass) assert.deepStrictEqual > rejects ['1'] and [1] [11.90ms]
(pass) assert.deepStrictEqual > rejects '+00000000' and false [3.61ms]
(pass) assert.deepStrictEqual > rejects '' and false [3.14ms]
(pass) assert.deepStrictEqual > rejects null and undefined [3.50ms]
(pass) assert.deepStrictEqual > rejects { a: -0 } and { a: 0 } [7.29ms]
(pass) assert.deepStrictEqual > rejects 1n and 1 [4.04ms]
(pass) assert.deepStrictEqual > rejects new String('a') and 'a' [5.99ms]
(pass) assert.deepStrictEqual > accepts two boxed equal strings [3.67ms]
(pass) assert.deepStrictEqual > accepts two boxed equal numbers [3.91ms]
(pass) assert.deepStrictEqual > rejects boxed -0 and boxed 0 [4.72ms]
(pass) assert.deep
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 558ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/422] build.rs build_script_build
[2/422] gen generated_host_exports.rs
generated_host_exports.rs: 119 exports (lazy=4, generic=115, rust=0); 288 extern-C blocks audited
[3/421] gen cpp.rs (cppbind)
[4/421] rustc bun_safety 
[5/421] rustc bun_zlib_sys 
[6/421] rustc bun_output 
[7/421] rustc bun_zstd 
[8/421] rustc bun_perf 
[9/421] rustc bun_base64 
[10/421] rustc bun_brotli 
[11/421] rustc bun_errno 
[12/421] rustc bun_boringssl_sys 
[13/421] rustc bun_clap 
[14/421] rustc bun_picohttp 
[15/421] rustc bun_ptr 
[16/421] rustc bun_cares_sys 
[17/421] rustc bun_tcc_sys 
[18/421] rustc bun_lsquic_sys 
[19/421] rustc bun_valkey 
[20/421] rustc bun_text_diff 
[21/421] rustc bun_zlib 
[22/421] rustc bun_paths 
[23/421] rustc bun_collections 
[24/421] rustc bun_url 
[25/421] rustc bun_shell_parser 
[26/421] rustc bun_semver 
[27/421] rustc bun_png 
[28/421] rustc bun_analytics 
[29/421] rustc bun_which 
[30/421] rustc bun_node_path 
[31/421] rustc bun_spawn_sys 
[32/421] rustc bun_http_types 
[33/421] rustc bu
... (truncated)
diff hotspot
bench/snippets/assert.mjs                          |   92 +
 src/jsc/bindings/BunCompiledObjectEquality.h       |    7 +-
 src/jsc/bindings/NodeObjEquiv.cpp                  |  216 +++
 src/jsc/bindings/NodeObjEquiv.h                    |  171 ++
 src/jsc/bindings/UtilInspect.cpp                   |   45 +-
 src/jsc/bindings/UtilInspect.h                     |    5 +
 src/jsc/bindings/bindings.cpp                      |  134 +-
 ...equality-walks-the-keys-of-its-second-value.cjs |   74 +
 test/js/node/assert/deep-equal.test.ts             | 1814 +++++++++++++++++++-
 9 files changed, 2410 insertions(+), 148 deletions(-)

gate history · 1 passed · 0 rejected · iteration 4

evidence per changed file
file                                                      reads  edits  tests
bench/snippets/assert.mjs                                     1      1    104
src/jsc/bindings/BunCompiledObjectEquality.h                  0      0    104
src/jsc/bindings/NodeObjEquiv.cpp                             1      2    103
src/jsc/bindings/NodeObjEquiv.h                               2      5    103
src/jsc/bindings/UtilInspect.cpp                              0      0    103
src/jsc/bindings/UtilInspect.h                                0      0    103
src/jsc/bindings/bindings.cpp                                16     11    105
…ct-deep-equality-walks-the-keys-of-its-second-value.cjs      0      1    114
test/js/node/assert/deep-equal.test.ts                        4     10    103

The file imported bun:test, so nobody could run its expectations on Node.
It now uses node:test and node:assert. No test is added, removed or
renamed: the same 478 tests pass on Bun and on Node 26.12.1. A row that
Bun gets wrong is an expected failure on Bun and an ordinary test on Node.
… strict deep equality

util.isDeepStrictEqual and assert.deepStrictEqual walked the keys of the
first value, read it with [[Get]], and took the second value's property
from its own-property slot. Node does the reverse, so a Proxy's get trap
on the second value never ran and the answer depended on the argument
order.

Bun::NodeObjEquiv::ownPropertiesEqual is node's key loop. It walks the
keys of the second value and reads that value with [[Get]]. It reads the
first value with [[Get]] while both values list their keys in the same
order, and from its own property descriptor after the first difference.
String keys come before symbol keys, and the key counts are compared
before a value is read. The generic walk and the named keys of arrays
and typed arrays use it.

Bun::NodeObjEquiv::arrayElementsEqual is node's element loop. A plain
array reads an element that is an accessor, reads the length of the
first array again for each element, and goes to sparseArrayEquiv at the
first hole of the second array. A pair of arrays that holds a Proxy
reads the lengths, the other keys and every element through the traps
that node calls.

Two objects of one structure compare string-keyed values before
symbol-keyed values. A function, or a Proxy of one, is equal to nothing
but itself. In node mode the compiled-object walk yields to the generic
walk when the two objects do not list the same keys in the same order.

assert.partialDeepStrictEqual, loose assert.deepEqual, Bun.deepEquals
and expect() do not change.
@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:07 PM PT - Nov 2nd, 2026

❌ @robobun, your commit eab084e has 41 failures in Build #137077 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47806

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

bun-47806 --bun

@github-actions github-actions Bot added the claude label Nov 2, 2026
@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft. It waits for #47605, which rewrites the same lines of BunCompiledObjectEquality.h.

How I reproduced it, on the released binaries of Bun 1.4.1 to 1.4.4 and on main:

import util from "node:util";
const p = new Proxy({ a: 1 }, { get: (t, k, r) => (k === "a" ? 2 : Reflect.get(t, k, r)) });
console.log(util.isDeepStrictEqual({ a: 1 }, p), util.isDeepStrictEqual(p, { a: 1 }));
// Bun: true false. Node 26.12.1: false false.

test/js/node/assert/deep-equal.test.ts: 226 of 1,052 tests fail on main, 0 on this branch. The same file passes under node --test on Node 26.12.1.

CI (build 137077): no test that this PR adds or touches failed. The 28 red test files on Linux and Windows are the same 28 that are red on main (build 137093): 27 under test/aot/, and test/bundler/compile-aot-size.test.ts, which reports the same byte count on both builds. On macOS arm64 test/aot/engine/native-5.test.ts fails on the same engine assertion (property-lookup/absence-filter-names-what-stops-stores) as native-6.test.ts does on Linux. The other failures passed on a retry. The four macOS x64 test jobs expired before an agent took them, so that lane has no result.

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