From cefb2b4cb80a7187cbd56eef495cf4f4c7afd345 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:54:56 +0000 Subject: [PATCH 1/2] util: honor isDeepStrictEqual skipPrototype third argument Node v26 added a third skipPrototype argument to util.isDeepStrictEqual. When truthy, the prototype/class comparison is skipped recursively while every other strict rule (type tags, property counts, strict value compares) still applies, so instances of different classes with identical own properties compare equal. Bun hardcoded the two-argument form and dropped the third argument. Thread a skipPrototype flag through Bun__deepEquals/specialObjectsDequal and bypass the two calculatedClassName comparisons when it is set, then forward the third argument from util.isDeepStrictEqual. --- src/js/node/util.ts | 2 +- src/jsc/bindings/BunObject.cpp | 8 ++- src/jsc/bindings/bindings.cpp | 42 ++++++++------ src/jsc/bindings/headers-handwritten.h | 3 +- test/js/node/util/util.test.js | 79 ++++++++++++++++++++++++++ 5 files changed, 112 insertions(+), 22 deletions(-) diff --git a/src/js/node/util.ts b/src/js/node/util.ts index 88b6cc4a0ceb..a7d7384614cd 100644 --- a/src/js/node/util.ts +++ b/src/js/node/util.ts @@ -23,7 +23,7 @@ function isFunction(value) { } const deepEquals = Bun.deepEquals; -const isDeepStrictEqual = (a, b) => deepEquals(a, b, true); +const isDeepStrictEqual = (a, b, skipPrototype) => deepEquals(a, b, true, skipPrototype); const parseArgs = $newRustFunction("parse_args.rs", "parseArgs", 1); diff --git a/src/jsc/bindings/BunObject.cpp b/src/jsc/bindings/BunObject.cpp index 02a1127e00df..f13d6a152bad 100644 --- a/src/jsc/bindings/BunObject.cpp +++ b/src/jsc/bindings/BunObject.cpp @@ -698,13 +698,17 @@ JSC_DEFINE_HOST_FUNCTION(functionBunDeepEquals, (JSGlobalObject * globalObject, JSC::JSValue arg1 = callFrame->uncheckedArgument(0); JSC::JSValue arg2 = callFrame->uncheckedArgument(1); JSC::JSValue strict = callFrame->argument(2); + // Node's isDeepStrictEqual third argument: skip the prototype/class comparison. + // Only meaningful in strict mode; loose mode never compares prototypes. + bool skipPrototype = callFrame->argument(3).toBoolean(globalObject); Vector, 16> stack; MarkedArgumentBuffer gcBuffer; if (strict.isBoolean() && strict.asBoolean()) { - - bool isEqual = Bun__deepEquals(globalObject, arg1, arg2, gcBuffer, stack, scope, true); + bool isEqual = skipPrototype + ? Bun__deepEquals(globalObject, arg1, arg2, gcBuffer, stack, scope, true) + : Bun__deepEquals(globalObject, arg1, arg2, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsBoolean(isEqual)); } else { diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 3a30e116154e..c70b969ac06d 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -648,10 +648,10 @@ JSValue getIndexWithoutAccessors(JSGlobalObject* globalObject, JSObject* obj, ui return JSValue(); } -template +template std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, JSCell* _Nonnull c1, JSCell* _Nonnull c2); -template +template bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, bool addToStack) { VM& vm = globalObject->vm(); @@ -730,10 +730,10 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, JSCell* c2 = v2.asCell(); ASSERT(c1); ASSERT(c2); - std::optional isSpecialEqual = specialObjectsDequal(globalObject, gcBuffer, stack, scope, c1, c2); + std::optional isSpecialEqual = specialObjectsDequal(globalObject, gcBuffer, stack, scope, c1, c2); RETURN_IF_EXCEPTION(scope, false); if (isSpecialEqual.has_value()) return WTF::move(*isSpecialEqual); - isSpecialEqual = specialObjectsDequal(globalObject, gcBuffer, stack, scope, c2, c1); + isSpecialEqual = specialObjectsDequal(globalObject, gcBuffer, stack, scope, c2, c1); if (isSpecialEqual.has_value()) return WTF::move(*isSpecialEqual); JSObject* o1 = v1.getObject(); JSObject* o2 = v2.getObject(); @@ -780,7 +780,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, } } - auto eql = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); + auto eql = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, false); if (!eql) return false; } @@ -835,7 +835,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, return false; } - auto eql = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); + auto eql = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, false); if (!eql) return false; } @@ -845,7 +845,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, return true; } - if constexpr (isStrict) { + if constexpr (isStrict && !skipPrototype) { if (!equal(JSObject::calculatedClassName(o1), JSObject::calculatedClassName(o2))) { return false; } @@ -883,7 +883,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, RETURN_IF_EXCEPTION(scope, false); if (same) return true; - auto eql = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); + auto eql = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, false); if (!eql) { result = false; @@ -919,7 +919,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, RETURN_IF_EXCEPTION(scope, false); if (same) return true; - auto eql = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); + auto eql = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, false); if (!eql) { result = false; @@ -1007,7 +1007,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, return false; } - auto eql = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); + auto eql = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, false); if (!eql) return false; } @@ -1028,7 +1028,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, return true; } -template +template std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, JSCell* _Nonnull c1, JSCell* _Nonnull c2) { VM& vm = globalObject->vm(); @@ -1065,7 +1065,7 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark JSValue key2; bool foundMatchingKey = false; while (iter2->next(globalObject, key2)) { - bool equal = Bun__deepEquals(globalObject, key1, key2, gcBuffer, stack, scope, false); + bool equal = Bun__deepEquals(globalObject, key1, key2, gcBuffer, stack, scope, false); RETURN_IF_EXCEPTION(scope, {}); if (equal) { foundMatchingKey = true; @@ -1108,7 +1108,7 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark JSValue key2; bool foundMatchingKey = false; while (iter2->nextKeyValue(globalObject, key2, value2)) { - bool keysEqual = Bun__deepEquals(globalObject, key1, key2, gcBuffer, stack, scope, false); + bool keysEqual = Bun__deepEquals(globalObject, key1, key2, gcBuffer, stack, scope, false); RETURN_IF_EXCEPTION(scope, {}); if (keysEqual) { foundMatchingKey = true; @@ -1123,7 +1123,7 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark // Compare both values below. } - bool valuesEqual = Bun__deepEquals(globalObject, value1, value2, gcBuffer, stack, scope, false); + bool valuesEqual = Bun__deepEquals(globalObject, value1, value2, gcBuffer, stack, scope, false); RETURN_IF_EXCEPTION(scope, {}); if (!valuesEqual) { return false; @@ -1255,7 +1255,7 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark RETURN_IF_EXCEPTION(scope, {}); auto rightCause = right->get(globalObject, cause); RETURN_IF_EXCEPTION(scope, {}); - bool causesEqual = Bun__deepEquals(globalObject, leftCause, rightCause, gcBuffer, stack, scope, true); + bool causesEqual = Bun__deepEquals(globalObject, leftCause, rightCause, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, {}); if (!causesEqual) { return false; @@ -1305,7 +1305,7 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark return false; } - bool propertiesEqual = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); + bool propertiesEqual = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, {}); if (!propertiesEqual) { return false; @@ -1421,8 +1421,10 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark return false; } - if (!equal(JSObject::calculatedClassName(c1->getObject()), JSObject::calculatedClassName(c2->getObject()))) { - return false; + if constexpr (!skipPrototype) { + if (!equal(JSObject::calculatedClassName(c1->getObject()), JSObject::calculatedClassName(c2->getObject()))) { + return false; + } } JSString* s1 = c1->toStringInline(globalObject); @@ -1585,6 +1587,10 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark return std::nullopt; } +// isDeepStrictEqual's skipPrototype mode (Node's third argument) is only used from +// BunObject.cpp, so force this instantiation here where the definition is visible. +template bool Bun__deepEquals(JSC::JSGlobalObject*, JSValue, JSValue, MarkedArgumentBuffer&, Vector, 16>&, ThrowScope&, bool); + /** * @brief `Bun.deepMatch(a, b)` * diff --git a/src/jsc/bindings/headers-handwritten.h b/src/jsc/bindings/headers-handwritten.h index e421d79b93f7..8ddcf16e2eee 100644 --- a/src/jsc/bindings/headers-handwritten.h +++ b/src/jsc/bindings/headers-handwritten.h @@ -429,7 +429,8 @@ extern "C" void Bun__EventLoop__runCallback2(JSC::JSGlobalObject* global, JSC::E extern "C" void Bun__EventLoop__runCallback3(JSC::JSGlobalObject* global, JSC::EncodedJSValue callback, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue arg1, JSC::EncodedJSValue arg2, JSC::EncodedJSValue arg3); /// @note throws a JS exception and returns false if a stack overflow occurs -template +/// @note skipPrototype skips the prototype/class-name comparison (Node's isDeepStrictEqual third argument) +template bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSC::JSValue v1, JSC::JSValue v2, JSC::MarkedArgumentBuffer&, Vector, 16>& stack, JSC::ThrowScope& scope, bool addToStack); /** diff --git a/test/js/node/util/util.test.js b/test/js/node/util/util.test.js index 8427b75c74e1..47e26493dc59 100644 --- a/test/js/node/util/util.test.js +++ b/test/js/node/util/util.test.js @@ -436,3 +436,82 @@ describe("util.parseEnv", () => { expect(util.parseEnv(new String("FOO=bar"))).toEqual({ FOO: "bar" }); }); }); + +describe("isDeepStrictEqual skipPrototype (third argument)", () => { + class Foo { + constructor(a) { + this.a = a; + } + } + class Bar { + constructor(a) { + this.a = a; + } + } + + it("has length 3 to match Node", () => { + expect(util.isDeepStrictEqual.length).toBe(3); + }); + + it("skips the prototype check when the third argument is truthy", () => { + // https://github.com/oven-sh/bun/issues/33074 + expect(util.isDeepStrictEqual(new Foo(1), new Bar(1), true)).toBe(true); + }); + + it("keeps the prototype check without the third argument", () => { + expect(util.isDeepStrictEqual(new Foo(1), new Bar(1))).toBe(false); + expect(util.isDeepStrictEqual(new Foo(1), new Bar(1), false)).toBe(false); + }); + + it("coerces the third argument like Node (truthiness)", () => { + expect(util.isDeepStrictEqual(new Foo(1), new Bar(1), 1)).toBe(true); + expect(util.isDeepStrictEqual(new Foo(1), new Bar(1), "x")).toBe(true); + expect(util.isDeepStrictEqual(new Foo(1), new Bar(1), {})).toBe(true); + expect(util.isDeepStrictEqual(new Foo(1), new Bar(1), 0)).toBe(false); + expect(util.isDeepStrictEqual(new Foo(1), new Bar(1), "")).toBe(false); + expect(util.isDeepStrictEqual(new Foo(1), new Bar(1), null)).toBe(false); + expect(util.isDeepStrictEqual(new Foo(1), new Bar(1), undefined)).toBe(false); + }); + + it("still enforces the other strict rules when skipping prototypes", () => { + expect(util.isDeepStrictEqual(new Foo(1), new Bar(2), true)).toBe(false); + const extra = new Bar(1); + extra.b = 2; + expect(util.isDeepStrictEqual(new Foo(1), extra, true)).toBe(false); + expect(util.isDeepStrictEqual([], {}, true)).toBe(false); + expect(util.isDeepStrictEqual(new Date(0), {}, true)).toBe(false); + }); + + it("treats plain, null-prototype and class objects as equal", () => { + expect(util.isDeepStrictEqual({ a: 1 }, new Foo(1), true)).toBe(true); + expect(util.isDeepStrictEqual(Object.create(null), {}, true)).toBe(true); + }); + + it("applies recursively to nested values", () => { + expect(util.isDeepStrictEqual({ x: new Foo(1) }, { x: new Bar(1) }, true)).toBe(true); + expect(util.isDeepStrictEqual({ a: { b: { c: new Foo(1) } } }, { a: { b: { c: new Bar(1) } } }, true)).toBe(true); + + const g1 = {}; + Object.defineProperty(g1, "x", { enumerable: true, get: () => new Foo(1) }); + const g2 = {}; + Object.defineProperty(g2, "x", { enumerable: true, get: () => new Bar(1) }); + expect(util.isDeepStrictEqual(g1, g2, true)).toBe(true); + }); + + it("applies recursively through arrays, Maps and Sets", () => { + class MyMap extends Map {} + expect(util.isDeepStrictEqual([new Foo(1)], [new Bar(1)], true)).toBe(true); + expect(util.isDeepStrictEqual(new MyMap([["a", 1]]), new Map([["a", 1]]), true)).toBe(true); + expect(util.isDeepStrictEqual(new Map([["k", new Foo(1)]]), new Map([["k", new Bar(1)]]), true)).toBe(true); + expect(util.isDeepStrictEqual(new Map([[new Foo(1), 1]]), new Map([[new Bar(1), 1]]), true)).toBe(true); + expect(util.isDeepStrictEqual(new Map([[new Foo(1), 1]]), new Map([[new Bar(1), 2]]), true)).toBe(false); + expect(util.isDeepStrictEqual(new Set([new Foo(1)]), new Set([new Bar(1)]), true)).toBe(true); + }); + + it("skips the prototype check for boxed String objects", () => { + class MyStr extends String {} + expect(util.isDeepStrictEqual(new String("a"), new MyStr("a"), true)).toBe(true); + expect(util.isDeepStrictEqual(new String("a"), new MyStr("a"))).toBe(false); + expect(util.isDeepStrictEqual(new String("a"), new MyStr("b"), true)).toBe(false); + }); +}); From b687664b091628c2d3807ad3d2a8979afba165c7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:00:42 +0000 Subject: [PATCH 2/2] check for pending exception after second specialObjectsDequal call Matches the handling after the first call; specialObjectsDequal can return nullopt with an exception pending, and the comparison must not continue in that state. --- src/jsc/bindings/bindings.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index c70b969ac06d..68c148554f82 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -734,6 +734,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, RETURN_IF_EXCEPTION(scope, false); if (isSpecialEqual.has_value()) return WTF::move(*isSpecialEqual); isSpecialEqual = specialObjectsDequal(globalObject, gcBuffer, stack, scope, c2, c1); + RETURN_IF_EXCEPTION(scope, false); if (isSpecialEqual.has_value()) return WTF::move(*isSpecialEqual); JSObject* o1 = v1.getObject(); JSObject* o2 = v2.getObject();