diff --git a/packages/helpers/src/is.js b/packages/helpers/src/is.js index 0a873a5e..6dfd7038 100644 --- a/packages/helpers/src/is.js +++ b/packages/helpers/src/is.js @@ -26,3 +26,14 @@ export const isObject = (value) => value !== null && typeof value === 'object' & * @returns {boolean} True if the value is undefined, false otherwise. */ export const isUndefined = (value) => typeof value === 'undefined'; + +/** + * Checks if the given value is a plain object (object literal). + * @param {*} obj - The value to check. + * @returns {boolean} True if the value is a plain object, false otherwise. + */ +export const isPlainObject = (obj) => { + if (Object.prototype.toString.call(obj) !== '[object Object]') return false; + const proto = Object.getPrototypeOf(obj); + return proto === null || proto === Object.prototype; +}; diff --git a/packages/helpers/src/is.test.js b/packages/helpers/src/is.test.js index f61c4a15..986e0221 100644 --- a/packages/helpers/src/is.test.js +++ b/packages/helpers/src/is.test.js @@ -1,4 +1,4 @@ -import { isArray, isEqual, isObject, isUndefined } from './index.js'; +import { isArray, isEqual, isObject, isPlainObject, isUndefined } from './index.js'; describe('helpers', () => { describe('is', () => { @@ -60,5 +60,54 @@ describe('helpers', () => { expect(isUndefined('')).toBe(false); }); }); + + describe('isPlainObject', () => { + it('should return true for plain objects', () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({ foo: 'bar' })).toBe(true); + expect(isPlainObject(Object.create(null))).toBe(true); + expect(isPlainObject(Object.create(Object.prototype))).toBe(true); + expect(isPlainObject(Object.assign({}, { a: 1 }))).toBe(true); + }); + + it('should return false for primitives', () => { + expect(isPlainObject(true)).toBe(false); + expect(isPlainObject(undefined)).toBe(false); + expect(isPlainObject(1)).toBe(false); + expect(isPlainObject('string')).toBe(false); + expect(isPlainObject(Symbol('s'))).toBe(false); + }); + + it('should return false for `null`', () => { + expect(isPlainObject(null)).toBe(false); + }); + + it('should return false for functions and instances', () => { + function Foo() { + this.abc = {}; + } + + expect(isPlainObject(Foo)).toBe(false); + expect(isPlainObject(new Foo())).toBe(false); + expect(isPlainObject(() => {})).toBe(false); + expect(isPlainObject(function () {})).toBe(false); + }); + + it('should return false for arrays', () => { + expect(isPlainObject([])).toBe(false); + expect(isPlainObject([1, 2, 3])).toBe(false); + }); + + it('should return false for built-in objects', () => { + expect(isPlainObject(new Date())).toBe(false); + expect(isPlainObject(new Map())).toBe(false); + expect(isPlainObject(new Set())).toBe(false); + expect(isPlainObject(/abc/)).toBe(false); + }); + + it('should return false for objects created with Object.create and a custom prototype', () => { + expect(isPlainObject(Object.create({}))).toBe(false); + }); + }); }); }); diff --git a/packages/helpers/src/merge.js b/packages/helpers/src/merge.js index f977eed9..7df2c9e9 100644 --- a/packages/helpers/src/merge.js +++ b/packages/helpers/src/merge.js @@ -1,4 +1,4 @@ -import { isEqual, isObject, isUndefined } from './is.js'; +import { isEqual, isPlainObject, isUndefined } from './is.js'; /** * Deeply merges two objects. @@ -11,7 +11,7 @@ export function deepMerge(target, source) { return target; } - if (!isObject(source) || !isObject(target) || isEqual(target, source)) { + if (!isPlainObject(source) || !isPlainObject(target) || isEqual(target, source)) { return source; } diff --git a/packages/helpers/src/merge.test.js b/packages/helpers/src/merge.test.js index d6a00bb8..aaf38689 100644 --- a/packages/helpers/src/merge.test.js +++ b/packages/helpers/src/merge.test.js @@ -85,5 +85,95 @@ describe('helpers', () => { expect(result).toStrictEqual({ a: { b: { c: 1, d: 2 } } }); }); + + describe('Built-in objects', () => { + it('should overwrite RegExp objects instead of merging', () => { + const target = { r: /abc/i }; + const source = { r: /def/g }; + const result = deepMerge(target, source); + + expect(result.r).toBeInstanceOf(RegExp); + expect(result.r).toStrictEqual(/def/g); + expect(result.r).toBe(source.r); + }); + + it('should overwrite Map objects instead of merging', () => { + const map1 = new Map([['a', 1]]); + const map2 = new Map([['b', 2]]); + const target = { m: map1 }; + const source = { m: map2 }; + const result = deepMerge(target, source); + + expect(result.m).toBeInstanceOf(Map); + expect(Array.from(result.m.entries())).toStrictEqual([['b', 2]]); + expect(result.m).toBe(map2); + }); + + it('should overwrite Set objects instead of merging', () => { + const set1 = new Set([1, 2]); + const set2 = new Set([3, 4]); + const target = { s: set1 }; + const source = { s: set2 }; + const result = deepMerge(target, source); + + expect(result.s).toBeInstanceOf(Set); + expect(Array.from(result.s)).toStrictEqual([3, 4]); + expect(result.s).toBe(set2); + }); + + describe('Date objects', () => { + it('should overwrite Date objects instead of merging', () => { + const date1 = new Date('2020-01-01T00:00:00Z'); + const date2 = new Date('2021-01-01T00:00:00Z'); + const target = { a: date1 }; + const source = { a: date2 }; + const result = deepMerge(target, source); + + expect(result.a).toBeInstanceOf(Date); + expect(result.a).toStrictEqual(date2); + expect(result.a).toBe(date2); + }); + + it('should handle Date in nested objects', () => { + const date1 = new Date('2020-01-01T00:00:00Z'); + const date2 = new Date('2021-01-01T00:00:00Z'); + const target = { nested: { d: date1 } }; + const source = { nested: { d: date2 } }; + const result = deepMerge(target, source); + + expect(result.nested.d).toBeInstanceOf(Date); + expect(result.nested.d).toStrictEqual(date2); + expect(result.nested.d).toBe(date2); + }); + + it('should return the source Date reference when both are Date objects with the same value', () => { + const date = '2022-05-05T12:00:00Z'; + const target = { a: new Date(date) }; + const source = { a: new Date(date) }; + const result = deepMerge(target, source); + + expect(result.a).toBe(source.a); + }); + + it('should overwrite Date object with primitive value', () => { + const date = new Date('2020-01-01T00:00:00Z'); + const target = { a: date }; + const source = { a: '2021-01-01' }; + const result = deepMerge(target, source); + + expect(result).toStrictEqual({ a: '2021-01-01' }); + }); + + it('should overwrite primitive value with Date object', () => { + const date = new Date('2020-01-01T00:00:00Z'); + const target = { a: '2021-01-01' }; + const source = { a: date }; + const result = deepMerge(target, source); + + expect(result).toStrictEqual({ a: date }); + expect(result.a).toBe(date); + }); + }); + }); }); });