Skip to content

bun:test: toMatchObject and Bun.deepMatch compare a Proxy around a Set or a Map by the members it serves - #47889

Draft
robobun wants to merge 40 commits into
mainfrom
robobun/6e0587e4/deep-match-served-set-map
Draft

robobun wants to merge 40 commits into
mainfrom
robobun/6e0587e4/deep-match-served-set-map

Conversation

@robobun

@robobun robobun commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Depends on #47702, #47858 and #47888. Their commits are in this diff.

Problem

Fix

Background

  • Vue keeps raw members in the target and serves reactive(member), whose get trap unwraps a ref.
  • A draft, to ask a maintainer: compare what the Proxy serves, or keep "no verdict"?
  • Weighed: the target (fails correct Vue assertions), a native copy (drops duplicates), the received side only.

Downsides

Notes

No issue reports this. It rests on measurement against Jest and Vitest, as #47888 does.

The rule (measured against expect 30.5.2 and @vitest/expect 4.1.12 on Node 26.3.0, with @vue/reactivity 3.5.44)

  • The target chain, read with no trap, says if a side can be a Set or a Map. The members are what the Proxy serves.
  • An expected Proxy around another object is a pattern of properties, as before. A received value of another kind does not match.
  • Each Proxy is asked for Symbol.iterator first. A Proxy that serves none has no rule here, and the key walk decides, as before. Then size of both sides is compared with ===, before an iterator is opened.
  • Two sizes that are undefined are the same size, so such a pair takes this rule too. Jest and Vitest walk such a pair in step. bun:test: toEqual reads a Proxy around a Set or a Map through the Proxy #47888 names that walk under what is left out: no library that I found serves a Proxy with no size.
  • Then the scan of bun:test: compare an expected Map and Set in toMatchObject and Bun.deepMatch #47702 runs: a received member that the expected value has matches, for a Map if the value under get matches. Each other member must match an expected member as a subset.
  • What a trap, size, the iterator, has or get throws is thrown. A walk that is left early gets its return(). A revoked Proxy keeps its TypeError.
  • The class of a Set or a Map is not compared, as in bun:test: compare an expected Map and Set in toMatchObject and Bun.deepMatch #47702 for two real collections.

Rows (232 recorded rows on which Jest and Vitest agree. "Base" is #47702 with #47858 and #47888 merged.)

Where the answer is not that of Jest and Vitest (pinned in the test "where toMatchObject() ... is not that of Jest 30.5.2 and Vitest 4.1.12")

  • The class is not compared: 9 rows, 2 of them with no Proxy.
  • A size that is undefined: 5 rows. The references walk such a pair in step. Here a member that only the expected value has is not seen, an expected value with no has throws, and such a Proxy does not match a real Set.
  • The kind comes from the target: 3 rows.

Ahead-of-time builds

  • With an expected Proxy, native code hands members and keys of value to has and get of the Proxy. The old entry did not say so, and bun build --compile --aot gave another result than bun: [1,true,1,null] against [1,true,2,"added"] for a program whose has writes to the member it gets.
  • The entry now writes the contents of value as kept in native memory, which is how the table writes a value that is handed to code.
  • test/aot/parallel/builtins/deep-match-hands-* are three such programs. They print the same both ways with the entry and differ without it.
  • A program that passes no Proxy grows from 627 to 695 bytes of compiled code in one probe (a class with has and get as subset, an iterable class as value). Two literals that hold a Set do not grow.
  • expect().toMatchObject needs no entry: its generated entry allows any code to run.

Cost (release, linux x64, size and perf_event_open user instructions, JIT off, 5,000 calls minus an empty loop, median of 5, at 44cc6e5. Base as above, with #47888 at 8d7af31.)

call base this PR
toMatchObject, 4 key object, 2 key pattern 3,260 3,259
toMatchObject, 3 levels and an array 17,571 17,570
toMatchObject, [1,2,3] against [1,2,3] 10,484 10,482
toMatchObject, object with 100 number properties 44,043 44,042
toMatchObject, array of 10 class instances 43,526 43,531
Bun.deepMatch, 3 levels and an array 15,555 15,552
expect.objectContaining, 4 key object 3,488 3,487
toMatchObject, Set of 5 numbers 4,921 4,920
toMatchObject, Set of 1,000 numbers 153,638 153,637
toMatchObject, Map of 3 entries with object values 10,559 10,565
toMatchObject, Map of 100 entries with object values 245,624 245,929
toMatchObject, Map of 1,000 string keys 531,234 534,464
Bun.deepMatch, Map of 1,000 string keys 500,288 503,517
toEqual, 3 levels 5,656 5,655
  • Two real Maps cost 3 more instructions for each entry (0.6% for 1,000 string keys). The scan of a Map is inlined into subsetSpecialObjectMatches, which this PR changes, and its loop over the entries is 3 instructions longer there. Two real Sets do not change.
  • Four rows have several modes on both builds, and their ranges overlap: an array of 10 objects (40,192 and 40,193), a Set of 3 objects (10,781 and 10,728), an array of 100 numbers (103,094 and 103,383) and a Set of 30 Dates (97,613 and 97,523).
  • Text: 140,328,072 to 140,331,400. Data and bss do not change.

Tests

  • describe("toMatchObject() with a Proxy around a Set or a Map") in test/js/bun/test/expect.test.js: 10 tests that also pass on Jest and Vitest, with a hand-written stand-in for reactive(). 6 more tests under if (isBun): what a Proxy is asked and when, what it throws, a member that stays alive during a collection, has is not a function, the rows that differ from the references, and the exception checks in a subprocess.
  • With the native code of the base: expect.test.js 16 fail, deep-match.spec.ts 13 fail, vitest-flavor.test.ts 1 fail.
  • 152 more rows with a size that is no number (undefined, "1", 1n, null) on which Jest and Vitest agree: the base differs on 70, this PR on 18. 16 are passes that the references fail. 2 are right on the base and wrong here: a Proxy with no size against a real Set with the same members.
  • BUN_JSC_validateExceptionChecks=1: the 79 tests of expect.test.js that name a Proxy or toMatchObject pass, and so does deep-match.spec.ts. No abort.
  • Also run, all pass: bun-snapshots.test.ts, vitest-apis.test.ts, jest-extended.test.js, isArray-proxy-crash.test.ts, test/js/node/assert/deep-equal.test.ts.
  • The stand-in for Vue moves from the describe of bun:test: toEqual reads a Proxy around a Set or a Map through the Proxy #47888 to the scope of describe("expect()"), so both describes use one copy.

Between the PRs of this stack


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/regression/issue/isArray-proxy-crash.test.ts

robobun added 30 commits October 2, 2026 15:29
…lue in toMatchObject and Bun.deepMatch

Subset matching becomes an isPartial mode of Bun__deepEquals. The expected
value's type selects the comparison, so a Date, Error, Map, Set, RegExp,
typed array, boxed primitive or function on the expected side goes through
the same typed arm toEqual uses, and only a plain object or array is walked
key by key. The untyped Bun__deepMatch walk is removed.
…ng never checked

toMatchObject now applies an asymmetric matcher passed as the whole expected
value, so the IPv6 spot checks ran for the first time and three of them
asserted values node does not produce.
…ms in both directions

Subset matching dispatched on JSFunctionType only, so an expected native
constructor, mock function or callable Proxy was still walked key by key:
`expect({ f: jest.fn() }).toMatchObject({ f: jest.fn() })` and
`Bun.deepMatch(Set, Map)` passed. Any callable expected value now matches
only itself.

The URLSearchParams arm checks one direction and relies on the second call
with the sides swapped. Subset matching calls an arm once, so
`a=1&a=1` matched `a=1&b=1`. The arm now checks both directions in that
mode.

Add the cases of the per type pull requests for functions, Headers,
URLSearchParams and Temporal values.
The subset walk called Bun__deepEquals for every property. For two
primitives that call only runs sameValue. Do that in place.
The isPartial mode of Bun__deepEquals took over the cycle rule and the typed
arms of toEqual. A received object that refers to itself, a float array with
NaN and Headers with two Set-Cookie values no longer matched an equal
pattern. The mode is removed and the subset walk is as on main again.

The walk lists the enumerable keys of the expected value. A Date and an
Error have none, so any object matched. Before the walk, an expected Date
now needs a Date with the same time value, and an expected Error needs an
Error with the same message. The enumerable properties of the expected
value are matched after that, as before.

The comparison of Map, Set, functions and the other typed values is not
part of this change.
…instantiation

The helper sat directly above the comment of the explicit instantiation of
Bun__deepEquals. A change to that comment and this helper then touch the same
lines. It now sits between the instantiation and Bun__deepMatch. No behaviour
changes.
…and Bun.deepMatch

Bun__deepMatch walks the enumerable keys of the expected value. A Map, a Set
and a function keep what they hold elsewhere, so an expected one matched any
object. An asymmetric matcher that was the whole expected value was walked as
a plain object too.

- An expected Set or Map needs one of the same size. Each received member or
  entry must match an expected one as a subset. A candidate that does not
  match leaves nothing behind in the state of the walk.
- An expected function matches only itself. A method that the expected
  object inherits is not compared.
- A matcher that is the whole expected or received value is applied.

The property loop, the members of a Set and the entries of a Map share one
pair function, subsetPairMatches.
…d Bun.deepMatch

Bun__deepMatch listed the keys of the expected value with
PrivateSymbolMode::Include. So it compared the #fields of a class, which
Jest, Vitest and toEqual do not see, and for each array it walked the
builtins that Array.prototype keeps under private names.

Both lists now leave private names out, as Bun__deepEquals does. Those
builtins were also what made an expected array refuse an object that is not
an array. That rule is now written out: an expected array needs an array.
# Conflicts:
#	test/js/bun/test/expect.test.js
…private-name change

Merge of the new base: #47736 (private names leave the key walk) and the
branch of #44497 on top of it.

After a review, this branch keeps the Set and Map rules only:
- Function identity and the whole-pattern matcher are taken out. They stay
  in their own pull requests.
- The tests of server.spec.ts and url-parse-ipv6.test.ts and the elysia
  entry of test/vendor.json go back to what main has.
- matchEitherAsymmetricMatcher is shared with Bun__deepEquals. A value that
  only has the type of a built-in matcher, such as a Response, no longer
  hides a matcher on the other side.
- What is compared inside a Set or a Map is recorded again, and a candidate
  that fails takes its records back. A matcher under a property is shown
  whichever of the two was compared first.
- A received Proxy is not read as a Set or a Map.
…nction

# Conflicts:
#	src/jsc/bindings/bindings.cpp
… a Set or a Map

The scan for a received member that is not in the expected Set or Map
started at the first expected member each time, so two Sets of N cloned
objects in the same order took N squared over 2 tries. The scan now
keeps its iterator. It first tries the expected member behind the one
that matched last. If that try fails, it starts at the first expected
member as before and leaves that one member out.

Members that are not found by identity and follow each other in the
same order take one try each: 50 tries for 50 members, against 1,275.
Another order keeps the old number of tries plus at most one for each
member, and so does a Set with members found by identity between the
others.

One try is now the static helper subsetCandidateMatches.

Tests: each order of four members for a Set and a Map, a failed try in
each order, a matcher that throws, the Map path where the value under
the same key differs, and the number of tries.
… toMatchObject and Bun.deepMatch

The array gate of Bun__deepMatch compared getArrayLength() of both
operands. That reads the indexed storage of the cell, and a Proxy has
none. So a Proxy of an array that is not empty never had the length of
the array it wraps, and the match failed. A Vue reactive() array and a
Pinia store array are such Proxies.

Two arrays with indexed storage keep the storage compare. Any other pair
reads `length` with [[Get]] from each Proxy operand and compares the two
lengths with ===, which is the rule the gate already has for two arrays.

Part of #9103.
The matcher cast the sample and the value to JSArray. A Proxy of an
array is not one, so it never matched, and expect.not.arrayContaining
passed for every Proxy. The constructor rejected a Proxy sample with
"Expected a array".

When the sample or the value is a Proxy, both are classified with
JSC::isArray and read with [[Get]]. Two real arrays, and a value that is
not an array, take the path they took before. The two paths share the
containment loop.

The two assertions that #27529 added for this matcher in
isArray-proxy-crash.test.ts asserted the failed match that replaced the
crash. They now assert the match.
…ey after the matcher, drain stdout

- The two assertions for a Proxy of an object as the sample of
  expect.arrayContaining now pin the error of the sample check.
- The rows for a trap that throws under expect.not.arrayContaining in
  toMatchObject have a second key after the matcher, so the match reads
  on after the matcher.
- The snapshot subprocess test reads stdout, and the two subprocess
  tests are concurrent.
…y-array' into robobun/6e0587e4/deep-match-served-set-map
…t-map

# Conflicts:
#	test/js/bun/test/expect.test.js
…t or a Map by the members it serves

toMatchObject and Bun.deepMatch did not read a Proxy around a Set or a Map.
A received Proxy matched each expected Set and each expected Map.
An expected Proxy was only a pattern of properties.

They now ask the Proxy what script asks it, as Jest and Vitest do.
They read its iterator method and its `size`, and they run its iterator.
They call `has` and `get` of a Proxy on the expected side.
They read the value of a Map entry after its key is found.
The target of a Proxy only tells if the Proxy stands for a Set or for a Map.
A Proxy that serves no iterator method has no rule. Its properties are matched.
A Proxy that does not serve its Set or Map throws its TypeError.
The comparison uses the reader of toEqual, ServedCollection.
ServedSubsetSide gives the reader the names of the iterators that the scans of a Set and of a Map call.
The scans are templates. Two Sets and two Maps compile to the code that they had.

The scan of a Map leaves out the key of the entry that it tried first.
The iterator of a Proxy can give one key in more than one entry.
For a Proxy, the scan leaves out that key only if the key did not match.

The table of built-ins for ahead-of-time builds tells what Bun.deepMatch does to a Proxy.
It runs the iterators of the two operands.
It calls `has` and `get` of the subset with the members and the keys that the value holds.
Before, a compiled program read an old field of a member that `has` wrote.

The tests of toEqual and of toMatchObject share one stand-in for reactive() of Vue.
@github-actions github-actions Bot added the claude label Nov 4, 2026
@robobun

robobun commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:27 AM PT - Nov 4th, 2026

❌ @robobun, your commit 44cc6e5 has 41 failures in Build #137556 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47889

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

bun-47889 --bun

@robobun

robobun commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft, waits for #47702, #47858 and #47888.

Reproduced on a debug build of #47702 with #47858 and #47888 merged, with bun test: expect({ s: reactive(new Set([1])) }).toMatchObject({ s: new Set([2]) }) passes, with reactive of @vue/reactivity 3.5.44 and with the hand-written stand-in of the test. On this branch it fails, as in Jest 30.5.2 and Vitest 4.1.12.

The question for a maintainer is in the PR body: compare what the Proxy serves, or keep "no verdict" for a Proxy.

The branch: since #47942 it conflicts with main in one file, src/codegen/builtins/host/globals.builtins.ts. #47942 moved the definitions that the Bun.deepMatch entry of this PR changes into native-code.ts. The branch has #47888 up to 8d7af31, from before the rebase of #47888 on 2026-11-08. It needs f230e22 of #47888 to compile with the engine of main. The branch is not updated yet, and the PR body describes 44cc6e5 on main 3bf1a06.

The update is one merge of main and of #47888, the entry written again for the new table, and a new run of the three ahead-of-time programs. It comes when #47702 and #47858 are on the new main, or sooner if a maintainer asks.

CI: no test of this PR failed on build 137556 at 44cc6e5. 28 test files are red there: 26 under test/aot/, test/bundler/compile-aot-size.test.ts, and test/js/web/request/request-clone-leak.test.ts on the ASAN lane. 26 of the 28 are red on build 137481 of main too. The other two are red on builds of other branches: test/aot/engine/native-6.test.ts on 137552, with the same assertion on the same six lanes, and request-clone-leak.test.ts on 137568. The other failures passed on a retry.

…e constructors by identity under the vitest import

Two sizes that are undefined do not say that the second value has no other member, so each value must have the members of the other.
Under the vitest import the constructors of the two values must be one value, as in Vitest. Jest also takes two native functions of one name.
The row for expect.not.arrayContaining() against a served array is a test.todo with the answer of Jest and Vitest.
…) from another error

"fails" is a failed toEqual() only. A TypeError from a Proxy prints as it is, so it cannot pass for a mismatch.
…t-map

# Conflicts:
#	test/js/bun/test/expect.test.js
#	test/js/bun/test/vitest-flavor.test.ts
…h have the members of the other

Two sizes that are undefined do not say that the expected value has no other member. The received members are walked again for each expected member. That walk asks has and get of neither value, and it takes back what it puts in the state.
…re on main

The arms again take a Proxy for unequal to a Set or a Map. Bun__deepEquals keeps that pair for servedCollectionsDequal after the arm has answered.
With the test inside the arms the compiler laid out the loop over the members in another way: toEqual of two Sets of 3 objects took 32 more instructions.
The pass compared the members with the two values swapped. It asked has and get of the first value, which threw for a first value that serves neither. A Proxy with no size that holds itself ended in a RangeError, and an equality tester got (expected, received).
Two sizes that are undefined are equal sizes again, as before 304cabf. The test of the differences has the rows in which the answer is not that of Jest and Vitest.
…must each have the members of the other"

This reverts commit 54fa1c3.
…n.deepMatch

The rows that hold with one pass are back in the tests that also run on Jest and Vitest. The rows in which the answer is not that of Jest and Vitest are in the test of the differences.

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.

1 participant