Skip to content

[Fix] compare the values wrapped by two boxed primitives - #111

Merged
ljharb merged 1 commit into
inspect-js:mainfrom
nimbly1:fix/boxed-primitive-values
Nov 5, 2026
Merged

ljharb merged 1 commit into
inspect-js:mainfrom
nimbly1:fix/boxed-primitive-values

Conversation

@nimbly1

@nimbly1 nimbly1 commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

deepEqual treats two boxed primitives of the same kind as equal, whatever they wrap:

deepEqual(Object(1), Object(2));                           // true, in loose and strict mode
deepEqual(Object(true), Object(false), { strict: true });  // true
deepEqual([Object(1)], [Object(2)]);                       // true
deepEqual({ a: Object(1) }, { a: Object(2) });             // true

This holds for boxed numbers, booleans, BigInts and symbols. internalDeepEqual compares only the kind of the box (whichBoxedPrimitive) and never the primitive inside it. String objects were already told apart, but only because of their index keys.

The fix compares the wrapped primitives with Object.is. It reads them through the intrinsic valueOf, so an overridden valueOf is never called (the existing "valueOf is called for boxed primitives" test still passes). Node's assert.deepEqual and assert.deepStrictEqual (Node 24) compare boxed primitives the same way in both modes:

pair before after node assert
Object(1), Object(2) equal not equal not equal
Object(true), Object(false) equal not equal not equal
Object(NaN), Object(NaN) equal equal equal
Object(0), Object(-0) equal not equal not equal

One behavior change to check: Object(0) and Object(-0) are now different in loose mode too, as they are in Node. Plain 0 and -0 are still equal in loose mode. Tell me if you would rather keep boxed 0 and -0 equal in loose mode, and I will limit the check.

Checked: 60 new assertions in the "boxed primitives" test in test/cmp.js. 28 of them fail without the change. npx tape 'test/**/*.js' passes (720 tests), and eslint --ext=js,mjs . has no errors (the 13 warnings were there before). index.js is 399 lines, so I kept the change small for the max-lines limit of 400.

I also compared 65 pairs of values against Node's util.isDeepStrictEqual. The differences went from 14 to 9. The remaining 9 are other things, and I did not touch them: properties keyed by symbols, WeakMap and Promise instances, typed arrays that hold NaN or -0, invalid dates, Error cause, and RegExp lastIndex.

Not run: the npm audit step of npm test. The nyc coverage numbers read 0% on my Windows machine, so I am not quoting coverage. With ASSERT=1, 26 tests fail before and after this change (the WeakMap ones among them), and the new assertions pass.

Generated with Claude Code

Two boxed primitives of the same kind were equal whatever they wrapped:
`deepEqual(Object(1), Object(2))` was true, in loose and strict mode,
and so were boxed `true` and `false`, boxed BigInts and boxed Symbols,
alone or nested in an array or an object.
Only the kind was compared,
and `String` objects were told apart just because of their index keys.

Compare the wrapped primitives with `Object.is`,
read through the intrinsic `valueOf` so that an overridden one is never called.
This is what node's `assert.deepEqual` and `assert.deepStrictEqual` do:
a boxed `NaN` still equals another boxed `NaN`,
and a boxed `0` no longer equals a boxed `-0`.

Assisted-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Note that all of the transitive dependencies of `unbox-primitive` are already production dependencies.

Co-authored-by: nimbly1 <258366730+nimbly1@users.noreply.github.com>
Co-authored-by: Jordan Harband <ljharb@gmail.com>
@codecov

codecov Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (bd2b5a8) to head (3b11d93).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff             @@
##             main      #111      +/-   ##
===========================================
+ Coverage   98.30%   100.00%   +1.69%     
===========================================
  Files           2         2              
  Lines         236       238       +2     
  Branches       99       100       +1     
===========================================
+ Hits          232       238       +6     
+ Misses          4         0       -4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@nimbly1

nimbly1 commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

A note on the red checks. I think neither is caused by this change.

Tests: node.js assert (19 jobs, Node 8 to 26). This workflow already failed on main (run 23129362237, 2026-03-16). The Node 24 job on this PR reports 26 failing tests, and I get the same 26 when I run ASSERT=1 npx tape 'test/**/*.js' on the unmodified code. They are the primitive comparisons, Dates with own properties and WeakMap tests. The new boxed-primitive assertions pass in that mode.

tests / node tests in Tests: node.js >= 10. All 31 test jobs in that run passed (Node 10.24 to 26.12, and the vlt and bun jobs). The gate job only runs exit 1 when one of latest, nonlatest, vlt-latest or bun-latest reports a failure, and nonlatest created no jobs at all in this run. On main in March it created 250 jobs. With range: >= 10 and type: minors, today's nodejs.org/dist/index.json gives 289 minor versions across 17 majors, so 272 non-latest jobs. GitHub allows 256 jobs in one matrix. I could not read the matrix error itself, so this is an inference. If it is right, every PR to this repo will see the same red gate until the matrix is split.

Tests: node.js < 10 passed (Node 0.8 to 9.x, 98 jobs), and so did Tests: pretest/posttest.

@ljharb
ljharb force-pushed the fix/boxed-primitive-values branch from f6cceb4 to 3b11d93 Compare November 5, 2026 20:26

@ljharb ljharb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is a great catch - two boxed primitives of the same kind comparing equal regardless of their wrapped value was definitely a bug, and matching node's assert here (including boxed 0 vs -0 in loose mode) is the right call.

I pushed a couple of changes and squashed them into your commit:

  • Use unbox-primitive instead of a local unbox helper. The helper called callBound with a dynamically built name on every comparison; intrinsics should be cached at module level with explicit string arguments. unbox-primitive already does exactly that (and only uses the intrinsic valueOf/toString, so overridden methods are still never called), and all of its transitive dependencies are already production dependencies of this package, so it only adds the one package. This also frees up a few lines under max-lines.
  • More tests:
    • boxed primitives that share an overridden valueOf - both as an own property and via a shared [[Prototype]] - to make sure the wrapped value is read through the intrinsic and not the user-provided method
    • boxed primitives as Set elements, and as Map keys and values, since those go through a different code path (setHasEqualElement/mapHasEqualEntry) than arrays and plain objects, and were also returning true before this fix

All the new "not equal" assertions fail without the fix, and the ASSERT=1 failures are unchanged from main.

@ljharb
ljharb merged commit 3b11d93 into inspect-js:main Nov 5, 2026
160 of 180 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants