Skip to content

Commit 67ec94e

Browse files
committed
New: Deep-merge props by default
1 parent 6f33a47 commit 67ec94e

8 files changed

Lines changed: 141 additions & 80 deletions

File tree

README.md

Lines changed: 22 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,9 @@ export const { ScreenClassProvider, responsive } = createResponsiveSystem({
238238
});
239239
```
240240

241-
## Custom Merge Function
241+
### Merging
242242

243-
By default, Responsive System will apply any screen-size-specific overrides right on top of your base props, but sometimes you need more control over how your overrides are applied.
243+
Overrides are applied via [deepmerge](https://www.npmjs.com/package/deepmerge) which means that you can easily apply overrides in arbitrarily-nested objects.
244244

245245
Let's say you've got a component that accepts an `object` as a prop. The intrinsic `style` prop is a great example!
246246

@@ -264,50 +264,34 @@ And we can override the style on `small` screens.
264264
/>
265265
```
266266

267-
In this case, we might want to override the `backgroundColor` but to leave the `height` alone. But, by default the `small.style` is going to completely override the base `style` and the result on small screens will effectively be:
267+
A naive override algorithm would simply replace the base `style` with `small.style` and the result would be `style = { backgroundColor: 'blue' }`.
268268

269-
```jsx
270-
// no height! Bummer...
271-
<div style={{ backgroundColor: 'blue' }}>
272-
```
269+
However, by utilizing `deepmerge` we get `style = { height: 100, backgroundColor: 'blue' }` which is what we'd expect.
273270

274-
We can fix this by providing a `function` as a prop rather than an object. The function will be invoked with the base props as an argument so that you can have full control over how the overrides are handled.
271+
#### Arrays
275272

276-
```js
277-
<ResponsiveDiv
278-
style={{ height: 100, backgroundColor: 'black' }}
279-
small={(baseProps) => {
280-
return {
281-
...baseProps,
282-
style: { ...baseProps.style, backgroundColor: 'blue' },
283-
};
284-
}}
285-
/>
286-
```
273+
Merging arrays is tricky, but we've chosen a method that seems like a sane default. If there's a need for customization of the array-merge algorithm, we could support that in a future release.
287274

288-
And voila! Your overrides are applied on your terms.
275+
For now, the behavior is to treat them like we treat objects. That is, if the base array defines an item at `[0]` and the override array defines an item at `[0]`, the `override[0]` will be merged on top of `base[0]`. Here are a few examples:
289276

290-
### Optimizing
277+
```txt
278+
base [1, 2, 3, 4]
279+
override [5, 6, 7]
280+
result [5, 6, 7, 4]
281+
```
291282

292-
Pro tip: if you've got a bunch of screen classes that all need to perform custom overrides in the same way, you can create a helper function.
283+
```txt
284+
base [1, 2, 3]
285+
override [5, 6, 7, 8]
286+
result [5, 6, 7, 8]
287+
```
293288

294-
```jsx
295-
function makeOverrideFn(overrides) {
296-
// function that returns a function!
297-
return (baseProps) => {
298-
return {
299-
...baseProps,
300-
style: { ...baseProps.style, ...overrides },
301-
};
302-
};
303-
}
289+
Objects inside of an array are merged too! Objects and arrays can be nested arbitrarily deep, but keep in mind that merging deeply-nested structures will require our merge algorithm to do more work which could impact performance. It's best to keep your props as shallow as possible.
304290

305-
<ResponsiveDiv
306-
style={{ height: 100, backgroundColor: 'black' }}
307-
small={makeOverrideFn({ backgroundColor: 'blue' })}
308-
medium={makeOverrideFn({ backgroundColor: 'green' })}
309-
large={makeOverrideFn({ backgroundColor: 'purple' })}
310-
/>;
291+
```txt
292+
base [{ a: 'alpha' }, 1]
293+
override [{ b: 'bravo' }, 2]
294+
result [{ a: 'alpha', b: 'bravo' }, 2]
311295
```
312296

313297
## Goodies

examples/gatsby/yarn.lock

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3629,7 +3629,7 @@ deep-is@~0.1.3:
36293629
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
36303630
integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
36313631

3632-
deepmerge@^4.0.0:
3632+
deepmerge@^4.0.0, deepmerge@^4.2.2:
36333633
version "4.2.2"
36343634
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
36353635
integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
@@ -9675,10 +9675,10 @@ react-reconciler@^0.21.0:
96759675
prop-types "^15.6.2"
96769676
scheduler "^0.15.0"
96779677

9678-
react-responsive-system@^0.4.1:
9679-
version "0.4.1"
9680-
resolved "https://registry.yarnpkg.com/react-responsive-system/-/react-responsive-system-0.4.1.tgz#59fa94ea20e2c8e48bb28f7fc3f236a5dba9db2e"
9681-
integrity sha512-jq6V0XWXlUGZhlANGpk0V9XDiYvYaNSvDcVsA0A5fLfTfOI0jCPl1/VjqSq5ikCaVO9nbqtcATD+z8YUSbzCdA==
9678+
react-responsive-system@../../:
9679+
version "0.7.0"
9680+
dependencies:
9681+
deepmerge "^4.2.2"
96829682

96839683
react-side-effect@^1.1.0:
96849684
version "1.1.5"

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,7 @@
4444
"tslib": "^1.10.0",
4545
"typescript": "^3.7.2"
4646
},
47-
"dependencies": {}
47+
"dependencies": {
48+
"deepmerge": "^4.2.2"
49+
}
4850
}

src/index.tsx

Lines changed: 4 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import * as React from 'react';
2+
import { merge } from './merge';
3+
import { omit } from './omit';
24

35
//
46
// ─── HELPERS ────────────────────────────────────────────────────────────────────
@@ -7,34 +9,6 @@ import * as React from 'react';
79
// keep track of whether or not we have access to `window` (so that we don't crash during e.g. server-side rendering)
810
const windowExists = typeof window === 'object';
911

10-
/**
11-
* Omits the given keys from the given object
12-
*/
13-
export default function omit<T extends { [key: string]: any }, K extends keyof T>(
14-
obj: T,
15-
omittedKeys: K[],
16-
): Omit<T, K> {
17-
// TypeScript assigns the return type, string[] - we are asserting that the return type keyof T
18-
const allKeys = Object.keys(obj) as (keyof T)[];
19-
20-
return allKeys.reduce<Partial<Omit<T, K>>>((acc, key) => {
21-
// `omittedKeys.indexOf` expects an input of type K extends keyof T
22-
// `key` is type keyof T, but for some reason TypeScript is freaking out
23-
// idk.
24-
const shouldOmit = omittedKeys.indexOf(key as K) !== -1;
25-
26-
if (!shouldOmit) {
27-
// assert that if we got this far, `key` must be one of the keys that _does not_ exist in K
28-
const keptKey = key as Exclude<keyof T, K>;
29-
30-
acc[keptKey] = obj[keptKey];
31-
}
32-
33-
return acc;
34-
// assert that the output is exactly Omit<T, K> rather than Partial<Omit<T, K>>
35-
}, {}) as Omit<T, K>;
36-
}
37-
3812
//
3913
// ─── TYPES ──────────────────────────────────────────────────────────────────────
4014
//
@@ -290,14 +264,8 @@ export function createResponsiveSystem<B extends ScreenClassBreakpoints>(
290264
// e.g. mobile-first should apply smallest -> largest
291265
// e.g. desktop-first should apply largest -> smallest
292266
// we assume that the sorting is already done
293-
return applicableScreenClasses.reduce((o, sc) => {
294-
const override = props[sc] ?? {};
295-
if (typeof override === 'function') {
296-
return override(o);
297-
} else {
298-
return { ...o, ...override };
299-
}
300-
}, baseProps);
267+
const propsToMerge = [baseProps, ...applicableScreenClasses.map((sc) => props[sc] ?? {})];
268+
return merge(propsToMerge);
301269
}
302270

303271
// In order to support refs, we need to use forwardRef

src/merge.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import deepMerge from 'deepmerge';
2+
3+
type MergeArrayHelpers = deepMerge.Options & {
4+
cloneUnlessOtherwiseSpecified: <T>(item: T, options: MergeArrayHelpers) => T;
5+
isMergeableObject: <T>(item: T) => boolean;
6+
};
7+
8+
function mergeArrays(base: any[], override: any[], helpers: MergeArrayHelpers) {
9+
const { cloneUnlessOtherwiseSpecified, isMergeableObject } = helpers;
10+
11+
// clone the target array
12+
const final = [...base];
13+
14+
override.forEach((overrideItem, index) => {
15+
const baseItem = base[index];
16+
17+
if (isMergeableObject(overrideItem)) {
18+
// if we encounter a "mergeable" object, merge it with the base item
19+
/* eslint-disable @typescript-eslint/no-use-before-define */
20+
final[index] = mergeTwo(baseItem, overrideItem);
21+
} else {
22+
// otherwise just replace whatever was in the base array with the override
23+
final[index] = cloneUnlessOtherwiseSpecified(overrideItem, helpers);
24+
}
25+
});
26+
27+
return final;
28+
}
29+
30+
function mergeTwo<P extends {}>(base: Partial<P>, override: Partial<P>) {
31+
return deepMerge(base, override, { arrayMerge: mergeArrays });
32+
}
33+
34+
export function merge<P extends {}>(propsObjects: Partial<P>[]) {
35+
return deepMerge.all<P>(propsObjects, { arrayMerge: mergeArrays });
36+
}

src/omit.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Omits the given keys from the given object
3+
*/
4+
export function omit<T extends { [key: string]: any }, K extends keyof T>(
5+
obj: T,
6+
omittedKeys: K[],
7+
): Omit<T, K> {
8+
// TypeScript assigns the return type, string[] - we are asserting that the return type keyof T
9+
const allKeys = Object.keys(obj) as (keyof T)[];
10+
11+
return allKeys.reduce<Partial<Omit<T, K>>>((acc, key) => {
12+
// `omittedKeys.indexOf` expects an input of type K extends keyof T
13+
// `key` is type keyof T, but for some reason TypeScript is freaking out
14+
// idk.
15+
const shouldOmit = omittedKeys.indexOf(key as K) !== -1;
16+
17+
if (!shouldOmit) {
18+
// assert that if we got this far, `key` must be one of the keys that _does not_ exist in K
19+
const keptKey = key as Exclude<keyof T, K>;
20+
21+
acc[keptKey] = obj[keptKey];
22+
}
23+
24+
return acc;
25+
// assert that the output is exactly Omit<T, K> rather than Partial<Omit<T, K>>
26+
}, {}) as Omit<T, K>;
27+
}

test/merge.spec.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { merge } from '../src/merge';
2+
3+
it('merges same-length arrays', () => {
4+
const a = { array: [1, 2, 3] };
5+
const b = { array: [2, 4, 6] };
6+
7+
expect(merge([a, b])).toStrictEqual({ array: [2, 4, 6] });
8+
});
9+
10+
it('merges base-longer arrays', () => {
11+
const a = { array: [1, 2, 3, 4] };
12+
const b = { array: [2, 4, 6] };
13+
14+
expect(merge([a, b])).toStrictEqual({ array: [2, 4, 6, 4] });
15+
});
16+
17+
it('merges override-longer arrays', () => {
18+
const a = { array: [1, 2, 3] };
19+
const b = { array: [2, 4, 6, 8] };
20+
21+
expect(merge([a, b])).toStrictEqual({ array: [2, 4, 6, 8] });
22+
});
23+
24+
it('merges objects inside of arrays', () => {
25+
const a = { array: [{ one: 1 }, 2, 3] };
26+
const b = { array: [{ two: 2 }, 3, 4] };
27+
28+
expect(
29+
merge<any>([a, b]),
30+
).toStrictEqual({ array: [{ one: 1, two: 2 }, 3, 4] });
31+
});
32+
33+
it('merges objects in order', () => {
34+
const a = { foo: 1, alpha: true };
35+
const b = { foo: 2, bravo: true };
36+
const c = { foo: 3, charlie: true };
37+
38+
expect(merge([a, b, c])).toStrictEqual({ foo: 3, alpha: true, bravo: true, charlie: true });
39+
});

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2901,6 +2901,11 @@ deep-is@~0.1.3:
29012901
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
29022902
integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
29032903

2904+
deepmerge@^4.2.2:
2905+
version "4.2.2"
2906+
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
2907+
integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
2908+
29042909
defaults@^1.0.3:
29052910
version "1.0.3"
29062911
resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d"

0 commit comments

Comments
 (0)