Skip to content

Commit

Permalink
feat(utils): add deepmerge and isPlainObject
Browse files Browse the repository at this point in the history
  • Loading branch information
cheton committed Nov 12, 2024
1 parent 0ed3abc commit 5136f4e
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 25 deletions.
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',
'deepmerge',
'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',
'deepmerge',
'noop',
'once',
'runIfFn',
Expand Down
136 changes: 126 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,
deepmerge,
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,125 @@ 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('deepmerge', () => {
it('should not be subject to prototype pollution via __proto__', () => {
const result = deepmerge(
{},
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 = deepmerge(
{},
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 = deepmerge(
{},
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 = deepmerge(
{},
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 = deepmerge({ 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 = deepmerge({ element }, { element: element2 });

expect(result.element).toBe(element2);
});

it('should reset source when target is undefined', () => {
const result = deepmerge(
{
'&.disabled': {
color: 'red',
},
},
{
'&.disabled': undefined,
}
);
expect(result).toEqual({
'&.disabled': undefined,
});
});

it('should merge keys that do not exist in source', () => {
const result = deepmerge({ 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 = deepmerge(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
};

export const isWhitespace = (value) => {
// @see https://github.com/jonschlinkert/whitespace-regex
// eslint-disable-next-line no-control-regex
Expand Down
37 changes: 37 additions & 0 deletions packages/utils/src/shared.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ensureArray, ensureBoolean, ensureString } from 'ensure-type';
import { isPlainObject } from './assertion';

const _joinWords = (words) => {
words = ensureArray(words);
Expand All @@ -14,6 +15,20 @@ const _joinWords = (words) => {
return `'${words.slice(0, -1).join('\', \'')}', and '${words.slice(-1)}'`;
};

const _deepClone = (source) => {
if (!isPlainObject(source)) {
return source;
}

const output = {};

Object.keys(source).forEach((key) => {
output[key] = _deepClone(source[key]);
});

return output;
};

export const ariaAttr = (condition) => {
return ensureBoolean(condition) ? true : undefined;
};
Expand All @@ -39,6 +54,28 @@ export const dataAttr = (condition) => {
return condition ? '' : undefined;
};

export const deepmerge = (target, source, options = { clone: true }) => {
const output = options.clone ? { ...target } : target;

if (isPlainObject(target) && isPlainObject(source)) {
Object.keys(source).forEach((key) => {
if (
isPlainObject(source[key]) &&
Object.prototype.hasOwnProperty.call(target, key) &&
isPlainObject(target[key])
) {
output[key] = deepmerge(target[key], source[key], options);
} else if (options.clone) {
output[key] = isPlainObject(source[key]) ? _deepClone(source[key]) : source[key];
} else {
output[key] = source[key];
}
});
}

return output;
};

export const noop = () => {};

export const once = (fn) => {
Expand Down

0 comments on commit 5136f4e

Please sign in to comment.