diff --git a/packages/core/src/bundle/helpers/deepEqual/deepEqual.js b/packages/core/src/bundle/helpers/deepEqual/deepEqual.js new file mode 100644 index 00000000..390887c8 --- /dev/null +++ b/packages/core/src/bundle/helpers/deepEqual/deepEqual.js @@ -0,0 +1,77 @@ +const isObject = (value) => typeof value === 'object' && value !== null; +const isPlainObject = (value) => { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; +/** + * @name deepEqual + * @description - Performs a deep comparison between two values to determine if they are equivalent + * @category Helpers + * @usage low + * + * @param {any} a The first value to compare + * @param {any} b The second value to compare + * @returns {boolean} `true` if the two values are deeply equal, `false` otherwise + * + * @warning - Set members and Map keys are compared by reference, Map values are compared deeply. Objects without own enumerable string keys are compared by reference unless they are plain objects. + * + * @example + * deepEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }); // true + */ +export const deepEqual = (a, b) => { + const seen = new WeakMap(); + const mark = (x, y) => { + const visited = seen.get(x); + if (visited) { + if (visited.has(y)) return true; + visited.add(y); + return false; + } + seen.set(x, new WeakSet([y])); + return false; + }; + const compare = (x, y) => { + if (Object.is(x, y)) return true; + if (!isObject(x) || !isObject(y)) return false; + if (Object.getPrototypeOf(x) !== Object.getPrototypeOf(y)) return false; + if (mark(x, y)) return true; + if (x instanceof Date) return y instanceof Date && Object.is(x.getTime(), y.getTime()); + if (x instanceof RegExp) + return y instanceof RegExp && x.source === y.source && x.flags === y.flags; + if (x instanceof Error) + return y instanceof Error && x.name === y.name && x.message === y.message; + if (Array.isArray(x)) { + if (!Array.isArray(y) || x.length !== y.length) return false; + for (let index = 0; index < x.length; index += 1) { + if (!compare(x[index], y[index])) return false; + } + return true; + } + if (x instanceof Set) { + if (!(y instanceof Set) || x.size !== y.size) return false; + for (const value of x) { + if (!y.has(value)) return false; + } + return true; + } + if (x instanceof Map) { + if (!(y instanceof Map) || x.size !== y.size) return false; + for (const [key, value] of x) { + if (!y.has(key) || !compare(value, y.get(key))) return false; + } + return true; + } + const xo = x; + const yo = y; + const keysX = [...Object.keys(xo), ...Object.getOwnPropertySymbols(xo)]; + const keysY = [...Object.keys(yo), ...Object.getOwnPropertySymbols(yo)]; + if (keysX.length !== keysY.length) return false; + if (!Object.keys(xo).length && !isPlainObject(x)) return false; + for (const key of keysX) { + if (!Object.hasOwn(yo, key)) return false; + if (!compare(xo[key], yo[key])) return false; + } + return true; + }; + return compare(a, b); +}; diff --git a/packages/core/src/bundle/helpers/index.js b/packages/core/src/bundle/helpers/index.js index b3797bb3..57f75525 100644 --- a/packages/core/src/bundle/helpers/index.js +++ b/packages/core/src/bundle/helpers/index.js @@ -5,4 +5,6 @@ export * from './createEventEmitter/createEventEmitter'; export * from './createReactiveContext/createReactiveContext'; export * from './createSharedHook/createSharedHook'; export * from './createStore/createStore'; +export * from './deepEqual/deepEqual'; export * from './makeDestructurable/makeDestructurable'; +export * from './shallowEqual/shallowEqual'; diff --git a/packages/core/src/bundle/helpers/shallowEqual/shallowEqual.js b/packages/core/src/bundle/helpers/shallowEqual/shallowEqual.js new file mode 100644 index 00000000..fb176f5a --- /dev/null +++ b/packages/core/src/bundle/helpers/shallowEqual/shallowEqual.js @@ -0,0 +1,61 @@ +const isObject = (value) => typeof value === 'object' && value !== null; +const isPlainObject = (value) => { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; +/** + * @name shallowEqual + * @description - Performs a shallow comparison between two values to determine if they are equivalent + * @category Helpers + * @usage low + * + * @param {any} a The first value to compare + * @param {any} b The second value to compare + * @returns {boolean} `true` if the two values are shallowly equal, `false` otherwise + * + * @warning - Set members and Map keys are compared by reference. Objects without own enumerable string keys are compared by reference unless they are plain objects. + * + * @example + * shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2 }); // true + */ +export const shallowEqual = (a, b) => { + if (Object.is(a, b)) return true; + if (!isObject(a) || !isObject(b)) return false; + if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false; + if (a instanceof Date) return b instanceof Date && Object.is(a.getTime(), b.getTime()); + if (a instanceof RegExp) + return b instanceof RegExp && a.source === b.source && a.flags === b.flags; + if (a instanceof Error) return b instanceof Error && a.name === b.name && a.message === b.message; + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) return false; + for (let index = 0; index < a.length; index += 1) { + if (!Object.is(a[index], b[index])) return false; + } + return true; + } + if (a instanceof Set) { + if (!(b instanceof Set) || a.size !== b.size) return false; + for (const value of a) { + if (!b.has(value)) return false; + } + return true; + } + if (a instanceof Map) { + if (!(b instanceof Map) || a.size !== b.size) return false; + for (const [key, value] of a) { + if (!b.has(key) || !Object.is(value, b.get(key))) return false; + } + return true; + } + const ao = a; + const bo = b; + const keysA = [...Object.keys(ao), ...Object.getOwnPropertySymbols(ao)]; + const keysB = [...Object.keys(bo), ...Object.getOwnPropertySymbols(bo)]; + if (keysA.length !== keysB.length) return false; + if (!Object.keys(ao).length && !isPlainObject(a)) return false; + for (const key of keysA) { + if (!Object.hasOwn(bo, key)) return false; + if (!Object.is(ao[key], bo[key])) return false; + } + return true; +}; diff --git a/packages/core/src/bundle/hooks/lifecycle.js b/packages/core/src/bundle/hooks/lifecycle.js index 7eda9733..d523e832 100644 --- a/packages/core/src/bundle/hooks/lifecycle.js +++ b/packages/core/src/bundle/hooks/lifecycle.js @@ -1,4 +1,6 @@ export * from './useAsyncEffect/useAsyncEffect'; +export * from './useCustomCompareEffect/useCustomCompareEffect'; +export * from './useDeepEffect/useDeepEffect'; export * from './useDidUpdate/useDidUpdate'; export * from './useIsFirstRender/useIsFirstRender'; export * from './useIsomorphicLayoutEffect/useIsomorphicLayoutEffect'; diff --git a/packages/core/src/bundle/hooks/useCustomCompareEffect/useCustomCompareEffect.js b/packages/core/src/bundle/hooks/useCustomCompareEffect/useCustomCompareEffect.js new file mode 100644 index 00000000..7b0689b1 --- /dev/null +++ b/packages/core/src/bundle/hooks/useCustomCompareEffect/useCustomCompareEffect.js @@ -0,0 +1,21 @@ +import { useEffect, useRef } from 'react'; +/** + * @name useCustomCompareEffect + * @description - Hook that triggers the effect callback when the comparator reports the dependencies as changed + * @category Lifecycle + * @usage low + * + * @param {EffectCallback} effect The effect callback + * @param {DependencyList} deps The dependencies list for the effect + * @param {(deps: DependencyList, prevDeps: DependencyList) => boolean} comparator The function that returns `true` when the dependencies are equal + * + * @example + * useCustomCompareEffect(() => console.log("effect"), [user], ([user], [prevUser]) => user.id === prevUser.id); + */ +export const useCustomCompareEffect = (effect, deps, comparator) => { + const depsRef = useRef(undefined); + const signalRef = useRef(0); + if (!deps || !depsRef.current || !comparator(deps, depsRef.current)) signalRef.current += 1; + depsRef.current = deps; + useEffect(effect, [signalRef.current]); +}; diff --git a/packages/core/src/bundle/hooks/useDeepEffect/useDeepEffect.js b/packages/core/src/bundle/hooks/useDeepEffect/useDeepEffect.js new file mode 100644 index 00000000..f60e34bf --- /dev/null +++ b/packages/core/src/bundle/hooks/useDeepEffect/useDeepEffect.js @@ -0,0 +1,19 @@ +import { deepEqual } from '@/helpers/deepEqual/deepEqual'; +import { useCustomCompareEffect } from '../useCustomCompareEffect/useCustomCompareEffect'; +/** + * @name useDeepEffect + * @description - Hook that executes an effect only when dependencies change deeply + * @category Lifecycle + * @usage low + * + * @param {EffectCallback} effect The effect callback + * @param {DependencyList} [deps] The dependencies list for the effect + * + * @warning - Use `useCustomCompareEffect` with your own comparator when the comparison rules do not fit + * + * @example + * useDeepEffect(() => console.log("effect"), [user]); + */ +export const useDeepEffect = (effect, deps) => { + useCustomCompareEffect(effect, deps, deepEqual); +}; diff --git a/packages/core/src/bundle/hooks/useShallowEffect/useShallowEffect.js b/packages/core/src/bundle/hooks/useShallowEffect/useShallowEffect.js index d43314e3..ff53fa6c 100644 --- a/packages/core/src/bundle/hooks/useShallowEffect/useShallowEffect.js +++ b/packages/core/src/bundle/hooks/useShallowEffect/useShallowEffect.js @@ -1,37 +1,21 @@ -import { useEffect, useRef } from 'react'; -export const deepEqual = (a, b) => { - if (a === b) return true; - if (a == null || b == null) return a === b; - if (typeof a !== typeof b) return false; - if (typeof a !== 'object') return a === b; - if (Array.isArray(a) !== Array.isArray(b)) return false; - if (Array.isArray(a)) - return a.length === b.length && a.every((value, index) => deepEqual(value, b[index])); - const keysA = Object.keys(a); - const keysB = Object.keys(b); - if (keysA.length !== keysB.length) return false; - for (const key of keysA) { - if (!keysB.includes(key)) return false; - if (!deepEqual(a[key], b[key])) return false; - } - return true; -}; +import { shallowEqual } from '@/helpers/shallowEqual/shallowEqual'; +import { useCustomCompareEffect } from '../useCustomCompareEffect/useCustomCompareEffect'; +const shallowEqualDeps = (deps, prevDeps) => + deps.length === prevDeps.length && deps.every((dep, index) => shallowEqual(dep, prevDeps[index])); /** * @name useShallowEffect - * @description - Hook that executes an effect only when dependencies change shallowly or deeply + * @description - Hook that executes an effect only when dependencies change shallowly * @category Lifecycle * @usage low * * @param {EffectCallback} effect The effect callback * @param {DependencyList} [deps] The dependencies list for the effect * + * @warning - Use `useCustomCompareEffect` with your own comparator when the comparison rules do not fit + * * @example * useShallowEffect(() => console.log("effect"), [user]); */ export const useShallowEffect = (effect, deps) => { - const depsRef = useRef(deps); - if (!depsRef.current || !deepEqual(deps, depsRef.current)) { - depsRef.current = deps; - } - useEffect(effect, depsRef.current); + useCustomCompareEffect(effect, deps, shallowEqualDeps); }; diff --git a/packages/core/src/helpers/deepEqual/deepEqual.demo.tsx b/packages/core/src/helpers/deepEqual/deepEqual.demo.tsx new file mode 100644 index 00000000..49713027 --- /dev/null +++ b/packages/core/src/helpers/deepEqual/deepEqual.demo.tsx @@ -0,0 +1,204 @@ +import { deepEqual } from '@siberiacancode/reactuse'; +import { CheckIcon, XIcon } from 'lucide-react'; +import { useState } from 'react'; + +const MAX_INLINE_WIDTH = 32; + +const stringify = (value: unknown, indent = 0, seen = new Set(), offset = 0): string => { + if (typeof value === 'string') return `'${value}'`; + if (typeof value !== 'object' || value === null) return String(value); + if (seen.has(value)) return '[Circular]'; + if (value instanceof Date) return `Date('${value.toISOString().slice(0, 10)}')`; + if (value instanceof RegExp) return String(value); + + const pad = ' '.repeat(indent + 1); + const close = ' '.repeat(indent); + + seen.add(value); + + const wrap = (prefix: string, brackets: '[]' | '{}', entries: string[]) => { + seen.delete(value); + + const [open, end] = brackets; + if (!entries.length) return `${prefix}${open}${end}`; + + const inline = entries.join(', '); + const line = brackets === '[]' ? `${prefix}[${inline}]` : `${prefix}{ ${inline} }`; + if (indent * 2 + offset + line.length <= MAX_INLINE_WIDTH && !inline.includes('\n')) + return line; + + return `${prefix}${open}\n${entries.map((entry) => `${pad}${entry}`).join(',\n')}\n${close}${end}`; + }; + + if (Array.isArray(value)) + return wrap( + '', + '[]', + value.map((item) => stringify(item, indent + 1, seen)) + ); + + if (value instanceof Set) + return wrap( + 'Set ', + '{}', + [...value].map((item) => stringify(item, indent + 1, seen)) + ); + + if (value instanceof Map) + return wrap( + 'Map ', + '{}', + [...value].map(([key, item]) => { + const name = `${stringify(key)} => `; + return `${name}${stringify(item, indent + 1, seen, name.length)}`; + }) + ); + + return wrap( + '', + '{}', + Object.entries(value).map( + ([key, item]) => `${key}: ${stringify(item, indent + 1, seen, key.length + 2)}` + ) + ); +}; + +const createCircular = (label: string) => { + const node: Record = { label }; + node.self = node; + return node; +}; + +interface Case { + description: string; + label: string; + value: string; + create: (diverge: boolean) => [unknown, unknown]; +} + +const CASES: Case[] = [ + { + value: 'nested', + label: 'Nested', + description: 'Objects are compared key by key, all the way down', + create: (diverge) => [ + { user: { name: 'siberiacancode', profile: { stars: 1200, tags: ['react', 'hooks'] } } }, + { + user: { + name: 'siberiacancode', + profile: { stars: 1200, tags: ['react', diverge ? 'state' : 'hooks'] } + } + } + ] + }, + { + value: 'array', + label: 'Arrays', + description: 'Arrays match by length and index, so order matters', + create: (diverge) => [ + [1, [2, 3], { done: true }], + diverge ? [1, [3, 2], { done: true }] : [1, [2, 3], { done: true }] + ] + }, + { + value: 'date', + label: 'Dates', + description: 'Dates compare by timestamp, not by reference', + create: (diverge) => [ + { releasedAt: new Date('2024-01-01') }, + { releasedAt: new Date(diverge ? '2024-06-01' : '2024-01-01') } + ] + }, + { + value: 'collections', + label: 'Set & Map', + description: 'Map values are compared deeply, Set members and Map keys by reference', + create: (diverge) => [ + new Map([['owners', new Set([{ id: 1 }, { id: 2 }])]]), + new Map([['owners', new Set([{ id: 1 }, { id: diverge ? 3 : 2 }])]]) + ] + }, + { + value: 'regexp', + label: 'RegExp', + description: 'Regular expressions compare by source and flags', + create: (diverge) => [{ pattern: /ab+c/gi }, { pattern: diverge ? /ab+c/g : /ab+c/gi }] + }, + { + value: 'circular', + label: 'Circular', + description: 'Circular references are tracked, so comparison never loops forever', + create: (diverge) => [createCircular('node'), createCircular(diverge ? 'other' : 'node')] + } +]; + +interface ValuePanelProps { + name: string; + value: unknown; +} + +const ValuePanel = ({ name, value }: ValuePanelProps) => ( +
+ {name} +
+      {stringify(value)}
+    
+
+); + +const Demo = () => { + const [activeCase, setActiveCase] = useState(CASES[0]); + const [diverge, setDiverge] = useState(false); + + const [a, b] = activeCase.create(diverge); + const equal = deepEqual(a, b); + + return ( +
+
+ {CASES.map((item) => ( + + ))} +
+ +

{activeCase.description}

+ +
+ + +
+ +
+ + +
+ + a === b: {String(Object.is(a, b))} + + + {equal ? : } + deepEqual(a, b): {String(equal)} + +
+
+
+ ); +}; + +export default Demo; diff --git a/packages/core/src/helpers/deepEqual/deepEqual.test.ts b/packages/core/src/helpers/deepEqual/deepEqual.test.ts new file mode 100644 index 00000000..2fa54e22 --- /dev/null +++ b/packages/core/src/helpers/deepEqual/deepEqual.test.ts @@ -0,0 +1,182 @@ +import { expect, it } from 'vitest'; + +import { deepEqual } from './deepEqual'; + +it('Should compare primitives', () => { + expect(deepEqual(1, 1)).toBe(true); + expect(deepEqual(1, 2)).toBe(false); + expect(deepEqual('a', 'a')).toBe(true); + expect(deepEqual('a', 'b')).toBe(false); + expect(deepEqual(true, true)).toBe(true); + expect(deepEqual(true, false)).toBe(false); +}); + +it('Should compare nullish values', () => { + expect(deepEqual(null, null)).toBe(true); + expect(deepEqual(undefined, undefined)).toBe(true); + expect(deepEqual(null, undefined)).toBe(false); + expect(deepEqual(null, {})).toBe(false); + expect(deepEqual(undefined, 0)).toBe(false); +}); + +it('Should not compare values of different types', () => { + expect(deepEqual(1, '1')).toBe(false); + expect(deepEqual(0, false)).toBe(false); + expect(deepEqual([], {})).toBe(false); + expect(deepEqual({}, [])).toBe(false); +}); + +it('Should compare arrays', () => { + expect(deepEqual([1, 2, 3], [1, 2, 3])).toBe(true); + expect(deepEqual([1, 2, 3], [1, 2])).toBe(false); + expect(deepEqual([1, 2, 3], [1, 2, 4])).toBe(false); + expect(deepEqual([[1, [2]]], [[1, [2]]])).toBe(true); + expect(deepEqual([[1, [2]]], [[1, [3]]])).toBe(false); +}); + +it('Should compare plain objects', () => { + expect(deepEqual({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true); + expect(deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true); + expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); + expect(deepEqual({ a: 1 }, { b: 1 })).toBe(false); + expect(deepEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } })).toBe(true); + expect(deepEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 2 } } })).toBe(false); +}); + +it('Should treat NaN as equal to itself', () => { + expect(deepEqual(Number.NaN, Number.NaN)).toBe(true); + expect(deepEqual([Number.NaN], [Number.NaN])).toBe(true); + expect(deepEqual({ a: Number.NaN }, { a: Number.NaN })).toBe(true); +}); + +it('Should distinguish +0 and -0', () => { + expect(deepEqual(0, -0)).toBe(false); + expect(deepEqual({ a: 0 }, { a: -0 })).toBe(false); +}); + +it('Should compare dates by time value', () => { + expect(deepEqual(new Date(1), new Date(1))).toBe(true); + expect(deepEqual(new Date(1), new Date(2))).toBe(false); + expect(deepEqual({ at: new Date(1) }, { at: new Date(2) })).toBe(false); + expect(deepEqual(new Date(Number.NaN), new Date(Number.NaN))).toBe(true); +}); + +it('Should compare regexps by source and flags', () => { + expect(deepEqual(/a/, /a/)).toBe(true); + expect(deepEqual(/a/, /b/)).toBe(false); + expect(deepEqual(/a/g, /a/i)).toBe(false); + expect(deepEqual({ pattern: /a/ }, { pattern: /b/ })).toBe(false); +}); + +it('Should compare sets', () => { + expect(deepEqual(new Set([1, 2]), new Set([1, 2]))).toBe(true); + expect(deepEqual(new Set([1, 2]), new Set([2, 1]))).toBe(true); + expect(deepEqual(new Set([1]), new Set([2]))).toBe(false); + expect(deepEqual(new Set([1]), new Set([1, 2]))).toBe(false); + expect(deepEqual(new Set(), new Set([1]))).toBe(false); +}); + +it('Should compare maps', () => { + expect(deepEqual(new Map([['a', 1]]), new Map([['a', 1]]))).toBe(true); + expect(deepEqual(new Map([['a', 1]]), new Map([['a', 2]]))).toBe(false); + expect(deepEqual(new Map([['a', 1]]), new Map([['b', 1]]))).toBe(false); + expect(deepEqual(new Map([['a', 1]]), new Map())).toBe(false); + expect(deepEqual(new Map([['a', { b: 1 }]]), new Map([['a', { b: 1 }]]))).toBe(true); +}); + +it('Should not equate different object types with the same keys', () => { + expect(deepEqual(new Date(1), /a/)).toBe(false); + expect(deepEqual(new Set([1]), new Map([['a', 1]]))).toBe(false); + expect(deepEqual(new Date(1), {})).toBe(false); +}); + +it('Should not equate a class instance with a plain object', () => { + class Point { + constructor( + public x: number, + public y: number + ) {} + } + + expect(deepEqual(new Point(1, 2), new Point(1, 2))).toBe(true); + expect(deepEqual(new Point(1, 2), new Point(1, 3))).toBe(false); + expect(deepEqual(new Point(1, 2), { x: 1, y: 2 })).toBe(false); +}); + +it('Should handle circular references', () => { + interface Node { + self?: Node; + value: number; + } + + const a: Node = { value: 1 }; + a.self = a; + const b: Node = { value: 1 }; + b.self = b; + const c: Node = { value: 2 }; + c.self = c; + + expect(deepEqual(a, b)).toBe(true); + expect(deepEqual(a, c)).toBe(false); +}); + +it('Should handle mutually circular references', () => { + const createPair = () => { + const left: any = { name: 'left' }; + const right: any = { name: 'right', left }; + left.right = right; + return left; + }; + + expect(deepEqual(createPair(), createPair())).toBe(true); +}); + +it('Should compare functions by reference', () => { + const callback = () => 1; + + expect(deepEqual(callback, callback)).toBe(true); + expect( + deepEqual( + () => 1, + () => 1 + ) + ).toBe(false); +}); + +it('Should compare errors by name and message', () => { + expect(deepEqual(new Error('a'), new Error('a'))).toBe(true); + expect(deepEqual(new Error('a'), new Error('b'))).toBe(false); + expect(deepEqual(new Error('a'), new TypeError('a'))).toBe(false); +}); + +it('Should not treat exotic objects without own keys as equal', () => { + expect(deepEqual(new ArrayBuffer(8), new ArrayBuffer(4))).toBe(false); + expect(deepEqual(new URL('https://a.dev'), new URL('https://b.dev'))).toBe(false); + + const first = document.createElement('div'); + const second = document.createElement('div'); + + expect(deepEqual(first, second)).toBe(false); + expect(deepEqual(first, first)).toBe(true); + expect(deepEqual({}, {})).toBe(true); +}); + +it('Should compare set members by reference', () => { + const member = { a: 1 }; + + expect(deepEqual(new Set([member]), new Set([member]))).toBe(true); + expect(deepEqual(new Set([{ a: 1 }]), new Set([{ a: 1 }]))).toBe(false); + expect(deepEqual(new Set([{ a: 1 }, { a: 1 }]), new Set([{ a: 1 }, { a: 2 }]))).toBe(false); +}); + +it('Should not report a pair as equal after it compared unequal', () => { + const first = { v: 1 }; + const second = { v: 2 }; + + expect( + deepEqual( + { set: new Set([first, second]), value: first }, + { set: new Set([{ v: 2 }, { v: 1 }]), value: { v: 2 } } + ) + ).toBe(false); +}); diff --git a/packages/core/src/helpers/deepEqual/deepEqual.ts b/packages/core/src/helpers/deepEqual/deepEqual.ts new file mode 100644 index 00000000..472e3bac --- /dev/null +++ b/packages/core/src/helpers/deepEqual/deepEqual.ts @@ -0,0 +1,100 @@ +const isObject = (value: unknown): value is object => typeof value === 'object' && value !== null; + +const isPlainObject = (value: object) => { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +/** + * @name deepEqual + * @description - Performs a deep comparison between two values to determine if they are equivalent + * @category Helpers + * @usage low + * + * @param {any} a The first value to compare + * @param {any} b The second value to compare + * @returns {boolean} `true` if the two values are deeply equal, `false` otherwise + * + * @warning - Set members and Map keys are compared by reference, Map values are compared deeply. Objects without own enumerable string keys are compared by reference unless they are plain objects. + * + * @example + * deepEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }); // true + */ +export const deepEqual = (a: any, b: any): boolean => { + const seen = new WeakMap>(); + + const mark = (x: object, y: object) => { + const visited = seen.get(x); + + if (visited) { + if (visited.has(y)) return true; + visited.add(y); + return false; + } + + seen.set(x, new WeakSet([y])); + return false; + }; + + const compare = (x: unknown, y: unknown): boolean => { + if (Object.is(x, y)) return true; + if (!isObject(x) || !isObject(y)) return false; + if (Object.getPrototypeOf(x) !== Object.getPrototypeOf(y)) return false; + + if (mark(x, y)) return true; + + if (x instanceof Date) return y instanceof Date && Object.is(x.getTime(), y.getTime()); + if (x instanceof RegExp) + return y instanceof RegExp && x.source === y.source && x.flags === y.flags; + if (x instanceof Error) + return y instanceof Error && x.name === y.name && x.message === y.message; + + if (Array.isArray(x)) { + if (!Array.isArray(y) || x.length !== y.length) return false; + + for (let index = 0; index < x.length; index += 1) { + if (!compare(x[index], y[index])) return false; + } + + return true; + } + + if (x instanceof Set) { + if (!(y instanceof Set) || x.size !== y.size) return false; + + for (const value of x) { + if (!y.has(value)) return false; + } + + return true; + } + + if (x instanceof Map) { + if (!(y instanceof Map) || x.size !== y.size) return false; + + for (const [key, value] of x) { + if (!y.has(key) || !compare(value, y.get(key))) return false; + } + + return true; + } + + const xo = x as Record; + const yo = y as Record; + + const keysX = [...Object.keys(xo), ...Object.getOwnPropertySymbols(xo)]; + const keysY = [...Object.keys(yo), ...Object.getOwnPropertySymbols(yo)]; + + if (keysX.length !== keysY.length) return false; + if (!Object.keys(xo).length && !isPlainObject(x)) return false; + + for (const key of keysX) { + if (!Object.hasOwn(yo, key)) return false; + if (!compare(xo[key], yo[key])) return false; + } + + return true; + }; + + return compare(a, b); +}; diff --git a/packages/core/src/helpers/index.ts b/packages/core/src/helpers/index.ts index b3797bb3..57f75525 100644 --- a/packages/core/src/helpers/index.ts +++ b/packages/core/src/helpers/index.ts @@ -5,4 +5,6 @@ export * from './createEventEmitter/createEventEmitter'; export * from './createReactiveContext/createReactiveContext'; export * from './createSharedHook/createSharedHook'; export * from './createStore/createStore'; +export * from './deepEqual/deepEqual'; export * from './makeDestructurable/makeDestructurable'; +export * from './shallowEqual/shallowEqual'; diff --git a/packages/core/src/helpers/shallowEqual/shallowEqual.demo.tsx b/packages/core/src/helpers/shallowEqual/shallowEqual.demo.tsx new file mode 100644 index 00000000..05b42a6c --- /dev/null +++ b/packages/core/src/helpers/shallowEqual/shallowEqual.demo.tsx @@ -0,0 +1,198 @@ +import { deepEqual, shallowEqual } from '@siberiacancode/reactuse'; +import { CheckIcon, XIcon } from 'lucide-react'; +import { useState } from 'react'; + +const MAX_INLINE_WIDTH = 32; + +const stringify = (value: unknown, indent = 0, seen = new Set(), offset = 0): string => { + if (typeof value === 'string') return `'${value}'`; + if (typeof value !== 'object' || value === null) return String(value); + if (seen.has(value)) return '[Circular]'; + if (value instanceof Date) return `Date('${value.toISOString().slice(0, 10)}')`; + if (value instanceof RegExp) return String(value); + + const pad = ' '.repeat(indent + 1); + const close = ' '.repeat(indent); + + seen.add(value); + + const wrap = (prefix: string, brackets: '[]' | '{}', entries: string[]) => { + seen.delete(value); + + const [open, end] = brackets; + if (!entries.length) return `${prefix}${open}${end}`; + + const inline = entries.join(', '); + const line = brackets === '[]' ? `${prefix}[${inline}]` : `${prefix}{ ${inline} }`; + if (indent * 2 + offset + line.length <= MAX_INLINE_WIDTH && !inline.includes('\n')) + return line; + + return `${prefix}${open}\n${entries.map((entry) => `${pad}${entry}`).join(',\n')}\n${close}${end}`; + }; + + if (Array.isArray(value)) + return wrap( + '', + '[]', + value.map((item) => stringify(item, indent + 1, seen)) + ); + + if (value instanceof Set) + return wrap( + 'Set ', + '{}', + [...value].map((item) => stringify(item, indent + 1, seen)) + ); + + if (value instanceof Map) + return wrap( + 'Map ', + '{}', + [...value].map(([key, item]) => { + const name = `${stringify(key)} => `; + return `${name}${stringify(item, indent + 1, seen, name.length)}`; + }) + ); + + return wrap( + '', + '{}', + Object.entries(value).map( + ([key, item]) => `${key}: ${stringify(item, indent + 1, seen, key.length + 2)}` + ) + ); +}; + +interface Case { + description: string; + label: string; + value: string; + create: (diverge: boolean) => [unknown, unknown]; +} + +const CASES: Case[] = [ + { + value: 'flat', + label: 'Flat', + description: 'Top level values are compared with Object.is', + create: (diverge) => [ + { name: 'siberiacancode', stars: 1200 }, + { name: 'siberiacancode', stars: diverge ? 1300 : 1200 } + ] + }, + { + value: 'nested', + label: 'Nested', + description: 'Nested objects are compared by reference, equal shape is not enough', + create: (diverge) => [ + { user: { name: 'siberiacancode' } }, + { user: { name: diverge ? 'other' : 'siberiacancode' } } + ] + }, + { + value: 'array', + label: 'Arrays', + description: 'Arrays match by length and index, without going deeper', + create: (diverge) => [ + [1, 2, 3], + [1, 2, diverge ? 4 : 3] + ] + }, + { + value: 'date', + label: 'Dates', + description: 'Dates compare by timestamp, not by reference', + create: (diverge) => [new Date('2024-01-01'), new Date(diverge ? '2024-06-01' : '2024-01-01')] + }, + { + value: 'regexp', + label: 'RegExp', + description: 'Regular expressions compare by source and flags', + create: (diverge) => [/ab+c/gi, diverge ? /ab+c/g : /ab+c/gi] + }, + { + value: 'collections', + label: 'Set & Map', + description: 'Set values and Map entries are matched by identity', + create: (diverge) => [ + new Map([['owners', new Set([{ id: 1 }])]]), + new Map([['owners', new Set([{ id: diverge ? 2 : 1 }])]]) + ] + } +]; + +interface ValuePanelProps { + name: string; + value: unknown; +} + +const ValuePanel = ({ name, value }: ValuePanelProps) => ( +
+ {name} +
+      {stringify(value)}
+    
+
+); + +const Demo = () => { + const [activeCase, setActiveCase] = useState(CASES[0]); + const [diverge, setDiverge] = useState(false); + + const [a, b] = activeCase.create(diverge); + const shallow = shallowEqual(a, b); + const deep = deepEqual(a, b); + + return ( +
+
+ {CASES.map((item) => ( + + ))} +
+ +

{activeCase.description}

+ +
+ + +
+ +
+ + +
+ + a === b: {String(Object.is(a, b))} + + + {shallow ? : } + shallowEqual: {String(shallow)} + + + {deep ? : } + deepEqual: {String(deep)} + +
+
+
+ ); +}; + +export default Demo; diff --git a/packages/core/src/helpers/shallowEqual/shallowEqual.test.ts b/packages/core/src/helpers/shallowEqual/shallowEqual.test.ts new file mode 100644 index 00000000..58d82edf --- /dev/null +++ b/packages/core/src/helpers/shallowEqual/shallowEqual.test.ts @@ -0,0 +1,172 @@ +import { expect, it } from 'vitest'; + +import { shallowEqual } from './shallowEqual'; + +it('Should compare primitives', () => { + expect(shallowEqual(1, 1)).toBe(true); + expect(shallowEqual(1, 2)).toBe(false); + expect(shallowEqual('a', 'a')).toBe(true); + expect(shallowEqual('a', 'b')).toBe(false); + expect(shallowEqual(true, true)).toBe(true); + expect(shallowEqual(true, false)).toBe(false); +}); + +it('Should compare nullish values', () => { + expect(shallowEqual(null, null)).toBe(true); + expect(shallowEqual(undefined, undefined)).toBe(true); + expect(shallowEqual(null, undefined)).toBe(false); + expect(shallowEqual(null, {})).toBe(false); + expect(shallowEqual(undefined, 0)).toBe(false); +}); + +it('Should not compare values of different types', () => { + expect(shallowEqual(1, '1')).toBe(false); + expect(shallowEqual(0, false)).toBe(false); + expect(shallowEqual([], {})).toBe(false); + expect(shallowEqual({}, [])).toBe(false); +}); + +it('Should compare arrays item by item', () => { + expect(shallowEqual([1, 2, 3], [1, 2, 3])).toBe(true); + expect(shallowEqual([1, 2, 3], [1, 2])).toBe(false); + expect(shallowEqual([1, 2, 3], [1, 2, 4])).toBe(false); + expect(shallowEqual([], [])).toBe(true); +}); + +it('Should compare plain objects one level deep', () => { + expect(shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true); + expect(shallowEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true); + expect(shallowEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); + expect(shallowEqual({ a: 1 }, { b: 1 })).toBe(false); + expect(shallowEqual({}, {})).toBe(true); +}); + +it('Should compare nested values by reference', () => { + const nested = { c: 1 }; + + expect(shallowEqual({ a: nested }, { a: nested })).toBe(true); + expect(shallowEqual({ a: { c: 1 } }, { a: { c: 1 } })).toBe(false); + expect(shallowEqual([[1]], [[1]])).toBe(false); + expect(shallowEqual({ a: [1] }, { a: [1] })).toBe(false); +}); + +it('Should compare symbol keys', () => { + const key = Symbol('key'); + + expect(shallowEqual({ [key]: 1 }, { [key]: 1 })).toBe(true); + expect(shallowEqual({ [key]: 1 }, { [key]: 2 })).toBe(false); + expect(shallowEqual({ [key]: 1 }, {})).toBe(false); +}); + +it('Should treat NaN as equal to itself', () => { + expect(shallowEqual(Number.NaN, Number.NaN)).toBe(true); + expect(shallowEqual([Number.NaN], [Number.NaN])).toBe(true); + expect(shallowEqual({ a: Number.NaN }, { a: Number.NaN })).toBe(true); +}); + +it('Should distinguish +0 and -0', () => { + expect(shallowEqual(0, -0)).toBe(false); + expect(shallowEqual({ a: 0 }, { a: -0 })).toBe(false); + expect(shallowEqual([0], [-0])).toBe(false); +}); + +it('Should compare dates by time value', () => { + expect(shallowEqual(new Date(1), new Date(1))).toBe(true); + expect(shallowEqual(new Date(1), new Date(2))).toBe(false); + expect(shallowEqual(new Date(Number.NaN), new Date(Number.NaN))).toBe(true); + expect(shallowEqual({ at: new Date(1) }, { at: new Date(1) })).toBe(false); +}); + +it('Should compare regexps by source and flags', () => { + expect(shallowEqual(/a/, /a/)).toBe(true); + expect(shallowEqual(/a/, /b/)).toBe(false); + expect(shallowEqual(/a/g, /a/i)).toBe(false); + expect(shallowEqual({ pattern: /a/ }, { pattern: /a/ })).toBe(false); +}); + +it('Should compare sets by member identity', () => { + const member = { id: 1 }; + + expect(shallowEqual(new Set([1, 2]), new Set([1, 2]))).toBe(true); + expect(shallowEqual(new Set([1, 2]), new Set([2, 1]))).toBe(true); + expect(shallowEqual(new Set([1]), new Set([2]))).toBe(false); + expect(shallowEqual(new Set([1]), new Set([1, 2]))).toBe(false); + expect(shallowEqual(new Set([member]), new Set([member]))).toBe(true); + expect(shallowEqual(new Set([{ id: 1 }]), new Set([{ id: 1 }]))).toBe(false); +}); + +it('Should compare maps by value identity', () => { + const value = { id: 1 }; + + expect(shallowEqual(new Map([['a', 1]]), new Map([['a', 1]]))).toBe(true); + expect(shallowEqual(new Map([['a', 1]]), new Map([['a', 2]]))).toBe(false); + expect(shallowEqual(new Map([['a', 1]]), new Map([['b', 1]]))).toBe(false); + expect(shallowEqual(new Map([['a', 1]]), new Map())).toBe(false); + expect(shallowEqual(new Map([['a', value]]), new Map([['a', value]]))).toBe(true); + expect(shallowEqual(new Map([['a', { id: 1 }]]), new Map([['a', { id: 1 }]]))).toBe(false); +}); + +it('Should not equate different object types with the same keys', () => { + expect(shallowEqual(new Date(1), /a/)).toBe(false); + expect(shallowEqual(new Set([1]), new Map([['a', 1]]))).toBe(false); + expect(shallowEqual(new Date(1), {})).toBe(false); +}); + +it('Should not equate a class instance with a plain object', () => { + class Point { + constructor( + public x: number, + public y: number + ) {} + } + + expect(shallowEqual(new Point(1, 2), new Point(1, 2))).toBe(true); + expect(shallowEqual(new Point(1, 2), new Point(1, 3))).toBe(false); + expect(shallowEqual(new Point(1, 2), { x: 1, y: 2 })).toBe(false); +}); + +it('Should handle circular references without recursion', () => { + interface Node { + self?: Node; + value: number; + } + + const a: Node = { value: 1 }; + a.self = a; + const b: Node = { value: 1 }; + b.self = b; + + expect(shallowEqual(a, a)).toBe(true); + expect(shallowEqual(a, b)).toBe(false); +}); + +it('Should compare functions by reference', () => { + const callback = () => 1; + + expect(shallowEqual(callback, callback)).toBe(true); + expect( + shallowEqual( + () => 1, + () => 1 + ) + ).toBe(false); + expect(shallowEqual({ callback }, { callback })).toBe(true); +}); + +it('Should compare errors by name and message', () => { + expect(shallowEqual(new Error('a'), new Error('a'))).toBe(true); + expect(shallowEqual(new Error('a'), new Error('b'))).toBe(false); + expect(shallowEqual(new Error('a'), new TypeError('a'))).toBe(false); +}); + +it('Should not treat exotic objects without own keys as equal', () => { + expect(shallowEqual(new ArrayBuffer(8), new ArrayBuffer(4))).toBe(false); + expect(shallowEqual(new URL('https://a.dev'), new URL('https://b.dev'))).toBe(false); + + const first = document.createElement('div'); + const second = document.createElement('div'); + + expect(shallowEqual(first, second)).toBe(false); + expect(shallowEqual(first, first)).toBe(true); + expect(shallowEqual({}, {})).toBe(true); +}); diff --git a/packages/core/src/helpers/shallowEqual/shallowEqual.ts b/packages/core/src/helpers/shallowEqual/shallowEqual.ts new file mode 100644 index 00000000..a41e09b6 --- /dev/null +++ b/packages/core/src/helpers/shallowEqual/shallowEqual.ts @@ -0,0 +1,78 @@ +const isObject = (value: unknown): value is object => typeof value === 'object' && value !== null; + +const isPlainObject = (value: object) => { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +/** + * @name shallowEqual + * @description - Performs a shallow comparison between two values to determine if they are equivalent + * @category Helpers + * @usage low + * + * @param {any} a The first value to compare + * @param {any} b The second value to compare + * @returns {boolean} `true` if the two values are shallowly equal, `false` otherwise + * + * @warning - Set members and Map keys are compared by reference. Objects without own enumerable string keys are compared by reference unless they are plain objects. + * + * @example + * shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2 }); // true + */ +export const shallowEqual = (a: any, b: any): boolean => { + if (Object.is(a, b)) return true; + if (!isObject(a) || !isObject(b)) return false; + if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false; + + if (a instanceof Date) return b instanceof Date && Object.is(a.getTime(), b.getTime()); + if (a instanceof RegExp) + return b instanceof RegExp && a.source === b.source && a.flags === b.flags; + if (a instanceof Error) return b instanceof Error && a.name === b.name && a.message === b.message; + + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) return false; + + for (let index = 0; index < a.length; index += 1) { + if (!Object.is(a[index], b[index])) return false; + } + + return true; + } + + if (a instanceof Set) { + if (!(b instanceof Set) || a.size !== b.size) return false; + + for (const value of a) { + if (!b.has(value)) return false; + } + + return true; + } + + if (a instanceof Map) { + if (!(b instanceof Map) || a.size !== b.size) return false; + + for (const [key, value] of a) { + if (!b.has(key) || !Object.is(value, b.get(key))) return false; + } + + return true; + } + + const ao = a as Record; + const bo = b as Record; + + const keysA = [...Object.keys(ao), ...Object.getOwnPropertySymbols(ao)]; + const keysB = [...Object.keys(bo), ...Object.getOwnPropertySymbols(bo)]; + + if (keysA.length !== keysB.length) return false; + if (!Object.keys(ao).length && !isPlainObject(a)) return false; + + for (const key of keysA) { + if (!Object.hasOwn(bo, key)) return false; + if (!Object.is(ao[key], bo[key])) return false; + } + + return true; +}; diff --git a/packages/core/src/hooks/lifecycle.ts b/packages/core/src/hooks/lifecycle.ts index 7eda9733..d523e832 100644 --- a/packages/core/src/hooks/lifecycle.ts +++ b/packages/core/src/hooks/lifecycle.ts @@ -1,4 +1,6 @@ export * from './useAsyncEffect/useAsyncEffect'; +export * from './useCustomCompareEffect/useCustomCompareEffect'; +export * from './useDeepEffect/useDeepEffect'; export * from './useDidUpdate/useDidUpdate'; export * from './useIsFirstRender/useIsFirstRender'; export * from './useIsomorphicLayoutEffect/useIsomorphicLayoutEffect'; diff --git a/packages/core/src/hooks/useCustomCompareEffect/useCustomCompareEffect.demo.tsx b/packages/core/src/hooks/useCustomCompareEffect/useCustomCompareEffect.demo.tsx new file mode 100644 index 00000000..fa115227 --- /dev/null +++ b/packages/core/src/hooks/useCustomCompareEffect/useCustomCompareEffect.demo.tsx @@ -0,0 +1,89 @@ +import { useCustomCompareEffect } from '@siberiacancode/reactuse'; +import { RefreshCwIcon, UserIcon } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +const Demo = () => { + const [id, setId] = useState(1); + const [, forceRerender] = useState(0); + + const user = { id, profile: { theme: 'dark' } }; + + const effectCountRef = useRef(0); + const customCountRef = useRef(0); + + useEffect(() => { + effectCountRef.current++; + }, [user]); + + useCustomCompareEffect( + () => { + customCountRef.current++; + }, + [user], + ([user], [prevUser]) => user.id === prevUser.id + ); + + return ( +
+
+
+
+ +
+ +
+ User id: {id} + + The comparator only looks at user.id, so everything else in the object is + ignored. + +
+
+ +
+
+ + useEffect + + + {effectCountRef.current} + + + runs on every render + +
+ +
+ + useCustomCompareEffect + + + {customCountRef.current} + + + runs only when id changes + +
+
+ +
+ + + +
+
+
+ ); +}; + +export default Demo; diff --git a/packages/core/src/hooks/useCustomCompareEffect/useCustomCompareEffect.test.ts b/packages/core/src/hooks/useCustomCompareEffect/useCustomCompareEffect.test.ts new file mode 100644 index 00000000..e969f995 --- /dev/null +++ b/packages/core/src/hooks/useCustomCompareEffect/useCustomCompareEffect.test.ts @@ -0,0 +1,109 @@ +import type { DependencyList } from 'react'; + +import { act, renderHook } from '@testing-library/react'; + +import { renderHookServer } from '@/tests'; + +import { useCustomCompareEffect } from './useCustomCompareEffect'; + +const equal = (deps: DependencyList, prevDeps: DependencyList) => + deps.length === prevDeps.length && deps.every((dep, index) => Object.is(dep, prevDeps[index])); + +it('Should use custom compare effect', () => { + const effect = vi.fn(); + + renderHook(() => useCustomCompareEffect(effect, [{ id: 1 }], equal)); + + expect(effect).toHaveBeenCalledOnce(); +}); + +it('Should use custom compare effect on server side', () => { + const effect = vi.fn(); + + renderHookServer(() => useCustomCompareEffect(effect, [{ id: 1 }], equal)); + + expect(effect).not.toHaveBeenCalled(); +}); + +it('Should not run effect when comparator returns true', () => { + const effect = vi.fn(); + + const { rerender } = renderHook(() => useCustomCompareEffect(effect, [{ id: 1 }], () => true)); + + expect(effect).toHaveBeenCalledOnce(); + + act(rerender); + + expect(effect).toHaveBeenCalledOnce(); +}); + +it('Should cleanup and rerun effect when comparator returns false', () => { + const cleanup = vi.fn(); + const effect = vi.fn(() => cleanup); + + const { rerender } = renderHook(() => useCustomCompareEffect(effect, [{ id: 1 }], () => false)); + + expect(effect).toHaveBeenCalledOnce(); + expect(cleanup).not.toHaveBeenCalled(); + + act(rerender); + + expect(cleanup).toHaveBeenCalledOnce(); + expect(effect).toHaveBeenCalledTimes(2); +}); + +it('Should pass deps and prev deps to comparator in order', () => { + const comparator = vi.fn(() => true); + const first = { id: 1 }; + const second = { id: 2 }; + let deps = [first]; + + const { rerender } = renderHook(() => useCustomCompareEffect(vi.fn(), deps, comparator)); + + expect(comparator).not.toHaveBeenCalled(); + + act(() => { + deps = [second]; + rerender(); + }); + + expect(comparator).toHaveBeenCalledExactlyOnceWith([second], [first]); +}); + +it('Should allow deps length to change between renders', () => { + const effect = vi.fn(); + let deps: unknown[] = [{ id: 1 }]; + + const { rerender } = renderHook(() => useCustomCompareEffect(effect, deps, equal)); + + act(() => { + deps = [{ id: 1 }, { id: 2 }]; + rerender(); + }); + + expect(effect).toHaveBeenCalledTimes(2); +}); + +it('Should run cleanup on unmount', () => { + const cleanup = vi.fn(); + + const { unmount } = renderHook(() => useCustomCompareEffect(() => cleanup, [{ id: 1 }], equal)); + + expect(cleanup).not.toHaveBeenCalled(); + + unmount(); + + expect(cleanup).toHaveBeenCalledOnce(); +}); + +it('Should run effect on every render when deps are not passed', () => { + const effect = vi.fn(); + + const { rerender } = renderHook(() => useCustomCompareEffect(effect, undefined, equal)); + + expect(effect).toHaveBeenCalledOnce(); + + act(rerender); + + expect(effect).toHaveBeenCalledTimes(2); +}); diff --git a/packages/core/src/hooks/useCustomCompareEffect/useCustomCompareEffect.ts b/packages/core/src/hooks/useCustomCompareEffect/useCustomCompareEffect.ts new file mode 100644 index 00000000..10be57c2 --- /dev/null +++ b/packages/core/src/hooks/useCustomCompareEffect/useCustomCompareEffect.ts @@ -0,0 +1,30 @@ +import type { DependencyList, EffectCallback } from 'react'; + +import { useEffect, useRef } from 'react'; + +/** + * @name useCustomCompareEffect + * @description - Hook that triggers the effect callback when the comparator reports the dependencies as changed + * @category Lifecycle + * @usage low + * + * @param {EffectCallback} effect The effect callback + * @param {DependencyList} deps The dependencies list for the effect + * @param {(deps: DependencyList, prevDeps: DependencyList) => boolean} comparator The function that returns `true` when the dependencies are equal + * + * @example + * useCustomCompareEffect(() => console.log("effect"), [user], ([user], [prevUser]) => user.id === prevUser.id); + */ +export const useCustomCompareEffect = ( + effect: EffectCallback, + deps: Deps | undefined, + comparator: (deps: Deps, prevDeps: Deps) => boolean +) => { + const depsRef = useRef(undefined); + const signalRef = useRef(0); + + if (!deps || !depsRef.current || !comparator(deps, depsRef.current)) signalRef.current += 1; + depsRef.current = deps; + + useEffect(effect, [signalRef.current]); +}; diff --git a/packages/core/src/hooks/useDeepEffect/useDeepEffect.demo.tsx b/packages/core/src/hooks/useDeepEffect/useDeepEffect.demo.tsx new file mode 100644 index 00000000..41dd3098 --- /dev/null +++ b/packages/core/src/hooks/useDeepEffect/useDeepEffect.demo.tsx @@ -0,0 +1,95 @@ +import { useDeepEffect, useShallowEffect } from '@siberiacancode/reactuse'; +import { LayersIcon, RefreshCwIcon } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +const Demo = () => { + const [, forceRerender] = useState(0); + + const filter = { role: 'admin', range: { from: 0, to: 10 } }; + + const effectCountRef = useRef(0); + const shallowCountRef = useRef(0); + const deepCountRef = useRef(0); + + useEffect(() => { + effectCountRef.current++; + }, [filter]); + + useShallowEffect(() => { + shallowCountRef.current++; + }, [filter]); + + useDeepEffect(() => { + deepCountRef.current++; + }, [filter]); + + return ( +
+
+
+
+ +
+ +
+ Nested dependency + + The nested range object is rebuilt on every render, so only a deep + comparison sees it as unchanged. + +
+
+ +
+
+ + useEffect + + + {effectCountRef.current} + + + every render + +
+ +
+ + shallow + + + {shallowCountRef.current} + + + nested ref differs + +
+ +
+ deep + + {deepCountRef.current} + + + skips identical values + +
+
+ +
+ +
+
+
+ ); +}; + +export default Demo; diff --git a/packages/core/src/hooks/useDeepEffect/useDeepEffect.test.ts b/packages/core/src/hooks/useDeepEffect/useDeepEffect.test.ts new file mode 100644 index 00000000..eae97022 --- /dev/null +++ b/packages/core/src/hooks/useDeepEffect/useDeepEffect.test.ts @@ -0,0 +1,53 @@ +import { act, renderHook } from '@testing-library/react'; + +import { renderHookServer } from '@/tests'; + +import { useDeepEffect } from './useDeepEffect'; + +it('Should use deep effect', () => { + const effect = vi.fn(); + + renderHook(() => useDeepEffect(effect, [])); + + expect(effect).toHaveBeenCalledOnce(); +}); + +it('Should use deep effect on server side', () => { + const effect = vi.fn(); + + renderHookServer(() => useDeepEffect(effect, [])); + + expect(effect).not.toHaveBeenCalled(); +}); + +it('Should not run effect when deps are deep equal', () => { + const effect = vi.fn(); + let object = { a: 'a', b: { c: 'c' } }; + + const { rerender } = renderHook(() => useDeepEffect(effect, [object])); + + expect(effect).toHaveBeenCalledOnce(); + + act(() => { + object = { a: 'a', b: { c: 'c' } }; + rerender(); + }); + + expect(effect).toHaveBeenCalledOnce(); +}); + +it('Should run effect when nested deps change', () => { + const effect = vi.fn(); + let object = { a: 'a', b: { c: 'c' } }; + + const { rerender } = renderHook(() => useDeepEffect(effect, [object])); + + expect(effect).toHaveBeenCalledOnce(); + + act(() => { + object = { a: 'a', b: { c: 'd' } }; + rerender(); + }); + + expect(effect).toBeCalledTimes(2); +}); diff --git a/packages/core/src/hooks/useDeepEffect/useDeepEffect.ts b/packages/core/src/hooks/useDeepEffect/useDeepEffect.ts new file mode 100644 index 00000000..cea93e7b --- /dev/null +++ b/packages/core/src/hooks/useDeepEffect/useDeepEffect.ts @@ -0,0 +1,23 @@ +import type { DependencyList, EffectCallback } from 'react'; + +import { deepEqual } from '@/helpers/deepEqual/deepEqual'; + +import { useCustomCompareEffect } from '../useCustomCompareEffect/useCustomCompareEffect'; + +/** + * @name useDeepEffect + * @description - Hook that executes an effect only when dependencies change deeply + * @category Lifecycle + * @usage low + * + * @param {EffectCallback} effect The effect callback + * @param {DependencyList} [deps] The dependencies list for the effect + * + * @warning - Use `useCustomCompareEffect` with your own comparator when the comparison rules do not fit + * + * @example + * useDeepEffect(() => console.log("effect"), [user]); + */ +export const useDeepEffect = (effect: EffectCallback, deps?: DependencyList) => { + useCustomCompareEffect(effect, deps, deepEqual); +}; diff --git a/packages/core/src/hooks/useShallowEffect/useShallowEffect.ts b/packages/core/src/hooks/useShallowEffect/useShallowEffect.ts index 26f3c242..db7821a4 100644 --- a/packages/core/src/hooks/useShallowEffect/useShallowEffect.ts +++ b/packages/core/src/hooks/useShallowEffect/useShallowEffect.ts @@ -1,48 +1,26 @@ import type { DependencyList, EffectCallback } from 'react'; -import { useEffect, useRef } from 'react'; +import { shallowEqual } from '@/helpers/shallowEqual/shallowEqual'; -export const deepEqual = (a: any, b: any): boolean => { - if (a === b) return true; - if (a == null || b == null) return a === b; - if (typeof a !== typeof b) return false; - if (typeof a !== 'object') return a === b; - if (Array.isArray(a) !== Array.isArray(b)) return false; +import { useCustomCompareEffect } from '../useCustomCompareEffect/useCustomCompareEffect'; - if (Array.isArray(a)) - return a.length === b.length && a.every((value, index) => deepEqual(value, b[index])); - - const keysA = Object.keys(a); - const keysB = Object.keys(b); - - if (keysA.length !== keysB.length) return false; - - for (const key of keysA) { - if (!keysB.includes(key)) return false; - if (!deepEqual(a[key], b[key])) return false; - } - - return true; -}; +const shallowEqualDeps = (deps: DependencyList, prevDeps: DependencyList) => + deps.length === prevDeps.length && deps.every((dep, index) => shallowEqual(dep, prevDeps[index])); /** * @name useShallowEffect - * @description - Hook that executes an effect only when dependencies change shallowly or deeply + * @description - Hook that executes an effect only when dependencies change shallowly * @category Lifecycle * @usage low * * @param {EffectCallback} effect The effect callback * @param {DependencyList} [deps] The dependencies list for the effect * + * @warning - Use `useCustomCompareEffect` with your own comparator when the comparison rules do not fit + * * @example * useShallowEffect(() => console.log("effect"), [user]); */ export const useShallowEffect = (effect: EffectCallback, deps?: DependencyList) => { - const depsRef = useRef(deps); - - if (!depsRef.current || !deepEqual(deps, depsRef.current)) { - depsRef.current = deps; - } - - useEffect(effect, depsRef.current); + useCustomCompareEffect(effect, deps, shallowEqualDeps); };