Skip to content
Draft
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
107 changes: 107 additions & 0 deletions bench/snippets/deep-equals.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fastDeepEquals from "fast-deep-equal/es6/index";
import vm from "node:vm";
import { bench, group, run } from "../runner.mjs";
// const Date = globalThis.Date;

Expand Down Expand Up @@ -502,4 +503,110 @@ for (let { tests, description } of fixture) {
}
}

// Bun.deepEquals(a, b, true) is what expect().toStrictEqual() runs.
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
class Tags extends Set {}
// A Date of @sinonjs/fake-timers owns its constructor.
class ClockDate extends Date {
constructor(time) {
super(time);
Object.defineProperty(this, "constructor", { value: Date, enumerable: false });
}
}
const record = () => ({ id: 1, name: "n", email: "e", createdAt: 1, updatedAt: 2, role: "r", active: true, tags: 0 });
const manyKinds = () => [new Date(5), new Map([[1, 2]]), /a/, new Uint8Array(1), new Error("x"), { a: 1 }, [1]];
const otherRealm = vm.createContext({});

const strictFixture = [
{ description: "equal objects", value1: { a: 1, b: "2" }, value2: { a: 1, b: "2" }, equal: true },
{
description: 'equal objects (different properties "order")',
value1: { a: 1, b: "2" },
value2: { b: "2", a: 1 },
equal: true,
},
{
description: "object literal and JSON.parse()",
value1: { a: 1, b: "2", c: 3 },
value2: JSON.parse('{"a":1,"b":"2","c":3}'),
equal: true,
},
{ description: "equal objects (8 keys)", value1: record(), value2: record(), equal: true },
{
description: "object literal and JSON.parse() (8 keys)",
value1: record(),
value2: JSON.parse(JSON.stringify(record())),
equal: true,
},
{ description: "equal arrays", value1: [1, 2, 3], value2: [1, 2, 3], equal: true },
{
description: "array literal and computed array",
value1: [1, 2, 3],
value2: Array.from({ length: 3 }, (_, i) => i + 1),
equal: true,
},
{
description: "equal arrays of objects",
value1: Array.from({ length: 1000 }, (_, i) => ({ id: i, name: "n" })),
value2: Array.from({ length: 1000 }, (_, i) => ({ id: i, name: "n" })),
equal: true,
},
{ description: "equal class instances", value1: new Point(1, 2), value2: new Point(1, 2), equal: true },
{ description: "class instance and object literal", value1: new Point(1, 2), value2: { x: 1, y: 2 }, equal: false },
{ description: "equal date objects", value1: new Date(5), value2: new Date(5), equal: true },
{ description: "equal RegExp objects", value1: /foo/, value2: /foo/, equal: true },
{ description: "equal maps", value1: new Map([[1, 2]]), value2: new Map([[1, 2]]), equal: true },
{ description: "equal sets", value1: new Set([1]), value2: new Set([1]), equal: true },
{ description: "equal typed arrays", value1: new Uint8Array(8), value2: new Uint8Array(8), equal: true },
{ description: "equal errors", value1: new Error("x"), value2: new Error("x"), equal: true },
{ description: "subclass of Set and Set", value1: new Tags([1]), value2: new Set([1]), equal: false },
{ description: "map and set", value1: new Map(), value2: new Set(), equal: false },
{ description: "equal sets of many kinds", value1: new Set(manyKinds()), value2: new Set(manyKinds()), equal: true },
{
description: "equal maps with keys of many kinds",
value1: new Map(manyKinds().map(key => [key, 1])),
value2: new Map(manyKinds().map(key => [key, 1])),
equal: true,
},
{
description: "equal dates that own their constructor",
value1: new ClockDate(5),
value2: new ClockDate(5),
equal: true,
},
{
description: "typed array of another realm",
value1: vm.runInContext("new Uint8Array(8)", otherRealm),
value2: new Uint8Array(8),
equal: true,
},
{
description: "map of another realm",
value1: vm.runInContext("new Map([[1, 2]])", otherRealm),
value2: new Map([[1, 2]]),
equal: true,
},
{
description: "object of another realm",
value1: vm.runInContext('({ a: 1, b: "2" })', otherRealm),
value2: { a: 1, b: "2" },
equal: true,
},
];

group("strict", () => {
for (const { description, value1, value2, equal } of strictFixture) {
bench(`strict ${description}`, () => {
if (Bun.deepEquals(value1, value2, true) !== equal) {
throw new Error(`Expected ${equal} for strict ${description}`);
}
});
}
});

await run();
8 changes: 8 additions & 0 deletions docs/guides/util/deep-equals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,16 @@ class Foo {
a = 1;
}
Bun.deepEquals(new Foo(), { a: 1 }, true); // false

// objects of different classes
class Tags extends Set {}
Bun.deepEquals(new Tags(["a"]), new Set(["a"]), true); // false
Bun.deepEquals(Buffer.from("a"), new Uint8Array([97]), true); // false
Bun.deepEquals(Object.create(null), {}, true); // false
```

Two objects have the same class when they have the same `constructor`. Two arrays always have the same class. So do two values of one built-in class from two realms, such as a `Map` from a `node:vm` context and a `Map` of the main realm.

---

See [Utils](/runtime/utils).
4 changes: 2 additions & 2 deletions docs/runtime/redis.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ await redis.set("user:1:name", "Alice");
// Get a key
const name = await redis.get("user:1:name");

// Get a key as Uint8Array
// Get a key as a Buffer
const buffer = await redis.getBuffer("user:1:name");

// Delete a key
Expand Down Expand Up @@ -345,7 +345,7 @@ The client automatically converts Redis responses to JavaScript values:
- Big number responses (RESP3) are returned as `BigInt`. A payload that is not an integer literal is returned as a string. `getBuffer` returns the payload as a `Buffer`.
- Error responses throw JavaScript errors with appropriate error codes
- Boolean responses (RESP3) are returned as JavaScript booleans
- Map responses (RESP3) are returned as JavaScript objects
- Map responses (RESP3) are returned as JavaScript objects with a null prototype
- Set responses (RESP3) are returned as JavaScript arrays

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

// objects of different classes
class Tags extends Set {}
Bun.deepEquals(new Tags(["a"]), new Set(["a"]), true); // false
Bun.deepEquals(Buffer.from("a"), new Uint8Array([97]), true); // false
Bun.deepEquals(Object.create(null), {}, true); // false
```

Two objects have the same class when they have the same `constructor`. Two arrays always have the same class. So do two values of one built-in class from two realms, such as a `Map` from a `node:vm` context and a `Map` of the main realm.

## `Bun.escapeHTML()`

`Bun.escapeHTML(value: string | object | number | boolean): string`
Expand Down
9 changes: 5 additions & 4 deletions packages/bun-types/redis.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,11 @@ declare module "bun" {
get(key: RedisClient.KeyLike): Promise<string | null>;

/**
* Get the value of a key as a Uint8Array
* Get the value of a key as a Buffer
* @param key The key to get
* @returns Promise that resolves with the key's value as a Uint8Array, or null if the key doesn't exist
* @returns Promise that resolves with the key's value as a Buffer, or null if the key doesn't exist
*/
getBuffer(key: RedisClient.KeyLike): Promise<Uint8Array<ArrayBuffer> | null>;
getBuffer(key: RedisClient.KeyLike): Promise<Buffer<ArrayBuffer> | null>;

/**
* Set key to hold the string value
Expand Down Expand Up @@ -893,7 +893,8 @@ declare module "bun" {
/**
* Get all the fields and values in a hash
* @param key The hash key
* @returns Promise that resolves with an object containing all fields and values, or empty object if key does not exist
* @returns Promise that resolves with an object containing all fields and values, or empty object if key does not exist.
* The object has a null prototype.
*/
hgetall(key: RedisClient.KeyLike): Promise<Record<string, string>>;

Expand Down
11 changes: 11 additions & 0 deletions packages/bun-types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2077,6 +2077,11 @@ declare module "bun:test" {
* 1. It checks that the class is the same.
* 2. It checks that `undefined` values match as well.
*
* Two objects have the same class when they have the same `constructor`.
* Two arrays always have the same class. So does a built-in such as `Map`
* or `Uint8Array` from another realm, for example a `node:vm` context or a
* jsdom window.
*
* @example
* class Dog {
* type = "dog";
Expand All @@ -2086,6 +2091,12 @@ declare module "bun:test" {
* expect(actual).toStrictEqual({ type: "dog" }); // fail
*
* @example
* class Tags extends Set {}
* expect(new Tags(["a"])).toStrictEqual(new Set(["a"])); // fail
* expect(Buffer.from("a")).toStrictEqual(new Uint8Array([97])); // fail
* expect(Object.create(null)).toStrictEqual({}); // fail
*
* @example
* const actual = { value: 1, name: undefined };
* expect(actual).toEqual({ value: 1 });
* expect(actual).toStrictEqual({ value: 1 }); // fail
Expand Down
2 changes: 1 addition & 1 deletion src/js/node/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ function isFunction(value) {
return typeof value === "function";
}

// Node semantics (includes the [[Prototype]] identity check Bun.deepEquals omits) plus the
// Node semantics (the [[Prototype]] identity check, where strict Bun.deepEquals has Jest's constructor check) plus the
// skipPrototype third argument, which is public API in node v26.3.0 (fn.length === 3).
// https://github.com/nodejs/node/blob/main/lib/internal/util/comparisons.js
const { isDeepStrictEqual } = require("internal/util/comparisons");
Expand Down
109 changes: 109 additions & 0 deletions src/jsc/bindings/BunStrictClassEquality.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#include "root.h"

#include "BunStrictClassEquality.h"

#include <JavaScriptCore/AOTPropertyWalk.h>
#include <JavaScriptCore/InternalFunction.h>
#include <JavaScriptCore/JSCInlines.h>
#include <JavaScriptCore/JSFunction.h>
#include <JavaScriptCore/PropertySlot.h>

namespace Bun {
namespace StrictClassEquality {

// A function equals itself alone, a Proxy answers with its traps, and a global object has a comparison of its own.
static bool isTypedByItsConstructor(JSC::JSObject* object)
{
JSC::JSType type = object->type();
return !JSC::isFunctionType(type) && type != JSC::ProxyObjectType && type != JSC::GlobalProxyType && type != JSC::GlobalObjectType;
}

struct Constructor {
// Empty where only script can answer.
JSC::JSValue value;
bool isOwn { false };
};

static Constructor constructorWithoutScript(JSC::JSGlobalObject* globalObject, JSC::ThrowScope& scope, JSC::JSObject* object)
{
JSC::VM& vm = globalObject->vm();
const JSC::Identifier& name = vm.propertyNames->constructor;
JSC::PropertySlot slot(object, JSC::PropertySlot::InternalMethodType::VMInquiry, &vm);
// The object is asked by itself: one of compiled code keeps some own properties in a second object, which is then the slotBase().
bool isOwn = object->methodTable()->getOwnPropertySlot(object, globalObject, name, slot);
RETURN_IF_EXCEPTION(scope, {});
bool found = isOwn;
if (!found && !slot.isTaintedByOpaqueObject()) {
JSC::JSValue prototype = object->getPrototypeDirect();
found = prototype.isObject() && asObject(prototype)->getNonIndexPropertySlot(globalObject, name, slot);
RETURN_IF_EXCEPTION(scope, {});
}
if (slot.isTaintedByOpaqueObject() || slot.isAccessor())
return {};
if (!found)
return { JSC::jsUndefined(), false };
JSC::JSValue value = slot.getValue(globalObject, name);
RETURN_IF_EXCEPTION(scope, {});
return { value, isOwn };
}

// Jest takes a function that prints as native code for a built-in. Here that is a host function or a builtin of the engine, not a class of a module in src/js.
static bool isBuiltinConstructor(JSC::JSCell* cell)
{
if (cell->type() == JSC::InternalFunctionType)
return true;
return cell->type() == JSC::JSFunctionType && uncheckedDowncast<JSC::JSFunction>(cell)->isHostOrPrivateBuiltinFunction();
}

static WTF::String builtinConstructorName(JSC::VM& vm, JSC::JSCell* cell)
{
if (cell->type() == JSC::InternalFunctionType)
return uncheckedDowncast<JSC::InternalFunction>(cell)->name();
return uncheckedDowncast<JSC::JSFunction>(cell)->name(vm);
}

// Map of a node:vm context and Map of the main realm. Jest compares the names alone, which also makes two mock functions of one realm one class.
static bool areSameBuiltinOfTwoRealms(JSC::VM& vm, JSC::JSValue constructor1, JSC::JSValue constructor2)
{
if (!constructor1.isCell() || !constructor2.isCell())
return false;
JSC::JSCell* cell1 = constructor1.asCell();
JSC::JSCell* cell2 = constructor2.asCell();
if (!isBuiltinConstructor(cell1) || !isBuiltinConstructor(cell2))
return false;
if (asObject(cell1)->realmMayBeNull() == asObject(cell2)->realmMayBeNull())
return false;
return builtinConstructorName(vm, cell1) == builtinConstructorName(vm, cell2);
}

} // namespace StrictClassEquality

bool haveSameClassForStrictEqualitySlow(JSC::JSGlobalObject* globalObject, JSC::ThrowScope& scope, JSC::JSObject* o1, JSC::JSObject* o2)
{
using namespace StrictClassEquality;
JSC::VM& vm = globalObject->vm();

if (!isTypedByItsConstructor(o1) || !isTypedByItsConstructor(o2))
return true;

Constructor constructor1 = constructorWithoutScript(globalObject, scope, o1);
RETURN_IF_EXCEPTION(scope, false);
Constructor constructor2 = constructorWithoutScript(globalObject, scope, o2);
RETURN_IF_EXCEPTION(scope, false);
if (!constructor1.value || !constructor2.value) {
// Only script can say. The class names decide here, because a Map or an Error never gets to the comparison of them that follows.
JSC::AOT::PropertyWalk::materializeLazyProperties(vm, o1);
JSC::AOT::PropertyWalk::materializeLazyProperties(vm, o2);
return equal(JSC::JSObject::calculatedClassName(o1), JSC::JSObject::calculatedClassName(o2));
}

bool haveOneConstructor = JSC::JSValue::strictEqual(globalObject, constructor1.value, constructor2.value);
RETURN_IF_EXCEPTION(scope, false);
if (haveOneConstructor || areSameBuiltinOfTwoRealms(vm, constructor1.value, constructor2.value))
return true;

// An own "constructor" that is no function is a key of the data, as in JSON.parse('{"constructor":{}}').
return constructor1.isOwn && constructor2.isOwn && !constructor1.value.isCallable() && !constructor2.value.isCallable() && o1->getPrototypeDirect() == o2->getPrototypeDirect();
}

} // namespace Bun
Loading
Loading