diff --git a/force-app/lwc/signals/core.js b/force-app/lwc/signals/core.js index 277c006..6d4fbee 100644 --- a/force-app/lwc/signals/core.js +++ b/force-app/lwc/signals/core.js @@ -1,5 +1,6 @@ import { useInMemoryStorage } from "./use"; -import { debounce, deepEqual } from "./utils"; +import { debounce } from "./utils/debounce"; +import { isEqual } from "./utils/isEqual"; import { ObservableMembrane } from "./observable-membrane/observable-membrane"; const context = []; function _getCurrentObserver() { @@ -129,7 +130,7 @@ function $signal(value, options) { return _storageOption.get(); } function setter(newValue) { - if (deepEqual(newValue, _storageOption.get())) { + if (isEqual(newValue, _storageOption.get())) { return; } trackableState.set(newValue); @@ -191,7 +192,7 @@ function $resource(fn, source, options) { let data = null; if (_fetchWhen()) { const derivedSource = derivedSourceFn(); - if (!_isInitialLoad && deepEqual(derivedSource, _previousParams)) { + if (!_isInitialLoad && isEqual(derivedSource, _previousParams)) { // No need to fetch the data again if the params haven't changed return; } diff --git a/force-app/lwc/signals/utils.js b/force-app/lwc/signals/utils.js deleted file mode 100644 index 4f6adfe..0000000 --- a/force-app/lwc/signals/utils.js +++ /dev/null @@ -1,18 +0,0 @@ -export function debounce(func, delay) { - let debounceTimer = null; - return (...args) => { - if (debounceTimer) { - clearTimeout(debounceTimer); - } - debounceTimer = window.setTimeout(() => func(...args), delay); - }; -} -export function deepEqual(x, y) { - const objectKeysFn = Object.keys, - typeOfX = typeof x, - typeOfY = typeof y; - return x && y && typeOfX === "object" && typeOfX === typeOfY - ? objectKeysFn(x).length === objectKeysFn(y).length && - objectKeysFn(x).every((key) => deepEqual(x[key], y[key])) - : x === y; -} diff --git a/force-app/lwc/signals/utils/debounce.js b/force-app/lwc/signals/utils/debounce.js new file mode 100644 index 0000000..3e7348f --- /dev/null +++ b/force-app/lwc/signals/utils/debounce.js @@ -0,0 +1,9 @@ +export function debounce(func, delay) { + let debounceTimer = null; + return (...args) => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + debounceTimer = window.setTimeout(() => func(...args), delay); + }; +} diff --git a/force-app/lwc/signals/utils/isEqual.js b/force-app/lwc/signals/utils/isEqual.js new file mode 100644 index 0000000..fbe6f85 --- /dev/null +++ b/force-app/lwc/signals/utils/isEqual.js @@ -0,0 +1,48 @@ +function isPlainObject(value) { + return value?.constructor === Object; +} +export function isEqual(a, b) { + if (Object.is(a, b)) return true; + if (typeof a !== typeof b) return false; + if (Array.isArray(a) && Array.isArray(b)) return isSameArray(a, b); + if (a instanceof Date && b instanceof Date) + return a.getTime() === b.getTime(); + if (a instanceof RegExp && b instanceof RegExp) + return a.toString() === b.toString(); + if (isPlainObject(a) && isPlainObject(b)) return isSameObject(a, b); + if (a instanceof ArrayBuffer && b instanceof ArrayBuffer) + return dataViewsAreEqual(new DataView(a), new DataView(b)); + if (a instanceof DataView && b instanceof DataView) + return dataViewsAreEqual(a, b); + if (isTypedArray(a) && isTypedArray(b)) { + if (a.byteLength !== b.byteLength) return false; + return isSameArray(a, b); + } + return false; +} +function isSameObject(a, b) { + // check if the objects have the same keys + const keys1 = Object.keys(a); + const keys2 = Object.keys(b); + if (!isEqual(keys1, keys2)) return false; + // check if the values of each key in the objects are equal + for (const key of keys1) { + if (!isEqual(a[key], b[key])) return false; + } + // the objects are deeply equal + return true; +} +function isSameArray(a, b) { + if (a.length !== b.length) return false; + return a.every((element, index) => isEqual(element, b[index])); +} +function dataViewsAreEqual(a, b) { + if (a.byteLength !== b.byteLength) return false; + for (let offset = 0; offset < a.byteLength; offset++) { + if (a.getUint8(offset) !== b.getUint8(offset)) return false; + } + return true; +} +function isTypedArray(value) { + return ArrayBuffer.isView(value) && !(value instanceof DataView); +} diff --git a/src/lwc/signals/__tests__/isEqual.test.ts b/src/lwc/signals/__tests__/isEqual.test.ts new file mode 100644 index 0000000..2adc874 --- /dev/null +++ b/src/lwc/signals/__tests__/isEqual.test.ts @@ -0,0 +1,141 @@ +import { isEqual } from "../utils/isEqual"; + +describe("isEqual", () => { + test("correctly compares against undefined", () => { + expect(isEqual(undefined, undefined)).toBe(true); + expect(isEqual(undefined, null)).toBe(false); + expect(isEqual(undefined, 1)).toBe(false); + expect(isEqual(undefined, "foo")).toBe(false); + expect(isEqual(undefined, {})).toBe(false); + expect(isEqual(undefined, [])).toBe(false); + }); + + test("correct compares against nulls", () => { + expect(isEqual(null, null)).toBe(true); + expect(isEqual(null, 1)).toBe(false); + expect(isEqual(null, "foo")).toBe(false); + expect(isEqual(null, {})).toBe(false); + expect(isEqual(null, [])).toBe(false); + }); + + test("compares simple values", () => { + expect(isEqual(1, 1)).toBe(true); + expect(isEqual(1, 2)).toBe(false); + expect(isEqual("foo", "foo")).toBe(true); + expect(isEqual("foo", "bar")).toBe(false); + }); + + test("compares objects", () => { + expect(isEqual({}, {})).toBe(true); + expect(isEqual({ foo: "bar" }, { foo: "bar" })).toBe(true); + expect(isEqual({ foo: "bar" }, { foo: "baz" })).toBe(false); + expect(isEqual({ foo: "bar" }, { bar: "baz" })).toBe(false); + expect(isEqual({ foo: "bar" }, { foo: "bar", bar: "baz" })).toBe(false); + }); + + test("deep compares objects", () => { + expect(isEqual({ foo: { bar: "baz" } }, { foo: { bar: "baz" } })).toBe(true); + expect(isEqual({ foo: { bar: "baz" } }, { foo: { bar: "qux" } })).toBe(false); + expect(isEqual({ foo: { bar: "baz" } }, { foo: { baz: "qux" } })).toBe(false); + expect(isEqual({ foo: { bar: "baz" } }, { foo: { bar: "baz", baz: "qux" } })).toBe(false); + }); + + test("compares arrays", () => { + expect(isEqual([], [])).toBe(true); + expect(isEqual([1, 2, 3], [1, 2, 3])).toBe(true); + expect(isEqual([1, 2, 3], [1, 2, 4])).toBe(false); + expect(isEqual([1, 2, 3], [1, 2])).toBe(false); + expect(isEqual([1, 2, 3], [1, 2, 3, 4])).toBe(false); + }); + + test("compares maps", () => { + const map1 = new Map(); + map1.set("foo", "bar"); + const map2 = new Map(); + + expect(isEqual(map1, map2)).toBe(false); + }); + + test("compares dates", () => { + expect(isEqual(new Date(1), new Date(1))).toBe(true); + expect(isEqual(new Date(1), new Date(2))).toBe(false); + }); + + test("compares nested arrays", () => { + expect(isEqual([[1], [2], [3]], [[1], [2], [3]])).toBe(true); + expect(isEqual([[1], [2], [3]], [[1], [2], [4]])).toBe(false); + }); + + test("compares nested objects and arrays", () => { + expect(isEqual({ a: { b: [1] } }, { a: { b: [1] } })).toBe(true); + expect(isEqual({ a: { b: [1] } }, { a: { b: [2] } })).toBe(false); + }); + + const testFunction = () => { return 1 }; + test("functions", () => { + expect(isEqual(() => { return 1 }, () => { return 2 })).toBe(false); + expect(isEqual(testFunction, testFunction)).toBe(true); + }); + + test("objects with functions", () => { + expect(isEqual({ a: () => 1 }, { a: () => 1 })).toBe(false); + expect(isEqual({ a: testFunction }, { a: testFunction })).toBe(true); + }); + + test("regExp", () => { + expect(isEqual(/a(.*)/, /a(.*)/)).toBe(true); + expect(isEqual(/a/, /b.*/)).toBe(false); + }); + + test("deepEquals with Error objects", () => { + const error1 = new Error("test error"); + const error2 = new Error("test error"); + expect(isEqual(error1, error1)).toBe(true); + expect(isEqual(error1, error2)).toBe(false); + }); + + test("array buffers", () => { + const buffer1 = new ArrayBuffer(2); + const buffer1View = new Uint8Array(buffer1); + buffer1View.set([42, 43]); + + const buffer2 = new ArrayBuffer(2); + const buffer2View = new Uint8Array(buffer2); + buffer2View.set([42, 43]); + + const buffer3 = new ArrayBuffer(2); + const buffer3View = new Uint8Array(buffer3); + buffer3View.set([42, 44]); + + const buffer4 = new ArrayBuffer(3); + + expect(isEqual(buffer1, buffer2)).toBe(true); + expect(isEqual(buffer1, buffer3)).toBe(false); + expect(isEqual(buffer1, buffer4)).toBe(false); + }); + + test("typed arrays", () => { + expect(isEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(true); + expect(isEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2]))).toBe(false); + expect(isEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 4]))).toBe(false); + }); + + test("data views", () => { + const buffer1 = new ArrayBuffer(2); + const buffer2 = new ArrayBuffer(2); + const buffer3 = new ArrayBuffer(3); + + const view1 = new DataView(buffer1); + const view2 = new DataView(buffer2); + const view3 = new DataView(buffer3); + + view1.setUint8(0, 42); + view1.setUint8(1, 43); + + view2.setUint8(0, 42); + view2.setUint8(1, 43); + + expect(isEqual(view1, view2)).toBe(true); + expect(isEqual(view1, view3)).toBe(false); + }); +}); diff --git a/src/lwc/signals/__tests__/utils.test.ts b/src/lwc/signals/__tests__/utils.test.ts deleted file mode 100644 index 83d77d1..0000000 --- a/src/lwc/signals/__tests__/utils.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { deepEqual } from "../utils"; - -describe("deepEqual", () => { - test("correctly compares against undefined", () => { - expect(deepEqual(undefined, undefined)).toBe(true); - expect(deepEqual(undefined, null)).toBe(false); - expect(deepEqual(undefined, 1)).toBe(false); - expect(deepEqual(undefined, "foo")).toBe(false); - expect(deepEqual(undefined, {})).toBe(false); - expect(deepEqual(undefined, [])).toBe(false); - }); - - test("correct compares against nulls", () => { - expect(deepEqual(null, null)).toBe(true); - expect(deepEqual(null, 1)).toBe(false); - expect(deepEqual(null, "foo")).toBe(false); - expect(deepEqual(null, {})).toBe(false); - expect(deepEqual(null, [])).toBe(false); - }); - - test("compares simple values", () => { - expect(deepEqual(1, 1)).toBe(true); - expect(deepEqual(1, 2)).toBe(false); - expect(deepEqual("foo", "foo")).toBe(true); - expect(deepEqual("foo", "bar")).toBe(false); - }); - - test("compares objects", () => { - expect(deepEqual({}, {})).toBe(true); - expect(deepEqual({ foo: "bar" }, { foo: "bar" })).toBe(true); - expect(deepEqual({ foo: "bar" }, { foo: "baz" })).toBe(false); - expect(deepEqual({ foo: "bar" }, { bar: "baz" })).toBe(false); - expect(deepEqual({ foo: "bar" }, { foo: "bar", bar: "baz" })).toBe(false); - }); - - test("deep compares objects", () => { - expect(deepEqual({ foo: { bar: "baz" } }, { foo: { bar: "baz" } })).toBe(true); - expect(deepEqual({ foo: { bar: "baz" } }, { foo: { bar: "qux" } })).toBe(false); - expect(deepEqual({ foo: { bar: "baz" } }, { foo: { baz: "qux" } })).toBe(false); - expect(deepEqual({ foo: { bar: "baz" } }, { foo: { bar: "baz", baz: "qux" } })).toBe(false); - }); - - test("compares arrays", () => { - expect(deepEqual([], [])).toBe(true); - expect(deepEqual([1, 2, 3], [1, 2, 3])).toBe(true); - expect(deepEqual([1, 2, 3], [1, 2, 4])).toBe(false); - expect(deepEqual([1, 2, 3], [1, 2])).toBe(false); - expect(deepEqual([1, 2, 3], [1, 2, 3, 4])).toBe(false); - }); -}); diff --git a/src/lwc/signals/core.ts b/src/lwc/signals/core.ts index 9f363a5..6861975 100644 --- a/src/lwc/signals/core.ts +++ b/src/lwc/signals/core.ts @@ -1,5 +1,6 @@ import { useInMemoryStorage, State } from "./use"; -import { debounce, deepEqual } from "./utils"; +import { debounce } from "./utils/debounce"; +import { isEqual } from "./utils/isEqual"; import { ObservableMembrane } from "./observable-membrane/observable-membrane"; type ReadOnlySignal = { @@ -180,7 +181,7 @@ function $signal( } function setter(newValue: T) { - if (deepEqual(newValue, _storageOption.get())) { + if (isEqual(newValue, _storageOption.get())) { return; } trackableState.set(newValue); @@ -347,7 +348,7 @@ function $resource( let data: ReturnType | null = null; if (_fetchWhen()) { const derivedSource = derivedSourceFn(); - if (!_isInitialLoad && deepEqual(derivedSource,_previousParams)) { + if (!_isInitialLoad && isEqual(derivedSource, _previousParams)) { // No need to fetch the data again if the params haven't changed return; } diff --git a/src/lwc/signals/utils.ts b/src/lwc/signals/utils.ts deleted file mode 100644 index bd2e6e7..0000000 --- a/src/lwc/signals/utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -export function debounce unknown>( - func: F, - delay: number -): (...args: Parameters) => void { - let debounceTimer: number | null = null; - return (...args: Parameters) => { - if (debounceTimer) { - clearTimeout(debounceTimer); - } - debounceTimer = window.setTimeout(() => func(...args), delay); - }; -} - -export function deepEqual(x: unknown, y: unknown): boolean { - const objectKeysFn = Object.keys, - typeOfX = typeof x, - typeOfY = typeof y; - return x && y && typeOfX === "object" && typeOfX === typeOfY - ? objectKeysFn(x).length === objectKeysFn(y).length && - objectKeysFn(x).every((key) => deepEqual((x as Record)[key], (y as Record)[key])) - : x === y; -} diff --git a/src/lwc/signals/utils/debounce.ts b/src/lwc/signals/utils/debounce.ts new file mode 100644 index 0000000..52de631 --- /dev/null +++ b/src/lwc/signals/utils/debounce.ts @@ -0,0 +1,12 @@ +export function debounce unknown>( + func: F, + delay: number +): (...args: Parameters) => void { + let debounceTimer: number | null = null; + return (...args: Parameters) => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + debounceTimer = window.setTimeout(() => func(...args), delay); + }; +} diff --git a/src/lwc/signals/utils/isEqual.ts b/src/lwc/signals/utils/isEqual.ts new file mode 100644 index 0000000..4c96dc1 --- /dev/null +++ b/src/lwc/signals/utils/isEqual.ts @@ -0,0 +1,70 @@ +type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array; + +type PlainObject = Record; + +function isPlainObject(value: unknown): value is PlainObject { + return value?.constructor === Object; +} + +export function isEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + + if (typeof a !== typeof b) return false; + + if (Array.isArray(a) && Array.isArray(b)) + return isSameArray(a, b); + + if (a instanceof Date && b instanceof Date) + return a.getTime() === b.getTime(); + + if (a instanceof RegExp && b instanceof RegExp) + return a.toString() === b.toString(); + + if (isPlainObject(a) && isPlainObject(b)) + return isSameObject(a, b); + + if (a instanceof ArrayBuffer && b instanceof ArrayBuffer) + return dataViewsAreEqual(new DataView(a), new DataView(b)); + + if (a instanceof DataView && b instanceof DataView) + return dataViewsAreEqual(a, b); + + if (isTypedArray(a) && isTypedArray(b)) { + if (a.byteLength !== b.byteLength) return false; + return isSameArray(a, b); + } + + return false; +} + +function isSameObject(a: PlainObject, b: PlainObject) { + // check if the objects have the same keys + const keys1 = Object.keys(a); + const keys2 = Object.keys(b); + if (!isEqual(keys1, keys2)) return false; + + // check if the values of each key in the objects are equal + for (const key of keys1) { + if (!isEqual(a[key], b[key])) return false; + } + + // the objects are deeply equal + return true; +} + +function isSameArray(a: unknown[] | TypedArray, b: unknown[] | TypedArray) { + if (a.length !== b.length) return false; + return a.every((element, index) => isEqual(element, b[index])); +} + +function dataViewsAreEqual(a: DataView, b: DataView) { + if (a.byteLength !== b.byteLength) return false; + for (let offset = 0; offset < a.byteLength; offset++) { + if (a.getUint8(offset) !== b.getUint8(offset)) return false; + } + return true; +} + +function isTypedArray(value: unknown): value is TypedArray { + return ArrayBuffer.isView(value) && !(value instanceof DataView); +}