Skip to content

Compare Temporal objects by value in toMatchObject and Bun.deepMatch - #39288

Open
robobun wants to merge 1 commit into
mainfrom
farm/3a846784/deep-match-temporal
Open

robobun wants to merge 1 commit into
mainfrom
farm/3a846784/deep-match-temporal

Conversation

@robobun

@robobun robobun commented Aug 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A Temporal value used as the expected side of a subset match accepts any received object. On bun 1.4.0 all of these pass / return true:
    expect({ d: Temporal.PlainDate.from("2024-01-01") }).toMatchObject({ d: Temporal.PlainDate.from("2024-01-02") });
    expect(Temporal.Duration.from("PT60M")).toMatchObject(Temporal.Duration.from("PT1H"));
    expect({ d: {} }).toMatchObject({ d: Temporal.PlainDate.from("2024-01-01") });
    Bun.deepMatch({ d: Temporal.PlainDate.from("2024-01-02") }, { d: Temporal.PlainDate.from("2024-01-01") });
    expect({ d: Temporal.PlainDate.from("2024-01-01") }).toMatchSnapshot({ d: Temporal.PlainDate.from("2099-12-31") });
  • toEqual, toStrictEqual and Bun.deepEquals have compared Temporal values by their internal fields since Compare Temporal objects by value in Bun.deepEquals and toEqual #37024, so the matchers disagree with each other inside one test file.
  • Cause: Bun__deepMatch (src/jsc/bindings/bindings.cpp, shared by toMatchObject, Bun.deepMatch, expect.objectContaining, and the property matchers of toMatchSnapshot / toMatchInlineSnapshot) collects the expected value's enumerable properties and checks each against the received value. A Temporal object has none (its fields are internal slots behind non-enumerable prototype getters), so the loop runs zero times and returns true.

Fix

  • At the top of Bun__deepMatch, an expected value that JSC::temporalType() classifies as Temporal is compared with temporalObjectsDequal, the comparator deepEquals already uses, instead of the property walk. Same class and same fields match; a different class, a different value, or a non-Temporal received value does not. Extra own properties are ignored, as toEqual already does for Temporal and Date.
  • Only the expected side is checked, so a plain expected object keeps its current behavior against a Temporal received value: {} still matches, and expect(plainDate).toMatchObject({ year: 2024 }) still reads the prototype getter.
  • The local isTemporalObject helper is replaced by JSC::temporalType, which performs the same eight-class check (this is the same replacement Format Temporal values in console.log, util.inspect, and test pretty-format #37043 makes, so the two merge cleanly in either order).
  • The new code does not run JS, so no exception checks are needed; the file also passes BUN_JSC_validateExceptionChecks=1.
  • Verified:
    • test/js/bun/bun-object/deep-equals-temporal.test.ts: new subset matching on Temporal values block covering all eight classes through toMatchObject (nested, in arrays, and top level), expect.objectContaining, Bun.deepMatch, cross-class and plain-object expected values, extra own properties, and snapshot property matchers. 35 of the new cases fail on bun 1.4.0 (also with CI=true), all 113 pass with this change.
    • test/js/bun/test/expect.test.js (415 pass), deep-match.spec.ts, snapshot-tests/bun-snapshots.test.ts, node/assert/deep-equal.test.ts, both TOML suites and web/temporal/temporal.test.ts: unchanged.
  • Intentionally not in this PR: the same vacuous match for Date / Error / Set / Map expected values is expect: fix toBeCloseTo opposite-sign Infinity and toMatchObject Date/Error/Set/Map leaves #32870, and for Headers / URLSearchParams it is Make toMatchObject and Bun.deepMatch compare Headers and URLSearchParams by their entries #37904; both are separate decisions about how those leaves should compare. The snapshot text itself (PlainDate {} for every value, so a snapshot of a Temporal value never fails, and toEqual failures print an empty diff) is the pretty-format side and is addressed by Format Temporal values in console.log, util.inspect, and test pretty-format #37043; after this PR a mismatching Temporal property matcher fails, but the diff printed for it still looks empty until Format Temporal values in console.log, util.inspect, and test pretty-format #37043 lands.

Background

  • Subset matching is the Jest semantic behind toMatchObject: the expected value lists properties the received value must have; extra received properties are fine. In Bun it is implemented once in Bun__deepMatch and reused by Bun.deepMatch(subset, object), expect.objectContaining, and the optional property-matchers argument of the snapshot matchers.
  • Temporal objects (Instant, PlainDate, PlainDateTime, PlainTime, ZonedDateTime, PlainYearMonth, PlainMonthDay, Duration) are immutable values whose state lives in JSC internal slots; year, epochNanoseconds, etc. are non-enumerable getters on the prototype. Any property-based comparison therefore sees two instances of the same class as identical. Date has the same shape, which is why deepEquals special-cases both.
  • temporalObjectsDequal is the field comparison added in Compare Temporal objects by value in Bun.deepEquals and toEqual #37024: same class required, then exact time for Instant, ISO fields plus calendar for the plain types, exact time plus time zone plus calendar for ZonedDateTime, and unit-by-unit for Duration (so PT1H and PT60M stay different).
  • JSC::temporalType(JSValue) is JSC's classifier for these eight classes (returns TemporalType::None for anything else); Bun already uses it for TOML serialization.
Entry points probed on bun 1.4.0 before the change

Value-blind (a different Temporal value on the expected side was accepted):

  • toMatchObject, nested property and top level
  • toMatchObject with a plain object received and a Temporal expected value
  • toMatchObject across classes (PlainDateTime received, PlainDate expected, same fields)
  • toMatchObject on Duration PT60M vs PT1H
  • .not.toMatchObject
  • Bun.deepMatch
  • toMatchSnapshot(propertyMatchers)
  • expect.objectContaining(temporalValue) used as the whole pattern

Already correct: a Temporal value nested inside an expect.objectContaining({...}) pattern, because nested objectContaining properties are compared with deepEquals, which #37024 fixed.


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

fails on main (without fix)
ASAN without fix: 35 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/bun-object/deep-equals-temporal.test.ts
bun test v1.4.0 (8c5296ac4)

test/js/bun/bun-object/deep-equals-temporal.test.ts:
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [6.93ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [16.88ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [3.49ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [2.73ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [242.66ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [2.88ms]
(pass) Bun.deepEquals on Temporal values (st
... (truncated)

release without fix: 35 FAILED
bun test v1.4.0-canary.1 (eabb96de7)

test/js/bun/bun-object/deep-equals-temporal.test.ts:
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [0.12ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [4.62ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [0.06ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [0.03ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [4.81ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [0.84ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [0.05ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately const
... (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/mechgate.xml" test/js/bun/bun-object/deep-equals-temporal.test.ts
bun test v1.4.0 (8c5296ac4)

test/js/bun/bun-object/deep-equals-temporal.test.ts:
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [4.66ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [9.39ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [2.49ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [1.68ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [154.73ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [2.47ms]
(pass) Bun.deepEquals on Temporal values (str
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 749ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] cxx obj/src/jsc/bindings/bindings.cpp.o
[2/6] gen cpp.rs (cppbind)
[2/6] 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_bin v0.0.0 (/workspace/bun/src/bun_bin)
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 3m 26s
[3/6] link bun-profile
[5/6] strip bun
[5/6] bun-profile --revision
1.4.0-canary.1+3d880fffe
[build] done
bun test v1.4.0-canary.1 (3d880fffe)

test/js/bun/bun-object/deep-equals-temporal.test.ts:
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [0.07ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([Function]) [2.62ms]
(pass) Bun.deepEquals on Temporal values (strict: true) > two separately constructed instances of the same value are equal ([
... (truncated)
diff hotspot
src/jsc/bindings/bindings.cpp                      | 19 ++---
 .../js/bun/bun-object/deep-equals-temporal.test.ts | 87 ++++++++++++++++++++++
 2 files changed, 93 insertions(+), 13 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                 reads  edits  tests
src/jsc/bindings/bindings.cpp                            6      6      0
test/js/bun/bun-object/deep-equals-temporal.test.ts      1      4      0

Bun__deepMatch walks the expected object's enumerable properties.
Temporal objects keep their value in internal slots and have none, so a
Temporal expected value matched any received object in toMatchObject,
Bun.deepMatch, expect.objectContaining, and snapshot property matchers.
Route a Temporal expected value through the comparator deepEquals
already uses, and replace the local Temporal class check with
JSC::temporalType.
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 35 seconds

Limit details: You’ve used all 5 included reviews currently available under your plan.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 97a3b24a-9d0c-4a3a-9567-cee7dbcba42c

📥 Commits

Reviewing files that changed from the base of the PR and between 8c5296a and 3d880ff.

📒 Files selected for processing (2)
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/bun-object/deep-equals-temporal.test.ts

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

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:51 PM PT - Aug 15th, 2026

@robobun, your commit 3d880ff is building: #99094

@robobun

robobun commented Aug 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. Automated review left no requested changes; CI is running.

Reproduced on bun 1.4.0 with USE_SYSTEM_BUN=1 bun test test/js/bun/bun-object/deep-equals-temporal.test.ts: 35 of the new cases fail (a different Temporal value on the expected side is accepted by toMatchObject, Bun.deepMatch, expect.objectContaining, and snapshot property matchers); all 113 pass with this branch. Related open work this does not overlap with: #37043 (Temporal text in snapshots and diffs), #32870 (Date/Error/Set/Map leaves), #37904 (Headers/URLSearchParams leaves).

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

LGTM — small, well-scoped correctness fix reusing the existing temporalObjectsDequal comparator.

What was reviewed:

  • Traced temporalObjectsDequal(obj, subsetObj) — when subsetObj is Temporal it never returns nullopt, so .value() is safe.
  • Confirmed the check is on the subset side only, so expect(plainDate).toMatchObject({ year: 2024 }) still walks prototype getters (covered by a test).
  • Verified isTemporalObject had no other callers; its replacement JSC::temporalType is already used at both a JSObject* and a JSValue call site in this file.
  • New path does no JS execution (only dynamicDowncast + internal-slot reads), so no exception check is needed.
Extended reasoning...

Overview

The PR fixes Bun__deepMatch (backing toMatchObject, Bun.deepMatch, expect.objectContaining, and snapshot property matchers) to compare Temporal objects by their internal fields instead of vacuously accepting any object. The C++ change is four new lines in bindings.cpp plus replacing the local isTemporalObject helper with JSC::temporalType. ~87 lines of new tests in deep-equals-temporal.test.ts cover all eight Temporal classes across every entry point.

Security risks

None. This is test-matcher comparison logic; no untrusted input parsing, no allocation, no I/O.

Level of scrutiny

Low-to-medium. The change reuses temporalObjectsDequal (added in #37024 for deepEquals) verbatim, guarded by the same JSC::temporalType classifier already used elsewhere in the file. I traced the optional: when the guard holds (subsetValue is Temporal), o2 is Temporal, and temporalObjectsDequal returns a definite bool on every branch — either o1 matches one of the eight dynamicDowncast arms, or the trailing temporalType(o2) != None check returns false. .value() cannot throw. The new code runs no user JS, so no RETURN_IF_EXCEPTION is needed (consistent with the PR's BUN_JSC_validateExceptionChecks=1 claim). The pre-existing invariant that obj/subsetObj are non-null objects is unchanged — both were already dereferenced unconditionally below.

Other factors

Test coverage is thorough: each of the eight classes is exercised through toMatchObject (nested, array, top-level), expect.objectContaining, Bun.deepMatch, plus cross-class mismatch, plain-object received, plain-object expected (asymmetry preserved), extra own properties, and snapshot property matchers with a specific error-message assertion. The PR description documents 35 of these fail on 1.4.0. Related but distinct vacuous-match cases (Date/Error/Set/Map, Headers/URLSearchParams, pretty-format) are explicitly deferred to their own tracked issues, keeping scope tight. No CODEOWNERS on the touched paths and no prior human review comments to address.

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