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
82 changes: 58 additions & 24 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ interface Options {

const richTypes = { Date: true, RegExp: true, String: true, Number: true };

const temporalTypes = {
Instant: true,
PlainDate: true,
PlainTime: true,
PlainDateTime: true,
ZonedDateTime: true,
Duration: true,
PlainYearMonth: true,
PlainMonthDay: true,
};

export default function diff(
obj: Record<string, any> | any[],
newObj: Record<string, any> | any[],
Expand All @@ -35,56 +46,79 @@ export default function diff(
const isObjArray = Array.isArray(obj);

for (const key in obj) {
const objKey = obj[key];
const value = obj[key];
const path = isObjArray ? +key : key;
if (!(key in newObj)) {
diffs.push({
type: "REMOVE",
path: [path],
oldValue: obj[key],
oldValue: value,
});
continue;
}
const newObjKey = newObj[key];
const newValue = newObj[key];
const areCompatibleObjects =
typeof objKey === "object" &&
typeof newObjKey === "object" &&
Array.isArray(objKey) === Array.isArray(newObjKey);
typeof value === "object" &&
typeof newValue === "object" &&
Array.isArray(value) === Array.isArray(newValue);

// Only compute for non-null objects — primitives and null skip this
// entirely since Object.getPrototypeOf is expensive to call on every key
const objConstructor =
areCompatibleObjects && value
? Object.getPrototypeOf(value)?.constructor?.name
: undefined;

if (
objKey &&
newObjKey &&
value &&
newValue &&
areCompatibleObjects &&
!richTypes[Object.getPrototypeOf(objKey)?.constructor?.name] &&
(!options.cyclesFix || !_stack.includes(objKey))
!richTypes[objConstructor] &&
!temporalTypes[objConstructor] &&
(!options.cyclesFix || !_stack.includes(value))
) {
// Recurse into objects and arrays
diffs.push.apply(
diffs,
diff(
objKey,
newObjKey,
value,
newValue,
options,
options.cyclesFix ? _stack.concat([objKey]) : [],
options.cyclesFix ? _stack.concat([value]) : [],
).map((difference) => {
difference.path.unshift(path);
return difference;
}),
);
} else if (
objKey !== newObjKey &&
} else if (value === newValue) {
// Non-object values that are strictly equal are not differences
continue;
} else if (Number.isNaN(value) && Number.isNaN(newValue)) {
// treat NaN values as equivalent
!(Number.isNaN(objKey) && Number.isNaN(newObjKey)) &&
!(
areCompatibleObjects &&
(isNaN(objKey)
? objKey + "" === newObjKey + ""
: +objKey === +newObjKey)
)
continue;
} else if (
// Temporal types are always objects, and always compared by their string representation
// This is different from the Rich objects which can coerce using valueOf or toString
// Temporal types purposefully throw on valueOf but all provide a reliable toString for comparison
areCompatibleObjects &&
temporalTypes[objConstructor] &&
String(value) === String(newValue)
) {
continue;
} else if (
// These are the Rich Types that can be compared by coercing to primitive values
// but only if they are the same type of object
areCompatibleObjects &&
richTypes[objConstructor] &&
(isNaN(value) ? value + "" === newValue + "" : +value === +newValue)
) {
continue;
} else {
diffs.push({
path: [path],
type: "CHANGE",
value: newObjKey,
oldValue: objKey,
value: newValue,
oldValue: value,
});
}
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
},
"scripts": {
"build": "tsc --module CommonJS && shx mv \"dist/index.js\" \"dist/index.cjs\" && shx mv \"dist/index.d.ts\" \"dist/index.d.cts\" && tsc --module es2020 && prettier -w dist/*",
"test": "npm run build && node --test ./tests/*",
"test": "npm run build && node --harmony-temporal --test ./tests/*",
"bench": "npm run build && node --expose-gc bench.js",
"prepublish": "npm run build"
},
Expand Down
11 changes: 11 additions & 0 deletions tests/basic.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ test("replace object with null", () => {
]);
});

test("replace null with object", () => {
assert.deepStrictEqual(diff({ object: null }, { object: { test: true } }), [
{
type: "CHANGE",
path: ["object"],
value: { test: true },
oldValue: null,
},
]);
});

test("replace object with other value", () => {
assert.deepStrictEqual(
diff({ object: { test: true } }, { object: "string" }),
Expand Down
204 changes: 204 additions & 0 deletions tests/temporal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import test from "node:test";
import assert from "node:assert";
import diff from "../dist/index.js";

test("Handles equal Temporal.PlainDate", () => {
assert.deepStrictEqual(
diff(
{ date: Temporal.PlainDate.from("2024-01-15") },
{ date: Temporal.PlainDate.from("2024-01-15") },
),
[],
);
});
test("Handles unequal Temporal.PlainDate", () => {
assert.deepStrictEqual(
diff(
{ date: Temporal.PlainDate.from("2024-01-15") },
{ date: Temporal.PlainDate.from("2024-06-01") },
),
[
{
path: ["date"],
type: "CHANGE",
value: Temporal.PlainDate.from("2024-06-01"),
oldValue: Temporal.PlainDate.from("2024-01-15"),
},
],
);
});
test("Handles equal Temporal.Instant", () => {
assert.deepStrictEqual(
diff(
{ ts: Temporal.Instant.from("2024-01-01T00:00:00Z") },
{ ts: Temporal.Instant.from("2024-01-01T00:00:00Z") },
),
[],
);
});
test("Handles unequal Temporal.Instant", () => {
assert.deepStrictEqual(
diff(
{ ts: Temporal.Instant.from("2024-01-01T00:00:00Z") },
{ ts: Temporal.Instant.from("2024-06-01T12:00:00Z") },
),
[
{
path: ["ts"],
type: "CHANGE",
value: Temporal.Instant.from("2024-06-01T12:00:00Z"),
oldValue: Temporal.Instant.from("2024-01-01T00:00:00Z"),
},
],
);
});
test("Handles equal Temporal.PlainDateTime", () => {
assert.deepStrictEqual(
diff(
{ dt: Temporal.PlainDateTime.from("2024-01-15T10:30:00") },
{ dt: Temporal.PlainDateTime.from("2024-01-15T10:30:00") },
),
[],
);
});
test("Handles unequal Temporal.PlainDateTime", () => {
assert.deepStrictEqual(
diff(
{ dt: Temporal.PlainDateTime.from("2024-01-15T10:30:00") },
{ dt: Temporal.PlainDateTime.from("2024-01-15T11:00:00") },
),
[
{
path: ["dt"],
type: "CHANGE",
value: Temporal.PlainDateTime.from("2024-01-15T11:00:00"),
oldValue: Temporal.PlainDateTime.from("2024-01-15T10:30:00"),
},
],
);
});
test("Handles equal Temporal.ZonedDateTime", () => {
assert.deepStrictEqual(
diff(
{ zdt: Temporal.ZonedDateTime.from("2024-01-15T10:30:00[UTC]") },
{ zdt: Temporal.ZonedDateTime.from("2024-01-15T10:30:00[UTC]") },
),
[],
);
});
test("Handles equal Temporal.PlainTime", () => {
assert.deepStrictEqual(
diff(
{ time: Temporal.PlainTime.from("10:30:00") },
{ time: Temporal.PlainTime.from("10:30:00") },
),
[],
);
});
test("Handles equal Temporal.Duration", () => {
assert.deepStrictEqual(
diff(
{ dur: Temporal.Duration.from({ hours: 1, minutes: 30 }) },
{ dur: Temporal.Duration.from({ hours: 1, minutes: 30 }) },
),
[],
);
});
test("Handles unequal Temporal.Duration", () => {
assert.deepStrictEqual(
diff(
{ dur: Temporal.Duration.from({ hours: 1 }) },
{ dur: Temporal.Duration.from({ hours: 2 }) },
),
[
{
path: ["dur"],
type: "CHANGE",
value: Temporal.Duration.from({ hours: 2 }),
oldValue: Temporal.Duration.from({ hours: 1 }),
},
],
);
});
test("Handles Temporal value replaced with non-Temporal", () => {
assert.deepStrictEqual(
diff(
{ date: Temporal.PlainDate.from("2024-01-15") },
{ date: "2024-01-15" },
),
[
{
path: ["date"],
type: "CHANGE",
value: "2024-01-15",
oldValue: Temporal.PlainDate.from("2024-01-15"),
},
],
);
});
test("Handles equal Temporal.PlainYearMonth", () => {
assert.deepStrictEqual(
diff(
{ ym: Temporal.PlainYearMonth.from("2024-01") },
{ ym: Temporal.PlainYearMonth.from("2024-01") },
),
[],
);
});
test("Handles unequal Temporal.PlainYearMonth", () => {
assert.deepStrictEqual(
diff(
{ ym: Temporal.PlainYearMonth.from("2024-01") },
{ ym: Temporal.PlainYearMonth.from("2024-06") },
),
[
{
path: ["ym"],
type: "CHANGE",
value: Temporal.PlainYearMonth.from("2024-06"),
oldValue: Temporal.PlainYearMonth.from("2024-01"),
},
],
);
});
test("Handles equal Temporal.PlainMonthDay", () => {
assert.deepStrictEqual(
diff(
{ md: Temporal.PlainMonthDay.from("01-15") },
{ md: Temporal.PlainMonthDay.from("01-15") },
),
[],
);
});
test("Handles unequal Temporal.PlainMonthDay", () => {
assert.deepStrictEqual(
diff(
{ md: Temporal.PlainMonthDay.from("01-15") },
{ md: Temporal.PlainMonthDay.from("06-01") },
),
[
{
path: ["md"],
type: "CHANGE",
value: Temporal.PlainMonthDay.from("06-01"),
oldValue: Temporal.PlainMonthDay.from("01-15"),
},
],
);
});
test("Handles non-Temporal value replaced with Temporal", () => {
assert.deepStrictEqual(
diff(
{ date: "2024-01-15" },
{ date: Temporal.PlainDate.from("2024-01-15") },
),
[
{
path: ["date"],
type: "CHANGE",
value: Temporal.PlainDate.from("2024-01-15"),
oldValue: "2024-01-15",
},
],
);
});