New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: improve deep merge function #376
Conversation
@@ -17,4 +24,26 @@ describe('deepMerge', () => { | |||
const ret = deepMerge({}, target, source1, source2); | |||
expect(ret).toEqual({foo: 3, bar: 2, baz: 3, quux: 4}); | |||
}); | |||
|
|||
test.only('should forcefully merge very right source for heavy objects to avoid stack overflow and possible circular dependency', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove test.only?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok
src/utils/deep-merge.js
Outdated
@@ -25,5 +35,5 @@ export default function deepMerge( | |||
} | |||
|
|||
function isCloneable(obj: mixed) { | |||
return Array.isArray(obj) || {}.toString.call(obj) == '[object Object]'; | |||
return typeof obj === 'object' || typeof obj === 'function'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why typeof obj === 'function'
? seems like you wouldn't want to call deepMerge with a function as one of the arguments.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
may be I didn't get it, but actually function is object and I want it to be called in deepMerge as a normal object
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if you check where isClonable
is used, it's doing this:
if (isCloneable(value)) {
target[key] = _deepMerge.apply(this, [target[key] || {}, value])
}
Imagine that value
is a function like () => true
, this means you're trying to make a call like:
deepMerge(obj, () => true)
this makes no sense. you should only be passing object/null/undefined to deepMerge.
What was wrong with the initial implementation of this method? It's what was used in just-extend
which is battle tested in countless production apps. We should just keep that.
src/utils/deep-merge.js
Outdated
@@ -25,5 +35,5 @@ export default function deepMerge( | |||
} | |||
|
|||
function isCloneable(obj: mixed) { | |||
return Array.isArray(obj) || {}.toString.call(obj) == '[object Object]'; | |||
return typeof obj === 'object' || typeof obj === 'function'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do you consider functions and null
cloneable?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i did exclude null
, but function is an object. Lots of frameworks starting with jQuery use it as object with props, especially it's common if it's a function-constructor
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you point out where you exclude the null
? How are you intended to deepMerge
functions?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just pushed fresh code. Function merges the same way as any other object. We go through it's props and add them to target function, which is the prop itself of parent object
target?: ?{}, | ||
...sources: Array<null | ?{}> | ||
): {} { | ||
const MAX_DEPTH_OF_CHECK = 10; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where the number 10
comes from?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we can replace it with any other likable number. When I did research about redux data-objects in some project, the most optimal size was 6, deeper there was "trash" that sometimes appears in data-object inaccurately mixed in. Other number is 40 as it's a const for stack overflow in JS runtime. I can put here any reasonable number if any
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Left a couple comments to address
7859e05
to
3341150
Compare
can we simply use https://www.npmjs.com/package/lodash.merge or https://www.npmjs.com/package/just-extend instead of managing our own? |
3341150
to
7ec58b5
Compare
@gergelyke this PR is only to improve it, the method was added long before. However, to compare just.extend is lighter, but still contains many code, which we don't need, whereas we need only extend function, which is almost the same as ours (seems to be previous version of it) |
Yeah, I know it's been there - my question is if it is worth maintaining it, while we could just pull something on. |
@gergelyke that depends on strategy we follow. If we try to avoid extra dependencies, and take into account those drawbacks I mentioned, than we should maintain our own. |
what are those drawbacks? how many KBs of extra we are talking about in comparison to out implementation? |
@gergelyke size increased by 50KB. For others need more analysis, but this merge may be slower by performance due to more complex logic. Our implementation is 2KB size |
Can you help me understand how could the size increase by 50Ks, if the whole file of just-extend is 1.5K? |
We can uses |
@gergelyke this is for |
7ec58b5
to
d5e0106
Compare
@blackswanny what do you mean by blindly extends ? (also use just-clone as @nadiia says, it is even smaller) |
@angus-c lodash has some safe way of merging. For instance, it checks if objects are the same and does not proceed. Also in this version we added stack overflow prevention, which might be common case in misuse of redux or other data frameworks. |
@blackswanny I don't see lodash merge doing that check
it also doesn't prevent property clobbering
It does prevent circular reference errors although e.g. the following example also works in just-extend (maybe some circular references don't)
Full tradeoffs are here https://github.com/angus-c/just/blob/master/TRADEOFFS.md |
I added this deepMerge implementation and basically copied the code directly out of Regarding While having protection against stack overflow due to cyclical references is nice, I'm wondering if we need to add it immediately–maybe we should wait until a customer actually runs into this issue and reports it. Also, if we flow type |
@angus-c I just debugged this example and if for circular dependencies the next example causes stack overflow. Lodash has smarter way to prevent it, but it's more complex. const b = {};
const c = {d: b};
b.a = c;
extend(true, b, c); @schnerd yes, I may exclude it, but may at least leave error, so customer will find the bug easier. However, i remember this is pretty common case. |
@blackswanny Ok, I can add a patch for circular dependency to just-extend for that over the weekend. I couldn't reproduce the object equality check in lodash.merge @schnerd BTW just-extend is 369 bytes bundled and minified so I don't think rolling our own will help much with size :) https://bundlephobia.com/result?p=just-extend@3.0.0 |
My bad about mentioning the |
@schnerd @nadiia Here is example. Old merge and new merge. As you see. Property 'bar' is not saved in old version as all props are eliminated by new function. We may consider that it's ok to override all props of function, but I am not sure that it's true for all frameworks and object using function as high-order objects const A = () => {};
A.bar = 'bar';
const B = () => {};
B.foo = 'foo';
const merged = deepMerge({override: A}, {override: B}); |
40fc1b5
to
d1c0635
Compare
@blackswanny For your example, i'd argue that the new merge semantics in this PR are incorrect. If A and B are components, you would want the deepMerge to return B, not A with the properties of B. While you can assign properties to a function in javascript, I'm not sure that means we have to treat it like an object. This deepMerge helper should only be concerned with merging POJOs. |
@schnerd I just fixed that. Now it returns |
d1c0635
to
0e4418f
Compare
I removed check for functions |
@schnerd yes, we can still use below code for custom height overrides={{
DropDown: {
style: {
maxHeight: '100px',
}
}
}} |
0e4418f
to
862a2e4
Compare
improve deep merge function to avoid stack overflow and possible circular dependency