Repository navigation
Conversation
…ality assert.deepStrictEqual, util.isDeepStrictEqual and assert.partialDeepStrictEqual compared the lastIndex of two RegExps with SameValue. Node's areSimilarRegExps() uses ===. So a lastIndex of -0 was unequal to 0, and NaN was equal to NaN. Both native sites now call JSC::JSValue::strictEqual.
Write the nesting tests in deep-equal.test.ts with a plain loop, test() and node:assert. Add a "0" against 0 case. Add two rows for Sets and Maps that hold RegExps whose lastIndex differs only by the sign of zero.
|
Updated 4:22 PM PT - Nov 10th, 2026
✅ @robobun, your commit df4bb80d8f6e65912e418ff04dcb962fbd4fed58 passed in 🧪 To try this PR locally: bunx bun-pr 48501That installs a local version of the PR into your bun-48501 --bun |
|
Status How this was reproduced: the script under "Repro" in the PR notes compares two
On main's CI, build #139858: all 185 finished jobs passed. The two test files of this PR ran in the PR: #48501 |
|
No actionable comments were generated in the recent review. WalkthroughThe RegExp ChangesRegExp
Possibly related issues
Possibly related PRs
Suggested reviewers: Merge Risk:
|
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked whether the bindings.cpp change reaches bun:test's expect().toEqual/toStrictEqual or Bun.deepEquals — it does not: the edited arm is gated on mode.checkPrototypes, and the only instantiations with checkPrototypes=true are Bun__deepEqualsNodeStrict/Bun__deepEqualsNodeStrictSkipProto (the node:assert / node:util entry points); bun:test and Bun.deepEquals instantiate with checkPrototypes=false. Both new strictEqual calls keep the existing RETURN_IF_EXCEPTION under the throw scope, which matters since lastIndex can hold any user value. I did not run the test suites.
Extended reasoning...
The diff swaps JSC::sameValue for JSValue::strictEqual at the two RegExp lastIndex comparison sites used by node:assert/node:util deep equality and adds test matrix rows; it touches no security-sensitive surface. The ruled-out note records that bun:test matchers and Bun.deepEquals are unaffected because checkPrototypes is only true on the node entry points, and that exception checks after the new calls remain in place.
Problem
assert.deepStrictEqual,util.isDeepStrictEqualandassert.partialDeepStrictEqualcompare thelastIndexof two RegExps with SameValue (Object.is). Node uses===.-0against0throwsERR_ASSERTIONin Bun and passes in Node.NaNagainstNaNdoes the reverse.JSC::sameValueatsrc/jsc/bindings/bindings.cpp:1797andsrc/jsc/modules/NodeUtilTypesModule.cpp:812. node compat batch: callback-throw dispatch, Assert class + native deep-equality parity, Intl gate + URL/buffer fallout, compile cache, watch kill-signal, profilers (+98 tests) #34660 added both lines.Fix
JSC::JSValue::strictEqual, the engine's===. Looseassert.deepEqualstill ignoreslastIndex(assert.deepEqual()behaves differently in Bun compared to Node.js #23877).areSimilarRegExps()isa.lastIndex === b.lastIndexin every mode.test/js/node/assert/deep-equal.test.tsandtest/js/node/assert/assert.test.cjs. 22 tests fail on the unfixed build. Also rantest/js/node/assert/,bun-object/deep-equals.test.tsandbun/test/expect.test.js.sourceandflags, not by the text they were created from #48480, stack on node:assert: read properties and pair Set and Map entries as node's objEquiv does in strict deep equality #47806 (each has its own open decision).Background
lastIndexis an own data property of a RegExp. Only user code stores-0orNaNin it.===differ only for those two pairs.Downsides
lastIndexNaNwere deep-equal in every release. They are now unequal, as in Node.JSValue::strictEqualis a call whereJSC::sameValuewas inlined. A comparison of two RegExps costs 32 more instructions in strict mode (1972 to 2004) and 18 more in partial mode (923 to 941).lastIndexis0and-0no longer equals its copy in strict mode. Such Sets and Maps now reach the pairing bug ofassert.deepEqual/deepStrictEqualdoes not throw for unequal Sets #28760 and Bun testtoEqualincorrect comparison of maps with referential keys. #34830.Notes
Repro.
-0 vs 0NaN vs NaNtrue true true truefalse false false falsetrue true true truetrue true true truefalse false false falsetrue true true truetrue true true truefalse false false falseBefore 1.4.0,
assert.deepStrictEqualcalledBun.deepEquals, which does not readlastIndex. So 1.3.x accepted everylastIndexpair.Why
===. Node's documentation says thatlastIndexis "always compared" and names no operator. Its general line for strict mode says that primitives useObject.is. The implementation uses===forlastIndexin the loose, strict and partial modes. Fornode:*modules the implementation is the reference.Verdict probe. 39
lastIndexpairs (numbers, strings, BigInts,undefined,null, booleans, objects, symbols, and one RegExp object on both sides), 7 positions (top level, array element, object property, Map value, Map key, Set member, Errorcause) and 5 API forms (assert.deepStrictEqual,assert.notDeepStrictEqual,util.isDeepStrictEqualwith 2 and 3 arguments,assert.partialDeepStrictEqual). Each container holds one RegExp.lastIndex-0/0, 0/-0 or NaN/NaN.Bun.deepEqualscells changed: 0 of 546. Looseassert.deepEqualandassert.notDeepEqualcells changed: 0 of 546.Sets and Maps with several RegExps. Here
re(n)is/a/gwithlastIndexn. All rows areassert.deepStrictEqual.new Set([re(0), re(-0), re(1)])againstnew Set([re(0), re(1), re(1)])new Map([[re(0), 1], [re(-0), 2]])against a copynew Map([[re(0), 1], [re(0), 2]])against a copynew Set([{a:0}, {a:0}, {a:1}])againstnew Set([{a:0}, {a:1}, {a:1}])A grid against Node v26.12.1: collections of
/a/gRegExps withlastIndexfrom0,-0,1andNaN, every pair of collections,assert.deepStrictEqual.util.isDeepStrictEqualwith 2 and 3 arguments gives the same counts.In total, of 21,248 pairs, 1,268 are wrong on main and 430 are wrong with this change. 224 are newly wrong. Every newly wrong cell holds both a
0and a-0.assert.partialDeepStrictEqualhas 0 wrong cells with this change.The Set and Map walks take the first deep-equal member and do not pair members one to one. On main,
re(0)andre(-0)are different members, so these two inputs do not reach that walk. #47806 and #44636 are open for the pairing. Partial mode gives Node's verdict on all four rows. Twotest.failingrows (strictBug) pin the first two verdicts. If the pairing lands first, those two markers come off.What stays different from Node in the same two arms.
assert.deepEqualisBun.deepEquals(a, b, false), the same comparator mode as the publicBun.deepEquals(a, b). It does not readlastIndex(assert.deepEqual()behaves differently in Bun compared to Node.js #23877). The matrix records that withlooseBugrows. Draft node v26 compat, combined: CLI flags, async_hooks ids, deep-equality fidelity, console/util/dns/path fixes and 35 upstream tests #35391 adds a Node loose mode to the comparator.source,flagsorSymbol.toStringTagproperty on one RegExp is not read. Node reports such a pair unequal.new RegExp("a/b")and/a\/b/are unequal. deepEquals, node:assert: compare RegExps bysourceandflags, not by the text they were created from #48480 is open for that. It edits the line above each site. The C++ hunks and the test hunks of the two changes merge without a conflict.AggregateError#errors, so alastIndexthere is not compared. node:assert, node:util: compare theerrorsof an Error like Node #35495 is open for that.Measurements. Release builds (LTO, linux-x64) of 98cda0d plus this change, with and without the two tokens. Tools:
llvm-nm,llvm-objdump,llvm-size,gdb, and aperf_event_open(2)wrapper that countsinstructions:u. The container has noperf,valgrind,straceorbloaty.util.isDeepStrictEqual(/a/g, /a/g): 1971.74 -> 2003.74 instructions per call. With skipPrototype: 1609.03 -> 1641.03.assert.partialDeepStrictEqual: 922.52 -> 940.53. Each number is from 2e6 calls with the empty loop subtracted, the median of 5 interleaved runs. The spread is 1.89, 1.90 and 0.02.gdbstepiover one warm call, from host function entry to return:jsFunctionIsDeepStrictEqual1926 -> 1958, with skipPrototype 1562 -> 1594,jsFunctionPartialDeepStrictEqual862 -> 880.Bun__deepEqualsasks the RegExp arm once for each operand order. Partial mode asks once.lastIndexload through the branch after the exception check. The call is the out-of-lineJSC::JSValue::strictEqual(303 bytes, present in both binaries, 13 instructions for two int32 values).Bun.deepEquals(/a/g, /a/g): 485.07 -> 484.08 instructions per call. With the strict flag: 490.37 -> 489.37. Date pair: 374.06 -> 374.05.util.isDeepStrictEqual({a:1},{a:1}): 1143.14 -> 1143.14. The -1.00 is a code layout change inspecialObjectsDequalSlow.specialObjectsDequalSlow10,097 -> 9,868 B,PartialDeepEqual::compareBranch21,924 -> 21,577 B,buntext 142,830,298 -> 142,829,786 B (-512 B).heapStats().mimalloc.malloc_normal_count,BUN_JSC_useJIT=0): strict 40,020 -> 40,020, skipPrototype 40,025 -> 40,025, partial 20,018 -> 20,018.lastIndex): 0 unchecked-exception reports.Tests.
deep-equal.test.ts, 7 inassert.test.cjs): 18 for the fixed verdicts and 4test.failingchecks of the two collection rows. All 519 tests of the two files pass with the fix.node:testfile under Node v26.12.1 and v26.3.0.deep-equal.test.tsuses a plain loop,test()andnode:assert. node:assert: read properties and pair Set and Map entries as node's objEquiv does in strict deep equality #47806 converts that file tonode:test, which has notest.eachand noexpect. The file merged with node:assert: read properties and pair Set and Map entries as node's objEquiv does in strict deep equality #47806 passes under Node v26.12.1 (1903 tests).test/js/node/assert/,test/js/bun/bun-object/deep-equals.test.ts,deep-equals-temporal.test.ts,deep-match.spec.ts,test/js/bun/test/expect.test.js, and the vendoredtest-util-isDeepStrictEqual.js,test-assert.js,test-assert-deep-with-error.js,test-assert-typedarray-deepequal.js,test-assert-class.js,test-assert-checktag.js,test-assert-class-destructuring.js,test-runner-assert.js.Other designs weighed.
lastIndex, as Node's function does. The source and flags compare also servesBun.deepEqualsandexpect(), which must not readlastIndex. deepEquals, node:assert: compare RegExps bysourceandflags, not by the text they were created from #48480 changes that compare.comparisons.jsto JS (Implement Node's deep equality algorithm for node:assert #33068). Main has kept these comparators in native code since node compat batch: callback-throw dispatch, Assert class + native deep-equality parity, Intl gate + URL/buffer fallout, compile cache, watch kill-signal, profilers (+98 tests) #34660.