Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/guides/util/deep-equals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ class Foo {
a = 1;
}
Bun.deepEquals(new Foo(), { a: 1 }, true); // false

// object literals vs objects with a null prototype
Bun.deepEquals({ __proto__: null, a: 1 }, { a: 1 }, true); // false
```

---
Expand Down
3 changes: 3 additions & 0 deletions docs/runtime/utils.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,9 @@ class Foo {
a = 1;
}
Bun.deepEquals(new Foo(), { a: 1 }, true); // false

// object literals vs objects with a null prototype
Bun.deepEquals({ __proto__: null, a: 1 }, { a: 1 }, true); // false
```

## `Bun.escapeHTML()`
Expand Down
16 changes: 12 additions & 4 deletions src/jsc/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -814,9 +814,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2,
ASSERT(c1);
ASSERT(c2);

// Node's deepStrictEqual compares [[Prototype]]s with ===. Only the
// node:assert/node:util entry point does this; Bun.deepEquals and
// expect() keep their prototype-blind semantics.
// Node's deepStrictEqual compares [[Prototype]]s with ===; Bun.deepEquals and expect() only compare class names (below).
if constexpr (checkPrototypes && !skipPrototypeIdentity) {
JSObject* protoCheck1 = v1.getObject();
JSObject* protoCheck2 = v2.getObject();
Expand Down Expand Up @@ -957,6 +955,16 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2,
if (!equal(JSObject::calculatedClassName(o1), JSObject::calculatedClassName(o2))) {
return false;
}
// calculatedClassName() is "Object" for null-prototype objects too (node mode compared the prototypes above).
if constexpr (!checkPrototypes) {
JSValue proto1 = o1->getPrototype(globalObject);
RETURN_IF_EXCEPTION(scope, false);
JSValue proto2 = o2->getPrototype(globalObject);
RETURN_IF_EXCEPTION(scope, false);
if (proto1.isNull() != proto2.isNull()) {
return false;
}
}
}

JSC::Structure* o1Structure = o1->structure();
Expand Down Expand Up @@ -3096,7 +3104,7 @@ bool JSC__JSValue__jestStrictDeepEquals(JSC::EncodedJSValue JSValue0, JSC::Encod
}

// node:assert deepStrictEqual / node:util.isDeepStrictEqual: strict deepEquals
// plus node's [[Prototype]] identity rule (Bun.deepEquals stays prototype-blind).
// plus node's [[Prototype]] identity rule (Bun.deepEquals compares class names, not prototype identity).
bool Bun__deepEqualsNodeStrict(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* globalObject)
{
return deepEqualsWrapperImpl<true, false, true>(JSValue0, JSValue1, globalObject);
Expand Down
49 changes: 46 additions & 3 deletions test/js/bun/bun-object/deep-equals.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { bunEnv, bunExe, isASAN, isWindows } from "harness";
import util from "node:util";
import vm from "node:vm";

describe.each([true, false])("Bun.deepEquals(a, b, strict: %p)", strict => {
Expand Down Expand Up @@ -124,10 +125,52 @@ describe("Bun.deepEquals strict mode", () => {
expect(Bun.deepEquals({ a: { b: 1 } }, { a: { b: 1, c: undefined } }, true)).toBe(false);
});

// Matches Node's util.isDeepStrictEqual, which rejects a null prototype
// against Object.prototype.
it.failing("distinguishes a null-prototype object from an object literal", () => {
it("distinguishes a null-prototype object from an object literal", () => {
expect(Bun.deepEquals(Object.create(null), {})).toBe(true);
expect(Bun.deepEquals(Object.create(null), {}, true)).toBe(false);
expect(Bun.deepEquals({}, Object.create(null), true)).toBe(false);

const nullProto = Object.assign(Object.create(null), { a: 1 });
expect(Bun.deepEquals(nullProto, { a: 1 })).toBe(true);
expect(Bun.deepEquals(nullProto, { a: 1 }, true)).toBe(false);
expect(Bun.deepEquals({ a: 1 }, nullProto, true)).toBe(false);
expect(Bun.deepEquals({ x: nullProto }, { x: { a: 1 } }, true)).toBe(false);
expect(Bun.deepEquals([nullProto], [{ a: 1 }], true)).toBe(false);
});

it("treats two null-prototype objects like any other objects", () => {
expect(Bun.deepEquals({ __proto__: null, a: 1 }, Object.assign(Object.create(null), { a: 1 }), true)).toBe(true);
expect(Bun.deepEquals({ __proto__: null, a: 1 }, { __proto__: null, a: 2 }, true)).toBe(false);
expect(Bun.deepEquals({ __proto__: null, a: 1 }, { __proto__: null, a: 1, b: undefined }, true)).toBe(false);
expect(Bun.deepEquals({ __proto__: null, a: 1 }, { __proto__: null, a: 1, b: undefined })).toBe(true);
});

// node's skipPrototype mode shares this implementation, with every prototype check disabled.
it("does not apply the null-prototype rule in skipPrototype mode", () => {
const nullProto = Object.assign(Object.create(null), { a: 1 });
expect(util.isDeepStrictEqual(nullProto, { a: 1 })).toBe(false);
expect(util.isDeepStrictEqual(nullProto, { a: 1 }, true)).toBe(true);
expect(util.isDeepStrictEqual({ a: 1 }, nullProto, true)).toBe(true);
});

it("sees through a Proxy to a null-prototype target", () => {
const plain = new Proxy({}, {});
const nullProto = new Proxy(Object.create(null), {});
expect(Bun.deepEquals(nullProto, new Proxy(Object.create(null), {}), true)).toBe(true);
expect(Bun.deepEquals(nullProto, plain, true)).toBe(false);
expect(Bun.deepEquals(plain, nullProto, true)).toBe(false);
});

it("uses the prototype a Proxy's getPrototypeOf trap reports, not the target's", () => {
const plain = new Proxy({}, {});
const nullProto = new Proxy(Object.create(null), {});
// Both targets are extensible, so the traps may legally contradict them.
const reportsNull = new Proxy({}, { getPrototypeOf: () => null });
expect(Bun.deepEquals(reportsNull, plain, true)).toBe(false);
expect(Bun.deepEquals(reportsNull, nullProto, true)).toBe(true);
const reportsObject = new Proxy(Object.create(null), { getPrototypeOf: () => Object.prototype });
expect(Bun.deepEquals(reportsObject, plain, true)).toBe(true);
expect(Bun.deepEquals(reportsObject, nullProto, true)).toBe(false);
});
});

Expand Down
32 changes: 32 additions & 0 deletions test/js/bun/test/expect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,38 @@ describe("expect()", () => {
expect([, 1]).toEqual([undefined, 1]);
});

test("toStrictEqual() distinguishes null-prototype objects from object literals", () => {
const nullProto = () => ANY(Object.assign(Object.create(null), { a: 1 }));

expect(nullProto()).toEqual({ a: 1 });
expect({ a: 1 }).toEqual(nullProto());
expect(nullProto()).not.toStrictEqual({ a: 1 });
expect({ a: 1 }).not.toStrictEqual(nullProto());
expect(ANY(Object.create(null))).not.toStrictEqual({});
expect({}).not.toStrictEqual(ANY(Object.create(null)));

// Nested in an object property and in an array element.
expect({ x: nullProto() }).toEqual({ x: { a: 1 } });
expect({ x: nullProto() }).not.toStrictEqual({ x: { a: 1 } });
expect({ x: { a: 1 } }).not.toStrictEqual({ x: nullProto() });
expect([nullProto()]).toEqual([{ a: 1 }]);
expect([nullProto()]).not.toStrictEqual([{ a: 1 }]);
expect([{ a: 1 }]).not.toStrictEqual([nullProto()]);

// Two null-prototype objects compare like any other pair of objects.
expect(nullProto()).toStrictEqual(nullProto());
expect({ x: nullProto() }).toStrictEqual({ x: nullProto() });
expect([nullProto()]).toStrictEqual([nullProto()]);
expect(nullProto()).not.toStrictEqual(ANY(Object.assign(Object.create(null), { a: 2 })));
expect(nullProto()).not.toStrictEqual(ANY(Object.assign(Object.create(null), { a: 1, b: undefined })));
expect(nullProto()).toEqual(ANY(Object.assign(Object.create(null), { a: 1, b: undefined })));

const grouped = Object.groupBy([1, 2, 3], n => (n % 2 ? "odd" : "even"));
expect(grouped).toEqual({ odd: [1, 3], even: [2] });
expect(grouped).not.toStrictEqual({ odd: [1, 3], even: [2] });
expect(grouped).toStrictEqual(Object.groupBy([1, 2, 3], n => (n % 2 ? "odd" : "even")));
});

describe("toEqual() with DOM types", () => {
test("URLSearchParams", () => {
expect(new URLSearchParams("a=1")).not.toEqual(new URLSearchParams("b=1"));
Expand Down