Skip to content

Make toMatchObject and Bun.deepMatch compare Headers and URLSearchParams by their entries - #37904

Open
robobun wants to merge 4 commits into
mainfrom
farm/39ef521c/deep-match-headers-urlsearchparams
Open

robobun wants to merge 4 commits into
mainfrom
farm/39ef521c/deep-match-headers-urlsearchparams

Conversation

@robobun

@robobun robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • toMatchObject, Bun.deepMatch, expect.objectContaining and snapshot property matchers accept any Headers for any other Headers, and any URLSearchParams for any other URLSearchParams (same entry count):
    expect({ h: new Headers({ a: "1" }) }).toMatchObject({ h: new Headers({ a: "2" }) });   // passes
    expect(new URLSearchParams("a=1")).toMatchObject(new URLSearchParams("a=2"));           // passes
    Bun.deepMatch({ h: new Headers({ a: "1" }) }, { h: new Headers({ a: "2" }) });          // true
    Jest and Vitest fail these (iterableEquality compares the entries); toEqual in Bun already fails them.
  • Cause: Bun__deepMatch (src/jsc/bindings/bindings.cpp:1960) only walks the enumerable properties of the expected value. Headers, URLSearchParams and URL keep their state in C++ and expose nothing but prototype members, which are the same function objects on every instance, so the walk has nothing that reflects the entries. (URL happens to work already because its prototype members are accessors; Headers.count / URLSearchParams.size are why a different entry count was already rejected.)

Fix

  • Before the property walk, when the expected value and the received value are wrapper objects of the same JSType (JSDOMWrapperType: Headers, URL; JSAsJSONType: URLSearchParams), run specialObjectsDequal, the content comparison toEqual / Bun.deepEquals use, in both directions the way Bun__deepEquals does. A verdict (different entries) is returned; no verdict (same entries, or a wrapper type it does not know) falls through to the existing property walk, so expando properties are still subset matched and every other wrapper type behaves exactly as before.
  • Why this shape:
  • Verified:
    • test/js/bun/test/expect.test.js (describe toMatchObject): 5 new tests fail on the released binary, pass with this change; the two unguarded ones also pass under Vitest 4.1.9.
    • test/js/bun/bun-object/deep-match.spec.ts: 4 of the new rows fail on the released binary, all pass with this change.
    • Full expect.test.js, deep-match.spec.ts, deep-equals.test.ts, url.test.ts, headers.test.ts, filesystem_router.test.ts, snapshot tests: unchanged results. A grep of test/ found no existing test that passes a Headers, URLSearchParams or URL instance as a toMatchObject / objectContaining expected value, so nothing else changes behavior.
  • Not in this PR: a top-level asymmetric matcher passed directly to toMatchObject is still not applied, and Headers still pretty-prints as Headers {} in the failure diff; both are pre-existing and tracked separately. expect: fix toBeCloseTo opposite-sign Infinity and toMatchObject Date/Error/Set/Map leaves #32870 (open) handles the Date/Error/Set/Map leaves with a different rule (full deepEquals), which is right for those types because Jest's subsetEquality excludes them entirely; the two changes are independent.

Background

  • Bun__deepMatch is the engine behind toMatchObject, Bun.deepMatch, expect.objectContaining and the property-matcher argument of toMatchSnapshot / toMatchInlineSnapshot. It enumerates the expected value's enumerable properties (including inherited ones) and, for object-valued properties, recurses; values are only compared through those properties.
  • specialObjectsDequal is the helper Bun__deepEquals calls first for types whose equality is not defined by their properties (Set, Map, ArrayBuffer, Date, ...). For the wrapper branch it returns false when URL hrefs or Headers / URLSearchParams entries differ and std::nullopt otherwise, meaning "nothing special, go on comparing properties". Bun__deepEquals calls it with the arguments in both orders because some comparisons (today's URLSearchParams one) only iterate the first argument.
  • JSType: JSC lets embedders tag objects with a type byte. WebCore-style wrappers built from IDL use JSDOMWrapperType (Headers, URL, FormData, and Bun's generated classes such as Blob, Request, Response); JSAsJSONType marks wrappers that print via toJSON() (URLSearchParams, Cookie, CookieMap). specialObjectsDequal keys its wrapper branch on these two values, so the new check uses the same two.
Behavior on the released binary vs this branch (probe output)
                                                    1.4.0      this branch   vitest 4.1.9
{h: Headers a=1} toMatchObject {h: Headers a=2}     pass       fail          fail
{h: Headers a=1} toMatchObject {h: Headers b=1}     pass       fail          fail
{h: Headers a=1} toMatchObject {h: Headers a=1}     pass       pass          pass
[Headers a=1]    toMatchObject [Headers a=2]        pass       fail          fail
Headers a=1      toMatchObject Headers a=2          pass       fail          fail
{p: USP a=1}     toMatchObject {p: USP a=2}         pass       fail          fail
{u: URL a}       toMatchObject {u: URL b}           fail       fail          pass (vacuous)
{h: {}}          toMatchObject {h: Headers}         fail       fail          pass (vacuous)
{x: Headers}     toMatchObject {x: URLSearchParams} fail       fail          fail
{b: Blob "a"}    toMatchObject {b: Blob "abc"}      fail       fail          pass (vacuous)
{r: Response 200} toMatchObject {r: Response 404}   fail       fail          pass (vacuous)
Bun.deepMatch({h: Headers a=1}, {h: Headers a=2})   true       false         n/a

…ams by their entries

Bun__deepMatch only walked the enumerable properties of the expected
value. Headers, URLSearchParams and URL keep their state in C++ and only
expose prototype members, which are identical for every instance, so any
two instances matched each other. Run specialObjectsDequal (the content
comparison toEqual already uses) before the property walk when both sides
are wrapper objects of the same kind.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

DOM wrapper matching

Layer / File(s) Summary
Wrapper content comparison
src/jsc/bindings/bindings.cpp, test/js/bun/bun-object/deep-match.spec.ts
Bun__deepMatch compares supported DOM wrapper contents before property matching. Tests cover matching and mismatching Headers and URLSearchParams values.
Expectation matcher coverage
test/js/bun/test/expect.test.js
Tests cover toMatchObject, snapshot property matchers, and expect.objectContaining for Headers, URLSearchParams, and Bun URL values.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: entry-based comparison for Headers and URLSearchParams in toMatchObject and Bun.deepMatch.
Description check ✅ Passed The description explains the problem, implementation, rationale, scope, and verification results in sufficient detail.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review (head 9ead87e; the two follow-up commits only shorten a code comment).

Reproduced on the released binary (1.4.0) and main 9a543cc: expect({ h: new Headers({ a: "1" }) }).toMatchObject({ h: new Headers({ a: "2" }) }) passes, as do the URLSearchParams, array, top-level and Bun.deepMatch variants; Vitest 4.1.9 fails all of them. Fix and reasoning in the PR description.

Fail-before / pass-after: test/js/bun/test/expect.test.js (toMatchObject describe, 5 new tests) and test/js/bun/bun-object/deep-match.spec.ts (new Headers / URLSearchParams rows) fail on the released binary and pass with this branch.

Related open work, intentionally not folded in: #37881 and #37887 fix the underlying Headers / URLSearchParams comparisons in specialObjectsDequal and apply here automatically once merged; #32870 covers Date / Error / Set / Map leaves with a separate rule.

Comment thread src/jsc/bindings/bindings.cpp Outdated
@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:06 PM PT - Aug 12th, 2026

❌ @robobun, your commit 9ead87e has 3 failures in Build #93485 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37904

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

bun-37904 --bun

Comment thread src/jsc/bindings/bindings.cpp 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/bindings.cpp (1)

1986-1991: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Compare duplicate URLSearchParams entries by multiplicity.

specialObjectsDequal uses get(key), which checks only the first value for each key. Equal sizes do not detect different duplicate counts, so a=1&a=1&b=2 and a=1&b=2&b=2 compare equal. Compare all entries, such as with getAll, and add a regression test.

🤖 Prompt for 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.

In `@src/jsc/bindings/bindings.cpp` around lines 1986 - 1991, Update the
URLSearchParams comparison in specialObjectsDequal to compare every value for
each key, using multiplicity-aware entry retrieval such as getAll rather than
only the first value from get. Preserve asymmetric matching behavior and add a
regression test covering equal-size parameters with different duplicate counts,
such as a=1&a=1&b=2 versus a=1&b=2&b=2.
🤖 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.

Outside diff comments:
In `@src/jsc/bindings/bindings.cpp`:
- Around line 1986-1991: Update the URLSearchParams comparison in
specialObjectsDequal to compare every value for each key, using
multiplicity-aware entry retrieval such as getAll rather than only the first
value from get. Preserve asymmetric matching behavior and add a regression test
covering equal-size parameters with different duplicate counts, such as
a=1&a=1&b=2 versus a=1&b=2&b=2.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 41b82e80-7713-4859-b6dd-53932a0b72be

📥 Commits

Reviewing files that changed from the base of the PR and between 7f4a54f and 9ead87e.

📒 Files selected for processing (1)
  • src/jsc/bindings/bindings.cpp

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

On the review note about the URLSearchParams comparison in specialObjectsDequal only looking at the first value per name (a=1&a=1&b=2 vs a=1&b=2&b=2): that is pre-existing toEqual / Bun.deepEquals behavior and is fixed by the open #37887, which compares the entry lists and includes that exact case as a test. This PR deliberately reuses that comparison rather than adding a second one, so toMatchObject picks the fix up when #37887 lands; the tests here stick to cases whose result is the same before and after it.

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

I reviewed this and didn't find bugs. It's a small, well-tested fix that reuses specialObjectsDequal the same way Bun__deepEquals does, but since it changes user-visible toMatchObject semantics in the C++ bindings and sits adjacent to #32870's approach for other leaf types, a maintainer sign-off on the shape would be worthwhile.

Checked: the same-JSType gate keeps Blob/Response/Request and mixed pairs on the property walk (specialObjectsDequal returns nullopt for them with isStrict=false); the wrapper branch never returns true, so matching entries fall through to the existing walk as intended. Exception handling (RETURN_IF_EXCEPTION after each call) and the local Vector/MarkedArgumentBuffer lifetimes look correct. The two-direction call is load-bearing for the a=1&a=1 vs a=1&b=1 URLSearchParams case and is covered by a test.

Extended reasoning...

Overview

The PR adds ~12 lines to Bun__deepMatch in src/jsc/bindings/bindings.cpp: when both the received and expected values are wrapper objects of the same JSType (JSDOMWrapperType or JSAsJSONType), it calls specialObjectsDequal in both argument orders before the property walk, mirroring lines 834-839 in Bun__deepEquals. This makes toMatchObject, Bun.deepMatch, expect.objectContaining, and snapshot property matchers compare Headers/URLSearchParams/URL by their entries instead of vacuously matching. Tests are added to expect.test.js (5 new tests covering nested/top-level/array/snapshot/objectContaining variants) and deep-match.spec.ts (10 new it.each rows).

Security risks

None. This is test-matcher comparison logic with no untrusted-input parsing, no allocation sizing from external data, and no auth/crypto surface.

Level of scrutiny

Medium-high. The C++ change itself is small and mechanical — it reuses an existing helper with the same call pattern as Bun__deepEquals, has proper RETURN_IF_EXCEPTION after each fallible call, and the local Vector/MarkedArgumentBuffer are stack-scoped RAII. I traced the JSDOMWrapperType/JSAsJSONType branch of specialObjectsDequal (bindings.cpp:1743-1857): with isStrict=false it only returns false on entry mismatch and nullopt otherwise (including for non-URL/Headers/USP wrappers like Blob/Response), so unknown wrapper types and matching-entry cases correctly fall through to the existing property walk. The same-JSType gate means Headers vs URLSearchParams (different JSType bytes) and Headers vs plain object skip the new block entirely.

However, this is a user-visible behavior change to toMatchObject — a core test-runner primitive — and the PR explicitly chose specialObjectsDequal over full Bun__deepEquals to avoid regressing Blob/Response, while noting that #32870 takes a different approach (full deepEquals) for Date/Error/Set/Map leaves. A maintainer should confirm this shape is the one they want and that it composes with #32870.

Other factors

Test coverage is thorough (nested, top-level, array, both directions of the asymmetric USP repeated-key case, snapshot matchers, objectContaining). The PR description documents fail-before/pass-after against the released binary and Vitest cross-check. The comment-cop bot flagged the code comment twice; both threads are resolved and the comment is now a single line in the latest commit (9ead87e). No prior claude[bot] review exists on this PR.

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

I reviewed this and didn't find any bugs. Because it changes user-visible toMatchObject / Bun.deepMatch semantics in bindings.cpp and is coordinated with several open PRs on the same comparison path (#32870, #37881, #37887), a maintainer sign-off would still be worthwhile.

Checked: the new block reuses specialObjectsDequal with the same both-direction call shape as Bun__deepEquals; for the JSDOMWrapperType/JSAsJSONType branch that helper only ever returns false or std::nullopt, so equal-entry pairs and unhandled wrapper classes (Blob, Request, Response) fall through to the existing property walk unchanged. Traced the asymmetric-iteration URLSearchParams cases (a=1&a=1 vs a=1&b=1) through both call orders — the second call catches the direction the first misses. Confirmed expect.any(Headers) as a property value is still handled by the per-property asymmetric-matcher check before recursion, so the new top-level gate does not interfere. RETURN_IF_EXCEPTION is placed after each call. The one CI failure is an unrelated Windows aarch64 winver.h clang warning.

Extended reasoning...

Overview

Adds a 13-line block at the top of Bun__deepMatch in src/jsc/bindings/bindings.cpp that, when both received and expected are the same wrapper JSType (JSDOMWrapperType or JSAsJSONType), invokes specialObjectsDequal in both argument orders — the same helper and call pattern Bun__deepEquals already uses — before the enumerable-property walk. This makes toMatchObject, Bun.deepMatch, expect.objectContaining, and snapshot property matchers compare Headers / URLSearchParams / URL by their entries instead of vacuously matching. New tests land in test/js/bun/test/expect.test.js (5 tests under the toMatchObject describe) and test/js/bun/bun-object/deep-match.spec.ts (10 new it.each rows).

Security risks

None. This is pure test-matcher comparison logic; no untrusted input parsing, no I/O, no auth/crypto surface.

Level of scrutiny

Moderate-to-high. bindings.cpp is a core, load-bearing file, and this changes observable behavior of four user-facing matcher APIs. The change itself is small and mechanically conservative — it reuses an existing helper with the exact call shape Bun__deepEquals uses at lines 834–837, has RETURN_IF_EXCEPTION after each throwing call, and gates on the same JSType values specialObjectsDequal already keys on. I traced the JSDOMWrapperType/JSAsJSONType case in specialObjectsDequal (lines 1743–1857): it returns false on entry mismatch and std::nullopt (via compareAsNormalValue: break) otherwise, never true, so equal-entry wrappers and every non-URL/Headers/URLSearchParams wrapper (Blob, Request, Response, FormData, Cookie) fall through to the pre-existing property walk. The fresh local stack and contentsGCBuffer are safe because that branch only compares native WTF::String values and does not recurse through the JS heap.

Other factors

  • The bug is unambiguous: two different Headers matching under toMatchObject is wrong, and Jest/Vitest both reject it. The PR description includes a released-binary vs. this-branch vs. Vitest behavior table, and fail-before/pass-after was verified on 1.4.0.
  • Test coverage is thorough across the variant matrix (nested / top-level / array element / snapshot property matcher / objectContaining / Bun.deepMatch), with negative cases for both subset-larger and superset-larger, and the both-direction URLSearchParams asymmetry case.
  • The comment-cop feedback was addressed (comment reduced to one line in 9ead87e).
  • The reason I'm deferring rather than approving: this is a behavior change to matcher semantics that intersects with three open PRs (#32870 takes a different-but-compatible approach for Date/Error/Set/Map leaves; #37881 and #37887 fix the underlying specialObjectsDequal comparisons this now depends on). The design choice — full-entry equality on these leaves rather than subset matching — is well-argued as Jest-compatible, but it's the kind of decision a maintainer should confirm, especially given the related in-flight work.

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