Skip to content

Treat Proxy as transparent in deep equality and toMatchObject - #32948

Open
robobun wants to merge 7 commits into
mainfrom
farm/e5cd7583/deepequals-proxy-strict
Open

Treat Proxy as transparent in deep equality and toMatchObject#32948
robobun wants to merge 7 commits into
mainfrom
farm/e5cd7583/deepequals-proxy-strict

Conversation

@robobun

@robobun robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Fixes #9103

A transparent new Proxy(target, {}) fails toStrictEqual against its own target, and a Proxy over an array fails toMatchObject. Both affect every value coming out of Vue reactive(), Pinia, MobX, Immer and most DI containers. Node and Jest report equal.

Repro

const t = { a: 1 };
Bun.deepEquals(new Proxy(t, {}), t, true);  // bun: false   node/jest: true
Bun.deepEquals(new Proxy(t, {}), t);        // bun: true  (loose was already correct)

expect(new Proxy([{ a: 1 }], {})).toMatchObject([{ a: 1 }]);  // bun: throws   jest: passes

The same root cause in strict mode also produces a false positive: any two Proxies pass the type gate, so two Proxies over different-class targets compare equal.

class Foo { constructor() { this.a = 1; } }
Bun.deepEquals(new Proxy({ a: 1 }, {}), new Proxy(new Foo(), {}), true);
// bun: true   node/jest: false

Review on this PR caught a third case: the array fast path is the only place deepEquals compares array length, and it is skipped when a Proxy is involved. The generic path only enumerates own enumerable properties, and a trailing hole contributes none while length is not enumerable, so a Proxy-over-array compared equal to an array with a different length once the type gate let the pair through.

Bun.deepEquals(new Proxy([1, 2, 3], {}), [1, 2, 3, ,], true);
// node/jest: false

Cause and fix

Three independent sites, all "an internal trap-bypassing accessor gives the wrong answer for a Proxy", in src/jsc/bindings/bindings.cpp:

1. Bun__deepEquals, strict type gate. The gate compares JSObject::calculatedClassName, which for a ProxyObject skips the __proto__.constructor derivation (proxies set OverridesGetPrototype, and the helper only follows getPrototypeDirect()) and falls back to classInfo()->className, the literal string "ProxyObject". That never equals "Object"/"Array"/"Foo", so a Proxy is unequal to every non-Proxy, and any two Proxies pass trivially.

Fix: when either side is a ProxyObjectType, compare the observable prototypes via getPrototype(globalObject) (which goes through the getPrototypeOf trap). A transparent Proxy reports its target's prototype, so it compares the way its target would. Non-Proxy comparisons are unchanged.

1b. Bun__deepEquals, array length for Proxies. When both sides are arrays and a Proxy forced them off the fast path, compare the observable length through the traps before the generic property walk, the same way point 2 below handles Bun__deepMatch. This also fixes the pre-existing false positive where two Proxies over different-length arrays compared equal (both reported "ProxyObject").

1c. Bun__deepEquals, own-key sets for Proxies. The generic slow path establishes key-set equality by comparing counts and then probing getIfPropertyExists() (which uses InternalMethodType::HasProperty). A Proxy with a has trap that returns true unconditionally makes the probe vacuous, so two objects with disjoint key sets but equal counts compared equal (asymmetrically). When a Proxy is involved and counts match, additionally verify each left own-key appears in the right own-key array, as Node does. A get trap alone cannot trigger this (the existence probe uses has, not get), so the Vue/MobX/Immer case was already correct.

2. Bun__deepMatch, array length gate. getArrayLength() reads the internal indexed butterfly, which a ProxyObject does not have, so the "arrays should match exactly" gate always compared 0 against the real length. Bun__deepEquals already guards its own array fast path with !isProxy(); Bun__deepMatch was the missing sibling. When a Proxy is involved, read the observable length through the traps.

3. Bun__deepMatch, property enumeration. Built with PrivateSymbolMode::Include, unlike every site in Bun__deepEquals, which sweeps JSC-internal private builtin names from Array.prototype (@entries, @values, ...) into the comparison. Plain arrays get away with it because both sides resolve the same builtin function object and reference equality short-circuits, but a Proxy breaks the private-name chain walk. Users can never define private symbols, so use Exclude.

Those stray private names turned out to be the only thing making expect({}).toMatchObject([]) fail (the existing test at expect.test.js:3478 caught the regression). The real invariant, an array expectation only matches an array, was never checked; it is now explicit, the same way Bun__deepEquals checks v1Array != v2Array. The reverse direction is deliberately not gated: a plain-object expectation is a key subset, so a received array may satisfy it (expect.test.js:3473-3474 asserts this).

Exceptions from the traps (including a revoked Proxy) propagate via RETURN_IF_EXCEPTION; Node throws there too.

Verification

New cases live next to the existing Proxy coverage in test/js/bun/test/expect.test.js. Every toStrictEqual / toMatchObject expected value was confirmed against real Jest 30, and the util.isDeepStrictEqual / assert.deepStrictEqual values against Node 26.

USE_SYSTEM_BUN=1 bun test test/js/bun/test/expect.test.js -t "toStrictEqual treats Proxy as transparent"
  0 pass, 1 fail
USE_SYSTEM_BUN=1 bun test test/js/bun/test/expect.test.js -t "toMatchObject treats a Proxy over an array as an array"
  0 pass, 1 fail
bun bd test test/js/bun/test/expect.test.js
  400 pass, 0 fail

test/js/node/assert/ (150 pass), jest-extended.test.js, test/js/bun/util/inspect.test.js all pass. toMatchSnapshot.test.ts's error snapshots failure reproduces on the unpatched release binary and is pre-existing.

One existing test in this file is modified: deepEquals Set/Map stress test took over four minutes under debug+ASAN and timed out at 5000ms (1s on a release build), which prevented the suite from going green. It is loose mode over Sets and untouched by this change. Its cost is quadratic in the element count, so that is reduced from 150 to 20 (and the iterations from 2000/1000 to 500/250), which still constructs the JSSetIterator/JSMapIterator fallback thousands of times in under 2 seconds. To confirm nothing is lost, the exact structure-confusion bug it was added for (issue #14250, fixed in #14256) was re-injected and the test passes both before and after the shrink, so the reduction does not change what it catches. The test also asserted nothing (every deepEquals result was discarded); each result is now asserted true.

The exact Vue + Pinia fixture from the issue (both assertions, with real vue@3 and pinia@2) also passes.

Related

#29037 and #32872 both touch the same strict-mode block (null-prototype vs Object.prototype), but neither removes the unconditional calculatedClassName comparison, so neither fixes the Proxy case. This change only alters the branch taken when a Proxy is involved and composes with either.


[review] gate passed · iteration 7 · 2 files touched

fails on main (without fix)
ASAN without fix: 2 failed, 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/expect.test.js
bun test v1.4.0 (fbd8cb80c)

test/js/bun/test/expect.test.js:
(pass) expect() > () [596.19ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [5.43ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.96ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.53ms]
(pass) expect() > toBe() > expect(-0).toBe(-0) == true [0.47ms]
(pass) expect() > toBe() > expect(1).toBe(1) == true [0.48ms]
(pass) expect() > toBe() > expect(1).toBe(1) == true [0.46ms]
(pass) expect() > toBe() > expect(NaN).toBe(NaN) == true [0.45ms]
(pass) expect() > toBe() > expect(Infinity).toBe(Infinity) == true [0.48ms]
(pass) expect() > toBe() > expect({}).toBe({}) == true [0.46ms]
(pass) expect() > toBe() > expect(Symbol(a)).toBe(Symbol(a)) == true [0.54ms]
(pass) expect() > toBe() > expect(0).toBe(false) == false [4.11ms]
(pass) expect() > toBe() > expect(0).toBe("") == false [0.98ms]
(pass) expect() > toBe() > expect(0).toBe(-0) == false [0.62ms]
(pass) expect() > toBe() > expect(0).toBe(-0) == false [0.58ms]
(pass) e
... (truncated)

release without fix: 1 failed, 2 skipped
bun test v1.4.0-canary.1 (2e5f014f8)

test/js/bun/test/expect.test.js:
(pass) expect() > () [1.73ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [1.15ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true
(pass) expect() > toBe() > expect(0).toBe(0) == true
(pass) expect() > toBe() > expect(-0).toBe(-0) == true
(pass) expect() > toBe() > expect(1).toBe(1) == true
(pass) expect() > toBe() > expect(1).toBe(1) == true
(pass) expect() > toBe() > expect(NaN).toBe(NaN) == true
(pass) expect() > toBe() > expect(Infinity).toBe(Infinity) == true
(pass) expect() > toBe() > expect({}).toBe({}) == true
(pass) expect() > toBe() > expect(Symbol(a)).toBe(Symbol(a)) == true
(pass) expect() > toBe() > expect(0).toBe(false) == false [0.04ms]
(pass) expect() > toBe() > expect(0).toBe("") == false
(pass) expect() > toBe() > expect(0).toBe(-0) == false
(pass) expect() > toBe() > expect(0).toBe(-0) == false
(pass) expect() > toBe() > expect(1).toBe(2) == false
(pass) expect() > toBe() > expect(1).toBe(true) == false
(pass) expect() > toBe() > expect(1).toBe("1") == false
(pass) expect() > toBe() > expect(Infinity).toBe(-Infinity) == false
(pass) expect() > toBe() > expect("foo
... (truncated)
passes on PR (with fix)
ASAN with fix: 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/expect.test.js
bun test v1.4.0 (fbd8cb80c)

test/js/bun/test/expect.test.js:
(pass) expect() > () [572.60ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [5.58ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.98ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.56ms]
(pass) expect() > toBe() > expect(-0).toBe(-0) == true [0.51ms]
(pass) expect() > toBe() > expect(1).toBe(1) == true [0.49ms]
(pass) expect() > toBe() > expect(1).toBe(1) == true [0.52ms]
(pass) expect() > toBe() > expect(NaN).toBe(NaN) == true [0.46ms]
(pass) expect() > toBe() > expect(Infinity).toBe(Infinity) == true [0.51ms]
(pass) expect() > toBe() > expect({}).toBe({}) == true [0.50ms]
(pass) expect() > toBe() > expect(Symbol(a)).toBe(Symbol(a)) == true [0.47ms]
(pass) expect() > toBe() > expect(0).toBe(false) == false [3.84ms]
(pass) expect() > toBe() > expect(0).toBe("") == false [0.96ms]
(pass) expect() > toBe() > expect(0).toBe(-0) == false [0.61ms]
(pass) expect() > toBe() > expect(0).toBe(-0) == false [0.60ms]
(pass) e
... (truncated)

release with fix: 2 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1553ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/23] gen cpp.rs (cppbind)
[2/23] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[2/23] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_sourcemap v0.0.0 (/workspace/bun/src/sourcemap)
�[1m�[92m   Compiling�[0m bun_resolver v0.0.0 (/workspace/bun/src/resolver)
�[1m�[92m   Compiling�[0m bun_js_printer v0.0.0 (/workspace/bun/src/js_printer)
�[1m�[92m   Compiling�[0m bun_router v0.0.0 (/workspace/bun/src/router)
�[1m�[92m   Compiling�[0m bun_bundler v0.0.0 (/workspace/bun/src/bundler)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
�[1m�[92m   Compiling�[0m bun_transpiler v0.0.0 (/workspace/bun/src/transpiler)
�[1m�[92m   Compiling�[0m bun_bunfig v0.0.0 (/workspace/bun/src/bunfig)
�[1m�[92m   Compiling�[0m bun_install v0.0.0 (/
... (truncated)
diff hotspot
src/jsc/bindings/bindings.cpp   |  76 +++++++++++++++++++++++--
 test/js/bun/test/expect.test.js | 123 +++++++++++++++++++++++++++++++++++++---
 2 files changed, 186 insertions(+), 13 deletions(-)

gate history · 2 passed · 0 rejected · iteration 7

evidence per changed file
file                             reads  edits  tests
src/jsc/bindings/bindings.cpp       13     17     49
test/js/bun/test/expect.test.js     13     21     49

@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Updates Bun__deepEquals (strict mode) to compare Proxy objects via observable prototypes (getPrototype) instead of calculatedClassName. Updates Bun__deepMatch to exclude private symbols from property collections and restructures array handling to reject array-subset-vs-non-array matches and correctly read Proxy lengths. Adds corresponding tests for toStrictEqual Proxy transparency.

Proxy equality and matching fixes

Layer / File(s) Summary
Proxy prototype comparison in Bun__deepEquals
src/jsc/bindings/bindings.cpp
In strict mode, when either value is a Proxy, prototypes are compared via getPrototype instead of calculatedClassName.
Private symbol exclusion and array handling in Bun__deepMatch
src/jsc/bindings/bindings.cpp
subsetProps switches to PrivateSymbolMode::Exclude; array handling precomputes objIsArray/subsetIsArray, blocks array-subset vs non-array match, reads Proxy length via toLength, excludes private symbols from objProps, and enforces property-name count equality.
toStrictEqual Proxy transparency tests
test/js/bun/test/expect.test.js
Adds test scenarios for proxied plain objects, arrays, class instances, nested proxies, get-trap behavior, revoked proxies throwing TypeError, and consistency with node:util/node:assert.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #9103 by fixing toStrictEqual and toMatchObject for transparent Vue/Pinia proxies and adding matching tests.
Out of Scope Changes check ✅ Passed The added proxy, array, and test updates are all relevant to the linked issue and objectives.
Title check ✅ Passed The title clearly summarizes the main change: transparent Proxy handling in deep equality and toMatchObject.
Description check ✅ Passed The description explains the problem, cause, fix, scope, and verification results in substantial detail.

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

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. toStrictEqual and toMatchObject unexpected assertion error #9103 - toStrictEqual fails when comparing Vue reactive values (Proxy objects) against plain objects; this PR makes Proxy transparent in strict deep equality, directly fixing the reported false negative.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #9103

🤖 Generated with Claude Code

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:05 AM PT - Aug 1st, 2026

@robobun, your commit fbd8cb8 has 1 failures in Build #86854 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32948

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

bun-32948 --bun

Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread test/js/bun/test/expect.test.js Outdated
@robobun
robobun force-pushed the farm/e5cd7583/deepequals-proxy-strict branch from 838e593 to f8f201b Compare June 28, 2026 01:18
@robobun robobun changed the title Treat Proxy as transparent in strict deep equality Treat Proxy as transparent in deep equality and toMatchObject Jun 28, 2026
@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Updated to also fix the toMatchObject half of #9103 and to pick up the two review points (hoisted the node:util / node:assert requires to module-scope imports, trimmed the comments).

toMatchObject on a Proxy over an array turned out to be two separate bugs in Bun__deepMatch, both distinct from the toStrictEqual one:

  1. The "arrays should match exactly" gate uses getArrayLength(), which reads the internal indexed butterfly and is always 0 for a Proxy. Bun__deepEquals already guards its array fast path with !isProxy(); this was the missing sibling site.
  2. Property names were built with PrivateSymbolMode::Include (every site in Bun__deepEquals uses Exclude), sweeping JSC-internal private builtin names off Array.prototype into the comparison. Plain arrays survive by reference equality of the builtins; a Proxy breaks the private-name chain walk.

Those stray private names were also the only thing making expect({}).toMatchObject([]) fail, and the existing test at expect.test.js:3478 caught that when I removed them. The real invariant (an array expectation only matches an array) is now checked explicitly, the same way Bun__deepEquals checks v1Array != v2Array.

The exact Vue + Pinia fixture from #9103 (both toStrictEqual and toMatchObject, real vue@3 + pinia@2) passes, and every new toStrictEqual / toMatchObject expected value was confirmed against Jest 30.

Comment thread test/js/bun/test/expect.test.js Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/bindings.cpp`:
- Around line 844-859: The strict equality path in specialObjectsDequal is
treating ProxyObjectType as a normal object after only comparing prototypes,
which lets transparent proxies over special objects slip through with ordinary
property comparison. Fix this by unwrapping transparent proxies before calling
specialObjectsDequal, or by teaching specialObjectsDequal to handle
ProxyObjectType directly so the Map/Set/Date/RegExp branches still run on the
underlying target. Use the existing ProxyObjectType checks and the
specialObjectsDequal entry point to keep the behavior consistent with the other
special-object comparisons.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7bc45f1b-8b95-4e9f-af2e-36287d675b7c

📥 Commits

Reviewing files that changed from the base of the PR and between 0f9331d and 0c3ec82.

📒 Files selected for processing (2)
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/test/expect.test.js

Comment thread src/jsc/bindings/bindings.cpp

@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.

All my earlier nits are addressed and I didn't find any correctness issues, but this changes core deep-equality/match semantics in C++ JSC bindings (prototype-identity gate, PrivateSymbolMode flip with a compensating array gate) and overlaps the same block as #29037/#32872, so I'd like a human to sign off on the semantic choices.

Extended reasoning...

Overview

This PR modifies two hot paths in src/jsc/bindings/bindings.cpp:

  • Bun__deepEquals (strict mode): when either side is a ProxyObjectType, replaces the calculatedClassName string comparison with an observable-prototype identity check (getPrototype + RETURN_IF_EXCEPTION, p1 != p2).
  • Bun__deepMatch: switches both PropertyNameArrayBuilder sites from PrivateSymbolMode::Include to Exclude, adds an explicit "array expectation requires array received" gate, and reads length through traps when either side isProxy().

It also adds ~90 lines of tests in expect.test.js covering proxied plain objects, arrays, class instances, nested/double proxies, get-trap honoring, revoked-proxy TypeError propagation, and node:util/node:assert parity.

Security risks

None identified. The change reads observable prototypes/lengths through Proxy traps (which can run user JS), but every such call is followed by RETURN_IF_EXCEPTION and these paths already invoke user traps elsewhere in the same functions. No new untrusted-input parsing, no auth/crypto/permissions surface.

Level of scrutiny

High. Bun__deepEquals and Bun__deepMatch back Bun.deepEquals, expect().toStrictEqual, expect().toMatchObject, util.isDeepStrictEqual, and assert.deepStrictEqual — user-facing equality semantics that every Bun test depends on. The change is not mechanical: it picks prototype identity for the Proxy branch while the non-Proxy branch keeps class-name string comparison (different behavior for cross-realm or distinct-but-same-named prototypes), and the PrivateSymbolMode flip would have regressed an existing test (expect({}).toMatchObject([])) absent the new compensating subsetIsArray && !objIsArray gate the author added. The PR description also explicitly notes overlap with #29037 and #32872 on the same strict-mode block.

Other factors

  • All three of my prior inline nits (comment length/history, hoisting require, bare toThrow()) were addressed in follow-up commits and are resolved.
  • The bug-hunting system found nothing on the current revision.
  • CodeRabbit's special-objects concern was withdrawn after the author's empirical Node/Jest matrix; that discussion itself shows there are subtle reference-implementation disagreements in this space.
  • Test coverage is thorough and the author reports cross-verification against Jest 30 and Node 26, plus the original Vue/Pinia repro.
  • CI shows a build-rust / build-bun failure on the latest commit; likely infra, but worth confirming green before merge.

Given the semantic weight and the open-PR overlap on the same lines, I'm deferring rather than auto-approving.

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

On the one semantic reservation (prototype identity for the Proxy branch vs the class-name string for the non-Proxy branch, cross-realm and same-named-distinct classes): I probed both on the unpatched release binary and this branch so the answer is data rather than an argument.

input (strict) before after
proxy(otherRealmObj) vs thisRealmObj (same shape) false false
otherRealmObj vs thisRealmObj, no Proxy true true
proxy(new C1()) vs new C2() (distinct classes, both named C) false false
new C1() vs new C2(), no Proxy true true

Every row is unchanged. The two non-Proxy rows are not touched by this diff at all, and their looseness relative to Node (the calculatedClassName string gate) is the pre-existing question that #29037 is actually about, not something this PR widens or narrows.

There is also a structural reason no regression is possible on the Proxy branch. Before this change, on any input where a Proxy was involved, the old gate was one of exactly two things: unconditionally false when only one side was a Proxy ("ProxyObject" never equals any other class name, which is the reported bug), or vacuously passing when both sides were Proxies (both literally "ProxyObject"). A gate that never produced a correct discriminating answer has no correct answer for the new gate to flip. The new gate can only turn always-false into the Node/Jest answer, or turn a vacuous pass into a real check (the false positive in the PR description).

So the asymmetry is real but entirely inherited: the Proxy branch now uses the identity semantic that Node and Jest both use (prototype and .constructor respectively), and the non-Proxy branch keeps the class-name string it has always used. Making the non-Proxy branch consistent is a larger, user-visible behavioral change that deserves its own PR (and overlaps #29037), so I kept it out of scope here.

Comment thread test/js/bun/test/expect.test.js Outdated
Comment thread src/jsc/bindings/bindings.cpp
Comment thread test/js/bun/test/expect.test.js

@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.

8ea48b2 addresses the array-length regression I flagged (and the PR description now clarifies #14250 is the issue / #14256 the fix, so the URL nit is moot) — nothing left from my side, but this is a multi-part semantic change to the core deepEquals/deepMatch C++ paths plus a reduction of an unrelated stress test's iteration count, so it's worth a human pass.

Extended reasoning...

Overview

The PR changes Bun__deepEquals and Bun__deepMatch in src/jsc/bindings/bindings.cpp so that transparent Proxy wrappers compare the way their targets would, fixing #9103 (Vue/Pinia/MobX reactive values failing toStrictEqual/toMatchObject). Concretely: (1) the strict-mode type gate compares observable prototypes via getPrototype() instead of calculatedClassName() when a Proxy is involved; (1b) when both sides are arrays and a Proxy forced them off the fast path, observable .length is compared explicitly; (2) Bun__deepMatch reads array length through traps when a Proxy is present and adds an explicit array-expectation-only-matches-array gate; (3) PrivateSymbolMode::IncludeExclude for both property-name collections in Bun__deepMatch. Tests add ~90 lines of Proxy coverage and separately reduce the Set/Map stress test from 150×2000 to 20×500 to fit the debug+ASAN budget.

Security risks

None identified. The new code paths call getPrototype/get/toLength (which can run user JS via traps), but each is followed by RETURN_IF_EXCEPTION so exceptions propagate cleanly, and the revoked-Proxy test pins TypeError. No auth, crypto, filesystem, or network surface.

Level of scrutiny

High. Bun__deepEquals backs Bun.deepEquals, expect().toStrictEqual, util.isDeepStrictEqual, and assert.deepStrictEqual; Bun__deepMatch backs toMatchObject. These are correctness primitives whose semantics users depend on across the whole test ecosystem, and the change introduces a deliberate asymmetry (prototype identity for the Proxy branch vs class-name string for the non-Proxy branch) that interacts with open work in #29037/#32872. The PrivateSymbolMode flip and the new array-vs-non-array gate in deepMatch also subtly change which inputs pass — the author caught and explained one such regression (expect({}).toMatchObject([])), but a human should sanity-check that reasoning.

Other factors

My prior 🔴 inline (Proxy-over-array vs trailing-hole array comparing equal) is fixed in 8ea48b2 with matching test coverage, and the 🟡 issue-URL nit was resolved by clarifying the PR description rather than the code (14250 = issue, 14256 = fix PR), which is fine. The author's empirical cross-runtime tables are thorough and the test coverage is good. The unrelated stress-test reduction is well-justified in the description but is the kind of drive-by change a maintainer should be aware of. No CODEOWNERS entry covers this path. Given the breadth of semantic surface and the number of review rounds it took to converge, deferring to a human reviewer rather than auto-approving.

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review

All bot review threads are resolved (claude-bot's final summary: "No new issues found … the own-key-set check in fbd8cb8 addresses the last concern"; coderabbit withdrew its one finding after the cross-runtime matrix). The diff now covers the original report plus three slow-path cases review surfaced (array length with trailing holes; own-key sets under a lying has trap; toMatchObject array handling), each with regression tests.

Build 86854 (at fbd8cb80) finished with 193 jobs passed and 1 failed:

lane failing file cause
windows 2019 x64 test/regression/issue/36577.test.ts bun install frozen-lockfile: expect(exitCode).toBe(0) received 1

That test contains zero references to deepEquals / toStrictEqual / toMatchObject / Proxy (it exercises the package manager's lockfile comparison), failed the same way on the previous build at a different sha, and has been handed to main-break triage. Two more jobs expired waiting for an agent. test/js/bun/test/expect.test.js, the only test file this PR touches, does not appear in any failure annotation.

Locally, the full expect.test.js suite is 417 pass / 0 fail in ~8s on a debug+ASAN build and fail-before is intact on the unpatched baseline. I am not pushing more empty commits to re-roll CI for an unrelated lane; this needs a maintainer to look past it.

robobun added 5 commits August 1, 2026 05:32
Bun.deepEquals(proxy, target, true) and toStrictEqual() rejected any
ProxyObject because the strict-mode type gate compares
JSObject::calculatedClassName(), which for a Proxy falls through to the
internal class name "ProxyObject" instead of deriving a name from the
prototype (ProxyObject overrides getPrototype, so that path is skipped).
"ProxyObject" never equals "Object", so a transparent new Proxy(t, {})
was reported unequal to its own target. Symmetrically, any two Proxies
both report "ProxyObject" and pass the gate regardless of what they
wrap. When either side is a Proxy, compare the observable prototypes
(through the getPrototypeOf trap) instead. Matches Node's
util.isDeepStrictEqual and Jest's toStrictEqual.

toMatchObject() on a Proxy over an array failed for two more reasons in
Bun__deepMatch:

- getArrayLength() reads the internal indexed butterfly, which a
  ProxyObject does not have, so the "arrays should match exactly" gate
  always compared 0 against the real length. Read the observable length
  through the traps when a Proxy is involved, mirroring the !isProxy()
  guard Bun__deepEquals already has on its array fast path.

- Property names were built with PrivateSymbolMode::Include (unlike
  every site in Bun__deepEquals), sweeping JSC-internal private builtin
  names from Array.prototype into the comparison. Plain arrays got away
  with it because both sides resolve the same builtin function object
  and reference equality short-circuits, but a Proxy breaks the
  private-name chain walk. Users can never define private symbols, so
  use Exclude. Those stray private names were also the only thing
  making expect({}).toMatchObject([]) fail, so the real invariant (an
  array expectation only matches an array) is now checked explicitly,
  the same way Bun__deepEquals checks v1Array != v2Array.
The expect.test.js suite could not go green on a debug+ASAN build because
the deepEquals Set/Map stress test took over four minutes and timed out at
5000ms (1s on a release build). Its cost is quadratic in the element count:
every distinct array key misses the Set/Map identity lookup and takes the
JSSetIterator/JSMapIterator linear fallback, and 150 elements across 3000
iterations is ~45M nested deepEquals calls. It is loose mode over Sets and
untouched by the Proxy change; the timeout is pre-existing.

Shrink the element count (the quadratic term) from 150 to 20 and the
iterations from 2000/1000 to 500/250, which still constructs the fallback
iterators thousands of times and runs in under 2 seconds on a debug+ASAN
build. To confirm nothing is lost, the exact structure-confusion bug from
the fix this test was added for (#14256: passing the Set's own
Structure to JSSetIterator::create) was re-injected and the test passes
both before and after this change, so the shrink does not reduce what it
catches. The test also had zero assertions; every deepEquals result is now
asserted, so a wrong answer on the Set/Map fallback path fails it too.
…ast path

The array fast path is the only place strict deepEquals compares array
length, and it is skipped whenever a Proxy is involved. The generic path it
falls through to only enumerates own enumerable properties, and a trailing
hole contributes none while length is not enumerable, so two arrays with the
same occupied indices but different lengths compared equal once the Proxy
type gate started letting the pair through:

  Bun.deepEquals(new Proxy([1, 2, 3], {}), [1, 2, 3, ,], true)
  // was false (the old gate rejected every Proxy), became true,
  // Node's util.isDeepStrictEqual and Jest both say false

When both sides are arrays and the fast path was skipped, compare the
observable lengths through the traps, the same way Bun__deepMatch already
does. This also fixes the pre-existing false positive where two Proxies over
arrays of different lengths compared equal because both reported the class
name "ProxyObject".
@robobun
robobun force-pushed the farm/e5cd7583/deepequals-proxy-strict branch from 8ea48b2 to 1b8420a Compare August 1, 2026 06:07
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated
@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (was 837 commits behind, now mergeable). Two conflicts in bindings.cpp around the skipPrototype template parameter that landed after this branched; resolved by gating the Proxy prototype check behind isStrict && !skipPrototype alongside calculatedClassName, and keeping the Proxy array-length check under isStrict only (it compares values, not prototypes).

Re-verified at 1b8420a:

Also dropped the two empty ci: retrigger / chore: re-run checks commits.

Comment thread test/js/bun/test/expect.test.js Outdated
Each of the four new comments in bindings.cpp compresses to one line
without losing the load-bearing fact, matching the file's existing
style.

The rebase onto main also layered the shrunk Set/Map stress test under
an outer comment and a 480s per-test timeout that main had since added
for the old four-minute workload. Both are now dead: the outer comment
contradicts the inner one and the override guards a runtime that can no
longer occur.
Comment thread src/jsc/bindings/bindings.cpp
…olved

The generic slow path establishes key-set equality by (a) comparing key
counts and (b) probing each left-side key on the right with
getIfPropertyExists(), which uses InternalMethodType::HasProperty. That is
sound for ordinary objects, but a Proxy with a `has` trap that returns
true unconditionally makes the probe vacuous, so two objects with the same
key count but disjoint key sets compared equal (and asymmetrically, since
only the direction where the Proxy is on the right was fooled):

  Bun.deepEquals({ b: 1 }, new Proxy({ a: 1 }, { get: () => 1, has: () => true }), true)
  // was false (the old gate rejected every Proxy), became true, Node says false

When a Proxy is involved and the counts match, additionally verify every
left own key appears in the right own-key array (both come from
[[OwnPropertyKeys]]), which is what Node's util.isDeepStrictEqual does.

Note: a `get` trap alone cannot trigger this (getIfPropertyExists uses
HasProperty, not Get), so the reactive-proxy case from Vue / MobX / Immer,
which all forward `has` to the target, was already correct. Tests cover
both the get-only case and the lying `has` case.

@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.

No new issues found — the own-key-set check in fbd8cb8 addresses the last concern I raised, and the bug-hunting pass came back clean. That said, this changes user-visible equality semantics for toStrictEqual / util.isDeepStrictEqual / toMatchObject and introduces a prototype-identity gate on the Proxy branch that intentionally diverges from the class-name gate on the non-Proxy branch (and overlaps #29037 / #32872), so a maintainer should sign off on the semantic choice.

What was reviewed:

  • Exception discipline on every new trap-invoking call (get, getPrototype, toLength, isArray) — all have RETURN_IF_EXCEPTION.
  • The new subsetIsArray && !objIsArray gate in Bun__deepMatch — confirmed the reverse direction stays permissive per the existing test at expect.test.js:3473-3474.
  • The O(n²) own-key-set comparison at lines 1039-1052 — Proxy-only, strict-mode-only, bounded by own-key count; not a hot-path concern.
  • The stress-test shrink — outer comment and 480s override are gone, assertions now actually check the result.
Extended reasoning...

Overview

This PR fixes #9103 by making Bun__deepEquals (strict mode) and Bun__deepMatch treat Proxy wrappers as transparent, matching Node's util.isDeepStrictEqual and Jest's toStrictEqual/toMatchObject. It touches four sites in src/jsc/bindings/bindings.cpp: (1) a prototype-identity gate replaces calculatedClassName when either side is a Proxy; (2) an observable-length comparison for Proxy-over-array pairs that skip the array fast path; (3) an own-key-set comparison on the slow path when a Proxy's has trap could make the getIfPropertyExists probe vacuous; (4) in Bun__deepMatch, PrivateSymbolMode::Exclude, an explicit array-vs-non-array gate, and observable length reads for Proxies. It also adds ~100 lines of test coverage and shrinks an unrelated stress test that was timing out under debug+ASAN.

Security risks

None identified. The new code calls user-controllable Proxy traps (getPrototypeOf, get, has, ownKeys), but every call site has RETURN_IF_EXCEPTION and the values are only used for comparison — no allocation sizing, no pointer arithmetic, no privilege decisions. A revoked Proxy is covered by a test and correctly propagates a TypeError.

Level of scrutiny

High. This is core equality semantics reachable from expect().toStrictEqual, Bun.deepEquals, node:util.isDeepStrictEqual, node:assert.deepStrictEqual, and toMatchObject — a wide blast radius for any false-positive or false-negative. Review on this PR has already surfaced two real regressions (array length with trailing holes; own-key-set masking via a lying has trap), both now fixed with regression tests. That history is evidence the change is subtle, not evidence it's wrong.

Other factors

The prototype-identity semantic on the Proxy branch is stricter than the class-name-string semantic on the non-Proxy branch (cross-realm and same-named-distinct-class inputs behave differently on the two branches). The author defended this with an empirical before/after matrix showing no regression on those inputs and explicitly scoped unifying the non-Proxy branch to a separate PR that overlaps #29037 / #32872. That's a reasonable position, but it's a design call about how strict-equality should treat Proxies going forward and how it composes with those other PRs — a maintainer should own that decision rather than have it auto-approved.

The Bun__deepMatch change also adds a new failure mode (subsetIsArray && !objIsArray → false) that the author says was previously only enforced accidentally by private-symbol enumeration. The existing test at line 3478 covers it, but it is a user-visible behavioral clarification.

Given the semantic scope, the interaction with two other open PRs on the same block, and the review history, I'm deferring rather than approving.

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.

toStrictEqual and toMatchObject unexpected assertion error

1 participant