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
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