Skip to content

Commit

Permalink
fix(helpers/mergedeep): fix potential cases when source or target obe…
Browse files Browse the repository at this point in the history
…cts can be mutated (#476)

fix: cover immutability of objects
  • Loading branch information
yurisldk authored Dec 9, 2022
1 parent 491e2f6 commit 765fedb
Show file tree
Hide file tree
Showing 3 changed files with 101 additions and 56 deletions.
2 changes: 1 addition & 1 deletion src/lib/components/Flowbite/Flowbite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const Flowbite: FC<FlowbiteProps> = ({ children, theme = {} }) => {
const { theme: customTheme = {}, dark, usePreferences = true } = theme;
const [mode, setMode, toggleMode] = useThemeMode(usePreferences);

const mergedTheme = mergeDeep<FlowbiteTheme>(defaultTheme, customTheme);
const mergedTheme = mergeDeep(defaultTheme, customTheme);

useEffect(() => {
if (dark) {
Expand Down
92 changes: 64 additions & 28 deletions src/lib/helpers/mergeDeep.spec.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,73 @@
import { describe, expect, it } from 'vitest';
import { mergeDeep } from './mergeDeep';

describe.concurrent('Helper / mergeDeep (Deeply merge two objects)', () => {
it('should use the overriding value given an identical key in both inputs', () => {
const defaultTheme = {
base: 'base',
content: {
base: 'content',
},
flush: {
off: 'no-flush',
on: 'flush',
},
describe('Helper / mergeDeep (Deeply merge two objects)', () => {
it('should merge keys that do not exist in target', () => {
const target = {};
const source = { foo: 'bar' };

const result = mergeDeep(target, source);

expect(result).to.deep.equal({ foo: 'bar' });
});

it('should merge keys that do not exist in source', () => {
const target = { foo: 'bar' };
const source = {};

const result = mergeDeep(target, source);

expect(result).to.deep.equal({ foo: 'bar' });
});

it('should override target key if source key is identical', () => {
const target = { foo: { bar: 'baz' } };
const source = { foo: { bar: 'foobar' } };

const result = mergeDeep(target, source);

expect(result).to.deep.equal({ foo: { bar: 'foobar' } });
});

it('should merge keys and do not mutate target and source', () => {
const target = {
foo: { a: 1, b: { c: 2, f: { g: 3 } } },
baz: 5,
};
const overrides = {
content: {
base: 'new-content',
},
flush: {
off: 'new-no-flush',
on: 'new-flush',
},

const source = {
foo: { b: { c: 3, d: 3 } },
bar: { a: 1 },
};

expect(mergeDeep(defaultTheme, overrides)).toEqual({
base: 'base',
content: {
base: 'new-content',
},
flush: {
off: 'new-no-flush',
on: 'new-flush',
},
const result = mergeDeep(target, source);

expect(result).to.deep.equal({ foo: { a: 1, b: { c: 3, d: 3, f: { g: 3 } } }, baz: 5, bar: { a: 1 } });

expect(target).to.deep.equal({
foo: { a: 1, b: { c: 2, f: { g: 3 } } },
baz: 5,
});

expect(source).to.deep.equal({
foo: { b: { c: 3, d: 3 } },
bar: { a: 1 },
});

result.foo.b.c = 97;
result.baz = 98;
result.bar.a = 99;

expect(result).to.deep.equal({ foo: { a: 1, b: { c: 97, d: 3, f: { g: 3 } } }, baz: 98, bar: { a: 99 } });

expect(target).to.deep.equal({
foo: { a: 1, b: { c: 2, f: { g: 3 } } },
baz: 5,
});

expect(source).to.deep.equal({
foo: { b: { c: 3, d: 3 } },
bar: { a: 1 },
});
});
});
63 changes: 36 additions & 27 deletions src/lib/helpers/mergeDeep.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,48 @@
// source: https://stackoverflow.com/questions/27936772/how-to-deep-merge-instead-of-shallow-merge

import { DeepPartial } from '../components';

/**
* Simple object check.
* Check if provided parameter is plain object
* @param item
* @returns {boolean}
* @returns boolean
*/
export function isObject(item: unknown) {
return item && typeof item === 'object' && !Array.isArray(item);
function isObject(item: unknown): item is Record<string, unknown> {
return item !== null && typeof item === 'object' && item.constructor === Object;
}

function cloneDeep<T>(source: T) {
if (!isObject(source)) {
return source;
}

const output = { ...source };

Object.keys(source).forEach((key) => {
(output as Record<string, unknown>)[key] = cloneDeep(source[key]);
});

return output;
}

/**
* Deep merge two objects with deep copy of the target object.
* @param target
* @param ...sources
* Merge and deep copy the values of all of the enumerable own properties of target object from source object to a new object
* @param target The target object to get properties from.
* @param source The source object from which to copy properties.
* @return A new merged and deep copied object.
*/
export function mergeDeep<T extends Record<string, unknown>>(target: T, ...sources: DeepPartial<T>[]): T {
if (!sources.length) return target;
const source = sources.shift();
const output = { ...target };

if (isObject(target) && isObject(source)) {
for (const key in source) {
if (isObject(source[key])) {
if (!target[key]) Object.assign(output, { [key]: {} });
(output[key] as Record<string, unknown>) = mergeDeep(
target[key] as Record<string, unknown>,
source[key] as Record<string, unknown>,
);
export function mergeDeep<T extends object, S extends object>(target: T, source: S): T & S {
if (isObject(source) && Object.keys(source).length === 0) {
return cloneDeep({ ...target, ...source });
}

let output = { ...target, ...source };

if (isObject(source) && isObject(target)) {
Object.keys(source).forEach((key) => {
if (isObject(source[key]) && key in target && isObject(target[key])) {
(output as Record<string, unknown>)[key] = mergeDeep(target[key] as object, source[key] as object);
} else {
Object.assign(output, { [key]: source[key] });
(output as Record<string, unknown>)[key] = isObject(source[key]) ? cloneDeep(source[key]) : source[key];
}
}
});
}

return mergeDeep(output, ...sources);
return output;
}

0 comments on commit 765fedb

Please sign in to comment.