Skip to content
7 changes: 7 additions & 0 deletions src/backfills.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isObject } from './is-object.js';

const DATA_CONTEXT_PROP = 'dataContext';
let once = false;

Expand All @@ -6,6 +8,11 @@ let once = false;
* the `dataContext` prop returning the original data
*/
export function withDataContext(data: unknown) {
// Proxy throws on primitives.
if (!isObject(data)) {
return data;
}

return new Proxy(data as any, {
get(target, prop, receiver) {
if (prop === DATA_CONTEXT_PROP) {
Expand Down
162 changes: 93 additions & 69 deletions src/equal.ts
Original file line number Diff line number Diff line change
@@ -1,91 +1,115 @@
export function equal<T>(a: unknown, b: T, visited = new WeakSet()): boolean {
import { isObject } from './is-object.js';

type Visited = WeakMap<object, WeakSet<object>>;

export function equal<T>(a: unknown, b: T, visited?: Visited): boolean {
// Early return
if (Object.is(a, b)) return true;
if (Object.is(a, b)) {
return true;
}

if (isObject(a) && isObject(b)) {
if (a.constructor !== b.constructor) return false;
if (!isObject(a) || !isObject(b)) {
return false;
}

// Circular references
if (visited.has(a) && visited.has(b)) {
return true;
}
if (a.constructor !== b.constructor) {
return false;
}

visited.add(a);
visited.add(b);

// RegExp
if (isRegExp(a) && isRegExp(b)) return a.source === b.source && a.flags === b.flags;

// Maps
if (a instanceof Map && b instanceof Map) {
if (a.size !== b.size) return false;
for (const [keyA, valueA] of a.entries()) {
let found = false;
for (const [keyB, valueB] of b.entries()) {
if (equal(keyA, keyB, visited) && equal(valueA, valueB, visited)) {
found = true;
break;
}
}
if (!found) return false;
}
return true;
}
// Track pairs, not objects: failed Set/Map probes must not count as visited.
const pairs: Visited = visited ?? new WeakMap();
const pending = pairs.get(a) ?? new WeakSet<object>();

// Sets
if (a instanceof Set && b instanceof Set) {
if (a.size !== b.size) return false;
for (const valueA of a) {
let found = false;
for (const valueB of b) {
if (equal(valueA, valueB, visited)) {
found = true;
break;
}
}
if (!found) return false;
}
return true;
if (pending.has(b)) {
return true;
}

pairs.set(a, pending.add(b));

try {
return compare(a, b, pairs);
} finally {
pending.delete(b);
}
}

function compare(a: object, b: object, visited: Visited): boolean {
// RegExp
if (isRegExp(a) && isRegExp(b)) {
return a.source === b.source && a.flags === b.flags;
}

// Map entries iterate as [key, value] arrays.
if ((a instanceof Map && b instanceof Map) || (a instanceof Set && b instanceof Set)) {
return a.size === b.size && matchOneToOne(a, b, visited);
}

// Arrays
if (Array.isArray(a) && Array.isArray(b)) {
const length = a.length;

if (length !== b.length) {
return false;
}

// Arrays
if (Array.isArray(a) && Array.isArray(b)) {
const length = a.length;
if (length !== b.length) return false;
for (let i = 0; i < length; i++) {
if (!equal(a[i], b[i], visited)) return false;
for (let i = 0; i < length; i++) {
if (!equal(a[i], b[i], visited)) {
return false;
}
return true;
}
return true;
}

// toPrimitive
if (a.valueOf !== Object.prototype.valueOf) return a.valueOf() === b.valueOf();
// Strings based
if (a.toString !== Object.prototype.toString) return a.toString() === b.toString();
// toPrimitive; absent on null-prototype objects.
if (typeof a.valueOf === 'function' && a.valueOf !== Object.prototype.valueOf) {
return a.valueOf() === b.valueOf();
}

const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
// Strings based
if (typeof a.toString === 'function' && a.toString !== Object.prototype.toString) {
return a.toString() === b.toString();
}

for (const key of aKeys) {
if (!Object.hasOwn(b, key)) return false;
}
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);

for (const key of aKeys) {
if (!equal(a[key as keyof typeof a], b[key as keyof typeof b], visited)) return false;
}
if (aKeys.length !== bKeys.length) {
return false;
}

visited.delete(a);
visited.delete(b);
for (const key of aKeys) {
if (!Object.hasOwn(b, key)) {
return false;
}
}

return true;
for (const key of aKeys) {
if (!equal(a[key as keyof typeof a], b[key as keyof typeof b], visited)) {
return false;
}
}

return false;
return true;
}

function isObject(value: unknown): value is object {
return value != null && typeof value === 'object';
/** Greedy suffices: `equal` is transitive. */
function matchOneToOne(
left: Iterable<unknown>,
right: Iterable<unknown>,
visited: Visited,
): boolean {
const pool = [...right];

for (const a of left) {
const index = pool.findIndex((b) => equal(a, b, visited));

if (index < 0) {
return false;
}
pool.splice(index, 1);
}

return true;
}

function isRegExp(value: unknown): value is RegExp {
Expand Down
3 changes: 3 additions & 0 deletions src/is-object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object';
}
Loading
Loading