From 22ccadf3e35dac1bb20c43df2d8c2b1a5bf6d6fc Mon Sep 17 00:00:00 2001 From: Cheton Wu <447801+cheton@users.noreply.github.com> Date: Sat, 23 Nov 2024 15:40:14 +0800 Subject: [PATCH] feat(utils): add `merge` and `isPlainObject` (#946) * feat(utils): add `deepmerge` and `isPlainObject` * Create tonic-ui-945-utils.md * Update tonic-ui-945-utils.md * feat: rename deepmerge to merge * feat: enhance the `merge` function to support both arrays and plain objects * feat: use a `WeakMap` to track objects and detect circular references for `_deepClone(source)` * test(util): enhance test coverage for the `merge` function --- .changeset/tonic-ui-945-utils.md | 5 + packages/utils/__tests__/index.test.js | 2 + .../utils/src/__tests__/assertion.test.js | 65 ++++-- packages/utils/src/__tests__/index.test.js | 2 + packages/utils/src/__tests__/shared.test.js | 196 +++++++++++++++++- packages/utils/src/assertion.js | 10 + packages/utils/src/shared.js | 60 ++++++ 7 files changed, 315 insertions(+), 25 deletions(-) create mode 100644 .changeset/tonic-ui-945-utils.md diff --git a/.changeset/tonic-ui-945-utils.md b/.changeset/tonic-ui-945-utils.md new file mode 100644 index 0000000000..b88ed82263 --- /dev/null +++ b/.changeset/tonic-ui-945-utils.md @@ -0,0 +1,5 @@ +--- +"@tonic-ui/utils": minor +--- + +feat(utils): add `merge` and `isPlainObject` diff --git a/packages/utils/__tests__/index.test.js b/packages/utils/__tests__/index.test.js index a57334b021..8e57cfb343 100644 --- a/packages/utils/__tests__/index.test.js +++ b/packages/utils/__tests__/index.test.js @@ -10,6 +10,7 @@ test('should match expected exports', () => { 'isNullish', 'isNullOrUndefined', 'isObject', + 'isPlainObject', 'isWhitespace', // dom @@ -36,6 +37,7 @@ test('should match expected exports', () => { 'callAll', 'callEventHandlers', 'dataAttr', + 'merge', 'noop', 'once', 'runIfFn', diff --git a/packages/utils/src/__tests__/assertion.test.js b/packages/utils/src/__tests__/assertion.test.js index b842e588a9..fd64607880 100644 --- a/packages/utils/src/__tests__/assertion.test.js +++ b/packages/utils/src/__tests__/assertion.test.js @@ -1,4 +1,4 @@ -/* eslint-disable */ +import { runInNewContext } from 'node:vm'; import { isBlankString, isEmptyArray, @@ -7,6 +7,7 @@ import { isNullish, isNullOrUndefined, isObject, + isPlainObject, isWhitespace, noop, } from '@tonic-ui/utils/src'; @@ -34,7 +35,6 @@ describe('Check whether the value is a blank string', () => { describe('Check whether the value is an empty array', () => { it('should return true', () => { expect(isEmptyArray([])).toBe(true); - expect(isEmptyArray(new Array())).toBe(true); }); it('should return false', () => { @@ -47,12 +47,8 @@ describe('Check whether the value is an empty array', () => { expect(isEmptyArray(undefined)).toBe(false); expect(isEmptyArray('')).toBe(false); expect(isEmptyArray(' ')).toBe(false); - expect(isEmptyArray(new Boolean())).toBe(false); + expect(isEmptyArray(() => {})).toBe(false); expect(isEmptyArray(new Date())).toBe(false); - expect(isEmptyArray(new Function())).toBe(false); - expect(isEmptyArray(new Number())).toBe(false); - expect(isEmptyArray(new Object())).toBe(false); - expect(isEmptyArray(new String())).toBe(false); expect(isEmptyArray(new RegExp())).toBe(false); }); }); @@ -60,7 +56,6 @@ describe('Check whether the value is an empty array', () => { describe('Check whether the value is an empty object', () => { it('should return true', () => { expect(isEmptyObject({})).toBe(true); - expect(isEmptyObject(new Object())).toBe(true); }); it('should return false', () => { @@ -73,12 +68,8 @@ describe('Check whether the value is an empty object', () => { expect(isEmptyObject(undefined)).toBe(false); expect(isEmptyObject('')).toBe(false); expect(isEmptyObject(' ')).toBe(false); - expect(isEmptyObject(new Array())).toBe(false); - expect(isEmptyObject(new Boolean())).toBe(false); + expect(isEmptyObject(() => {})).toBe(false); expect(isEmptyObject(new Date())).toBe(false); - expect(isEmptyObject(new Function())).toBe(false); - expect(isEmptyObject(new Number())).toBe(false); - expect(isEmptyObject(new String())).toBe(false); expect(isEmptyObject(new RegExp())).toBe(false); }); }); @@ -124,7 +115,6 @@ describe('Check whether the value is an object', () => { it('should return true', () => { expect(isObject({})).toBe(true); expect(isObject(noop)).toBe(true); - expect(isObject(new Object())).toBe(true); }); it('should return false', () => { @@ -138,7 +128,52 @@ describe('Check whether the value is an object', () => { expect(isObject(' ')).toBe(false); }); }); - + +describe('Check whether the value is a plain object', () => { + function Foo(x) { + this.x = x; + } + + function ObjectConstructor() {} + ObjectConstructor.prototype.constructor = Object; + + it('should return true', () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({ foo: true })).toBe(true); + expect(isPlainObject({ constructor: Foo })).toBe(true); + expect(isPlainObject({ valueOf: 0 })).toBe(true); + expect(isPlainObject(Object.create(null))).toBe(true); + expect(isPlainObject(runInNewContext('({})'))).toBe(true); + }); + + it('should return false', () => { + expect(isPlainObject(['foo', 'bar'])).toBe(false); + expect(isPlainObject(new Foo(1))).toBe(false); + expect(isPlainObject(Math)).toBe(false); + expect(isPlainObject(JSON)).toBe(false); + expect(isPlainObject(Atomics)).toBe(false); // eslint-disable-line no-undef + expect(isPlainObject(Error)).toBe(false); + expect(isPlainObject(() => {})).toBe(false); + expect(isPlainObject(/./)).toBe(false); + expect(isPlainObject(null)).toBe(false); + expect(isPlainObject(undefined)).toBe(false); + expect(isPlainObject(Number.NaN)).toBe(false); + expect(isPlainObject('')).toBe(false); + expect(isPlainObject(0)).toBe(false); + expect(isPlainObject(false)).toBe(false); + expect(isPlainObject(new ObjectConstructor())).toBe(false); + expect(isPlainObject(Object.create({}))).toBe(false); + + (function () { + expect(isPlainObject(arguments)).toBe(false); // eslint-disable-line prefer-rest-params + }()); + + const foo = new Foo(); + foo.constructor = Object; + expect(isPlainObject(foo)).toBe(false); + }); +}); + describe('Check whether the value passed is all whitespace', () => { it('should return true', () => { expect(isWhitespace(' ')).toBe(true); diff --git a/packages/utils/src/__tests__/index.test.js b/packages/utils/src/__tests__/index.test.js index cb6d48ed8d..5c6da686cf 100644 --- a/packages/utils/src/__tests__/index.test.js +++ b/packages/utils/src/__tests__/index.test.js @@ -11,6 +11,7 @@ test('should match expected exports', () => { 'isNullish', 'isNullOrUndefined', 'isObject', + 'isPlainObject', 'isWhitespace', // dom @@ -37,6 +38,7 @@ test('should match expected exports', () => { 'callAll', 'callEventHandlers', 'dataAttr', + 'merge', 'noop', 'once', 'runIfFn', diff --git a/packages/utils/src/__tests__/shared.test.js b/packages/utils/src/__tests__/shared.test.js index 46156e13a4..ea20464d65 100644 --- a/packages/utils/src/__tests__/shared.test.js +++ b/packages/utils/src/__tests__/shared.test.js @@ -1,8 +1,10 @@ +import { runInNewContext } from 'node:vm'; import { ariaAttr, callAll, callEventHandlers, dataAttr, + merge, noop, once, runIfFn, @@ -14,19 +16,14 @@ afterEach(() => { jest.resetAllMocks(); }); -describe('ariaAttr / dataAttr', () => { - it('should render correct aria-* and data-* attributes', () => { - const ariaProps = { +describe('ariaAttr', () => { + it('should render correct aria-* attributes', () => { + const ariaAttrs = { 'aria-disabled': ariaAttr(true), - 'data-disabled': dataAttr(true), 'aria-selected': ariaAttr(false), - 'data-selected': dataAttr(false), }; - - expect(ariaProps['aria-disabled']).toBe(true); - expect(ariaProps['data-disabled']).toBe(''); - expect(ariaProps['aria-selected']).toBe(undefined); - expect(ariaProps['data-selected']).toBe(undefined); + expect(ariaAttrs['aria-disabled']).toBe(true); + expect(ariaAttrs['aria-selected']).toBe(undefined); }); }); @@ -78,6 +75,185 @@ describe('callEventHandlers', () => { }); }); +describe('dataAttr', () => { + it('should render correct data-* attributes', () => { + const dataAttrs = { + 'data-disabled': dataAttr(true), + 'data-selected': dataAttr(false), + }; + expect(dataAttrs['data-disabled']).toBe(''); + expect(dataAttrs['data-selected']).toBe(undefined); + }); +}); + +describe('merge', () => { + it('should replace the first array with the second array', () => { + // Second array fully replaces the first array + expect(merge( + [1, 2], + [3, 4, 5], + )).toEqual([3, 4, 5]); + + // Second array replaces corresponding elements of the first array, leaving trailing elements + expect(merge( + [1, 2, 3], + [4, 5], + )).toEqual([4, 5, 3]); + }); + + it('should merge objects and replace array values correctly', () => { + const result = merge( + { arr: [1, 2, { a: 1 }] }, + { arr: [3, 4, { b: 2 }] } + ); + expect(result).toEqual({ + arr: [3, 4, { b: 2 }] + }); + }); + + it('should merge arrays within nested structures', () => { + const result = merge( + { arr: [1, [2, 3], { a: [1, 2] }] }, + { arr: [4, [5, 6], { a: [3, 4] }] } + ); + expect(result).toEqual({ + arr: [4, [5, 6], { a: [3, 4] }] + }); + }); + + it('should handle arrays with objects correctly', () => { + const target = { + items: [ + { id: 1, value: 'old' }, + { id: 2, nested: { prop: 'old' } } + ] + }; + const source = { + items: [ + { id: 1, value: 'new' }, + { id: 2, nested: { prop: 'new' } } + ] + }; + const result = merge(target, source); + expect(result.items[0].value).toBe('new'); + expect(result.items[1].nested.prop).toBe('new'); + }); + + it('should handle array mutation correctly with clone option', () => { + const target = { arr: [1, { a: 1 }] }; + const source = { arr: [2, { b: 2 }] }; + const result = merge(target, source, { clone: true }); + result.arr[1].b = 3; + expect(source.arr[1].b).toBe(2); + expect(result.arr[1].b).toBe(3); + }); + + it('should handle circular references in arrays', () => { + const target = { arr: [] }; + target.arr.push(target); + const source = { arr: [{ value: 'test' }] }; + const result = merge(target, source); + expect(result.arr[0].value).toBe('test'); + }); + + it('should not be subject to prototype pollution via __proto__', () => { + const result = merge( + {}, + JSON.parse('{ "myProperty": "a", "__proto__" : { "isAdmin" : true } }'), + { + clone: false, + } + ); + expect(result.__proto__).toHaveProperty('isAdmin'); // eslint-disable-line no-proto + expect({}).not.toHaveProperty('isAdmin'); + }); + + it('should not be subject to prototype pollution via constructor', () => { + const result = merge( + {}, + JSON.parse('{ "myProperty": "a", "constructor" : { "prototype": { "isAdmin" : true } } }'), + { + clone: true, + } + ); + expect(result.constructor.prototype).toHaveProperty('isAdmin'); + expect({}).not.toHaveProperty('isAdmin'); + }); + + it('should not be subject to prototype pollution via prototype', () => { + const result = merge( + {}, + JSON.parse('{ "myProperty": "a", "prototype": { "isAdmin" : true } }'), + { + clone: false, + } + ); + expect(result.prototype).toHaveProperty('isAdmin'); + expect({}).not.toHaveProperty('isAdmin'); + }); + + it('should appropriately copy the fields without prototype pollution', () => { + const result = merge( + {}, + JSON.parse('{ "myProperty": "a", "__proto__" : { "isAdmin" : true } }') + ); + expect(result.__proto__).toHaveProperty('isAdmin'); // eslint-disable-line no-proto + expect({}).not.toHaveProperty('isAdmin'); + }); + + it('should merge objects across realms', function test() { + if (!/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + const vmObject = runInNewContext('({hello: "realm"})'); + const result = merge({ hello: 'original' }, vmObject); + expect(result.hello).toBe('realm'); + }); + + it('should not merge HTML elements', () => { + const element = document.createElement('div'); + const element2 = document.createElement('div'); + + const result = merge({ element }, { element: element2 }); + + expect(result.element).toBe(element2); + }); + + it('should reset source when target is undefined', () => { + const result = merge( + { + '&.disabled': { + color: 'red', + }, + }, + { + '&.disabled': undefined, + } + ); + expect(result).toEqual({ + '&.disabled': undefined, + }); + }); + + it('should merge keys that do not exist in source', () => { + const result = merge({ foo: { baz: 'test' } }, { foo: { bar: 'test' }, bar: 'test' }); + expect(result).toEqual({ + foo: { baz: 'test', bar: 'test' }, + bar: 'test', + }); + }); + + it('should deep clone source key object if target key does not exist', () => { + const foo = { foo: { baz: 'test' } }; + const bar = {}; + const result = merge(bar, foo); + expect(result).toEqual({ foo: { baz: 'test' } }); + result.foo.baz = 'new test'; + expect(result).toEqual({ foo: { baz: 'new test' } }); + expect(foo).toEqual({ foo: { baz: 'test' } }); + }); +}); + describe('runIfFn', () => { it('should run function if function or else return value', () => { expect(runIfFn(() => 2)).toStrictEqual(2); diff --git a/packages/utils/src/assertion.js b/packages/utils/src/assertion.js index 16835760f6..bf93302ae6 100644 --- a/packages/utils/src/assertion.js +++ b/packages/utils/src/assertion.js @@ -30,6 +30,16 @@ export const isObject = (value) => { return !isNullish(value) && (typeof value === 'object' || typeof value === 'function') && !Array.isArray(value); }; +// https://github.com/sindresorhus/is-plain-obj/blob/main/index.js +export const isPlainObject = (value) => { + if (typeof value !== 'object' || value === null) { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return (prototype === null || prototype === Object.prototype || Object.getPrototypeOf(prototype) === null) && !(Symbol.toStringTag in value) && !(Symbol.iterator in value); +}; + export const isWhitespace = (value) => { // @see https://github.com/jonschlinkert/whitespace-regex // eslint-disable-next-line no-control-regex diff --git a/packages/utils/src/shared.js b/packages/utils/src/shared.js index 24da7d2269..30f66b2243 100644 --- a/packages/utils/src/shared.js +++ b/packages/utils/src/shared.js @@ -1,4 +1,5 @@ import { ensureArray, ensureBoolean, ensureString } from 'ensure-type'; +import { isPlainObject } from './assertion'; const _joinWords = (words) => { words = ensureArray(words); @@ -14,6 +15,35 @@ const _joinWords = (words) => { return `'${words.slice(0, -1).join('\', \'')}', and '${words.slice(-1)}'`; }; +const _deepClone = (source, seen = new WeakMap()) => { + // Use a `WeakMap` to track objects and detect circular references. + // If the object has been cloned before, return the cached cloned version. + if (seen.has(source)) { + return seen.get(source); + } + + if (Array.isArray(source)) { + const clonedArray = []; + seen.set(source, clonedArray); + for (let i = 0; i < source.length; ++i) { + clonedArray[i] = _deepClone(source[i], seen); + } + return clonedArray; + } + + if (isPlainObject(source)) { + const clonedObject = {}; + seen.set(source, clonedObject); + for (const [key, value] of Object.entries(source)) { + clonedObject[key] = _deepClone(value, seen); + } + return clonedObject; + } + + // For primitive values and other types, return as is + return source; +}; + export const ariaAttr = (condition) => { return ensureBoolean(condition) ? true : undefined; }; @@ -39,6 +69,36 @@ export const dataAttr = (condition) => { return condition ? '' : undefined; }; +export const merge = (target, source, options = { clone: true }) => { + // Merge arrays + if (Array.isArray(target) && Array.isArray(source)) { + const output = options.clone ? [...target] : target; + source.forEach((item, index) => { + if (isPlainObject(item) && isPlainObject(output[index])) { + output[index] = merge(output[index], item, options); + } else { + output[index] = options.clone ? _deepClone(item) : item; + } + }); + return output; + } + + // Merge plain objects + if (isPlainObject(target) && isPlainObject(source)) { + const output = options.clone ? { ...target } : target; + for (const [key, value] of Object.entries(source)) { + if (isPlainObject(value) && Object.prototype.hasOwnProperty.call(output, key) && isPlainObject(output[key])) { + output[key] = merge(output[key], value, options); + } else { + output[key] = options.clone ? _deepClone(value) : value; + } + } + return output; + } + + return options.clone ? _deepClone(source) : source; +}; + export const noop = () => {}; export const once = (fn) => {