diff --git a/index.ts b/index.ts index c2383af..aa84876 100644 --- a/index.ts +++ b/index.ts @@ -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 | any[], newObj: Record | any[], @@ -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, }); } } diff --git a/package.json b/package.json index f001d1e..a2ae286 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/tests/basic.js b/tests/basic.js index b3ee9e8..abffb52 100644 --- a/tests/basic.js +++ b/tests/basic.js @@ -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" }), diff --git a/tests/temporal.js b/tests/temporal.js new file mode 100644 index 0000000..9c41ed4 --- /dev/null +++ b/tests/temporal.js @@ -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", + }, + ], + ); +});