Skip to content

Commit

Permalink
feat(utils): add merge and isPlainObject (#946)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
cheton authored Nov 23, 2024
1 parent 0b8452b commit 22ccadf
Show file tree
Hide file tree
Showing 7 changed files with 315 additions and 25 deletions.
5 changes: 5 additions & 0 deletions .changeset/tonic-ui-945-utils.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tonic-ui/utils": minor
---

feat(utils): add `merge` and `isPlainObject`
2 changes: 2 additions & 0 deletions packages/utils/__tests__/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ test('should match expected exports', () => {
'isNullish',
'isNullOrUndefined',
'isObject',
'isPlainObject',
'isWhitespace',

// dom
Expand All @@ -36,6 +37,7 @@ test('should match expected exports', () => {
'callAll',
'callEventHandlers',
'dataAttr',
'merge',
'noop',
'once',
'runIfFn',
Expand Down
65 changes: 50 additions & 15 deletions packages/utils/src/__tests__/assertion.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* eslint-disable */
import { runInNewContext } from 'node:vm';
import {
isBlankString,
isEmptyArray,
Expand All @@ -7,6 +7,7 @@ import {
isNullish,
isNullOrUndefined,
isObject,
isPlainObject,
isWhitespace,
noop,
} from '@tonic-ui/utils/src';
Expand Down Expand Up @@ -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', () => {
Expand All @@ -47,20 +47,15 @@ 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);
});
});

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', () => {
Expand All @@ -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);
});
});
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/utils/src/__tests__/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ test('should match expected exports', () => {
'isNullish',
'isNullOrUndefined',
'isObject',
'isPlainObject',
'isWhitespace',

// dom
Expand All @@ -37,6 +38,7 @@ test('should match expected exports', () => {
'callAll',
'callEventHandlers',
'dataAttr',
'merge',
'noop',
'once',
'runIfFn',
Expand Down
196 changes: 186 additions & 10 deletions packages/utils/src/__tests__/shared.test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { runInNewContext } from 'node:vm';
import {
ariaAttr,
callAll,
callEventHandlers,
dataAttr,
merge,
noop,
once,
runIfFn,
Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions packages/utils/src/assertion.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Check warning on line 40 in packages/utils/src/assertion.js

View workflow job for this annotation

GitHub Actions / build

This line has a length of 174. Maximum allowed is 160

Check warning on line 40 in packages/utils/src/assertion.js

View workflow job for this annotation

GitHub Actions / build

This line has a length of 174. Maximum allowed is 160
};

export const isWhitespace = (value) => {
// @see https://github.com/jonschlinkert/whitespace-regex
// eslint-disable-next-line no-control-regex
Expand Down
Loading

0 comments on commit 22ccadf

Please sign in to comment.