Skip to content

Commit 2e7bf82

Browse files
authored
Merge pull request #519 from margelo/@fix-nested-null-removal-inconsistency-between-single-and-multi-functions
Fix inconsistencies between single key operations (`merge`, `set`) and multi key operations (`mergeCollection`, `multiSet`)
2 parents 9d8d16b + 5d35172 commit 2e7bf82

File tree

7 files changed

+133
-49
lines changed

7 files changed

+133
-49
lines changed

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,9 @@ dist/
1818
# Published package
1919
*.tgz
2020

21+
# Yalc
22+
.yalc
23+
yalc.lock
24+
2125
# Perf tests
22-
.reassure
26+
.reassure

lib/Onyx.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ function set<TKey extends OnyxKey>(key: TKey, value: OnyxEntry<KeyValueMapping[T
245245
* @param data object keyed by ONYXKEYS and the values to set
246246
*/
247247
function multiSet(data: Partial<NullableKeyValueMapping>): Promise<void> {
248-
const keyValuePairs = OnyxUtils.prepareKeyValuePairsForStorage(data);
248+
const keyValuePairs = OnyxUtils.prepareKeyValuePairsForStorage(data, true);
249249

250250
const updatePromises = keyValuePairs.map(([key, value]) => {
251251
const prevValue = cache.getValue(key, false);
@@ -285,7 +285,7 @@ function merge<TKey extends OnyxKey>(key: TKey, changes: OnyxEntry<NullishDeep<K
285285
const mergeQueuePromise = OnyxUtils.getMergeQueuePromise();
286286

287287
// Top-level undefined values are ignored
288-
// Therefore we need to prevent adding them to the merge queue
288+
// Therefore, we need to prevent adding them to the merge queue
289289
if (changes === undefined) {
290290
return mergeQueue[key] ? mergeQueuePromise[key] : Promise.resolve();
291291
}
@@ -419,8 +419,15 @@ function mergeCollection<TKey extends CollectionKeyBase, TMap>(collectionKey: TK
419419
obj[key] = mergedCollection[key];
420420
return obj;
421421
}, {});
422-
const keyValuePairsForExistingCollection = OnyxUtils.prepareKeyValuePairsForStorage(existingKeyCollection);
423-
const keyValuePairsForNewCollection = OnyxUtils.prepareKeyValuePairsForStorage(newCollection);
422+
423+
// When (multi-)merging the values with the existing values in storage,
424+
// we don't want to remove nested null values from the data that we pass to the storage layer,
425+
// because the storage layer uses them to remove nested keys from storage natively.
426+
const keyValuePairsForExistingCollection = OnyxUtils.prepareKeyValuePairsForStorage(existingKeyCollection, false);
427+
428+
// We can safely remove nested null values when using (multi-)set,
429+
// because we will simply overwrite the existing values in storage.
430+
const keyValuePairsForNewCollection = OnyxUtils.prepareKeyValuePairsForStorage(newCollection, true);
424431

425432
const promises = [];
426433

lib/OnyxUtils.ts

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,7 @@ function keysChanged<TKey extends CollectionKeyBase>(
453453
// We prepare the "cached collection" which is the entire collection + the new partial data that
454454
// was merged in via mergeCollection().
455455
const cachedCollection = getCachedCollection(collectionKey);
456+
const cachedCollectionWithoutNestedNulls = utils.removeNestedNullValues(cachedCollection) as Record<string, unknown>;
456457

457458
// Regular Onyx.connect() subscriber found.
458459
if (typeof subscriber.callback === 'function') {
@@ -464,7 +465,7 @@ function keysChanged<TKey extends CollectionKeyBase>(
464465
// send the whole cached collection.
465466
if (isSubscribedToCollectionKey) {
466467
if (subscriber.waitForCollectionCallback) {
467-
subscriber.callback(cachedCollection);
468+
subscriber.callback(cachedCollectionWithoutNestedNulls);
468469
continue;
469470
}
470471

@@ -473,7 +474,7 @@ function keysChanged<TKey extends CollectionKeyBase>(
473474
const dataKeys = Object.keys(partialCollection ?? {});
474475
for (let j = 0; j < dataKeys.length; j++) {
475476
const dataKey = dataKeys[j];
476-
subscriber.callback(cachedCollection[dataKey], dataKey);
477+
subscriber.callback(cachedCollectionWithoutNestedNulls[dataKey], dataKey);
477478
}
478479
continue;
479480
}
@@ -482,7 +483,7 @@ function keysChanged<TKey extends CollectionKeyBase>(
482483
// notify them with the cached data for that key only.
483484
if (isSubscribedToCollectionMemberKey) {
484485
const subscriberCallback = subscriber.callback as DefaultConnectCallback<TKey>;
485-
subscriberCallback(cachedCollection[subscriber.key], subscriber.key as TKey);
486+
subscriberCallback(cachedCollectionWithoutNestedNulls[subscriber.key], subscriber.key as TKey);
486487
continue;
487488
}
488489

@@ -621,13 +622,16 @@ function keyChanged<TKey extends OnyxKey>(
621622
}
622623
if (isCollectionKey(subscriber.key) && subscriber.waitForCollectionCallback) {
623624
const cachedCollection = getCachedCollection(subscriber.key);
624-
cachedCollection[key] = data;
625-
subscriber.callback(cachedCollection);
625+
const cachedCollectionWithoutNestedNulls = utils.removeNestedNullValues(cachedCollection) as Record<string, unknown>;
626+
627+
cachedCollectionWithoutNestedNulls[key] = data;
628+
subscriber.callback(cachedCollectionWithoutNestedNulls);
626629
continue;
627630
}
628631

632+
const dataWithoutNestedNulls = utils.removeNestedNullValues(data);
629633
const subscriberCallback = subscriber.callback as DefaultConnectCallback<TKey>;
630-
subscriberCallback(data, key);
634+
subscriberCallback(dataWithoutNestedNulls, key);
631635
continue;
632636
}
633637

@@ -752,7 +756,8 @@ function sendDataToConnection<TKey extends OnyxKey>(mapping: Mapping<TKey>, val:
752756
return;
753757
}
754758

755-
(mapping as DefaultConnectOptions<TKey>).callback?.(val, matchedKey as TKey);
759+
const valuesWithoutNestedNulls = utils.removeNestedNullValues(val);
760+
(mapping as DefaultConnectOptions<TKey>).callback?.(valuesWithoutNestedNulls, matchedKey as TKey);
756761
}
757762

758763
/**
@@ -963,11 +968,12 @@ type RemoveNullValuesOutput = {
963968

964969
/**
965970
* Removes a key from storage if the value is null.
966-
* Otherwise removes all nested null values in objects and returns the object
971+
* Otherwise removes all nested null values in objects,
972+
* if shouldRemoveNestedNulls is true and returns the object.
967973
*
968974
* @returns The value without null values and a boolean "wasRemoved", which indicates if the key got removed completely
969975
*/
970-
function removeNullValues(key: OnyxKey, value: OnyxValue<OnyxKey>): RemoveNullValuesOutput {
976+
function removeNullValues(key: OnyxKey, value: OnyxValue<OnyxKey>, shouldRemoveNestedNulls = true): RemoveNullValuesOutput {
971977
if (value === null) {
972978
remove(key);
973979
return {value, wasRemoved: true};
@@ -976,7 +982,7 @@ function removeNullValues(key: OnyxKey, value: OnyxValue<OnyxKey>): RemoveNullVa
976982
// We can remove all null values in an object by merging it with itself
977983
// utils.fastMerge recursively goes through the object and removes all null values
978984
// Passing two identical objects as source and target to fastMerge will not change it, but only remove the null values
979-
return {value: utils.removeNestedNullValues(value as Record<string, unknown>), wasRemoved: false};
985+
return {value: shouldRemoveNestedNulls ? utils.removeNestedNullValues(value as Record<string, unknown>) : (value as Record<string, unknown>), wasRemoved: false};
980986
}
981987

982988
/**
@@ -986,28 +992,24 @@ function removeNullValues(key: OnyxKey, value: OnyxValue<OnyxKey>): RemoveNullVa
986992
987993
* @return an array of key - value pairs <[key, value]>
988994
*/
989-
function prepareKeyValuePairsForStorage(data: Record<OnyxKey, OnyxValue<OnyxKey>>): Array<[OnyxKey, OnyxValue<OnyxKey>]> {
990-
const keyValuePairs: Array<[OnyxKey, OnyxValue<OnyxKey>]> = [];
991-
992-
Object.entries(data).forEach(([key, value]) => {
993-
const {value: valueAfterRemoving, wasRemoved} = removeNullValues(key, value);
995+
function prepareKeyValuePairsForStorage(data: Record<OnyxKey, OnyxValue<OnyxKey>>, shouldRemoveNestedNulls: boolean): Array<[OnyxKey, OnyxValue<OnyxKey>]> {
996+
return Object.entries(data).reduce<Array<[OnyxKey, OnyxValue<OnyxKey>]>>((pairs, [key, value]) => {
997+
const {value: valueAfterRemoving, wasRemoved} = removeNullValues(key, value, shouldRemoveNestedNulls);
994998

995-
if (wasRemoved) {
996-
return;
999+
if (!wasRemoved) {
1000+
pairs.push([key, valueAfterRemoving]);
9971001
}
9981002

999-
keyValuePairs.push([key, valueAfterRemoving]);
1000-
});
1001-
1002-
return keyValuePairs;
1003+
return pairs;
1004+
}, []);
10031005
}
10041006

10051007
/**
10061008
* Merges an array of changes with an existing value
10071009
*
10081010
* @param changes Array of changes that should be applied to the existing value
10091011
*/
1010-
function applyMerge(existingValue: OnyxValue<OnyxKey>, changes: Array<OnyxValue<OnyxKey>>, shouldRemoveNullObjectValues: boolean): OnyxValue<OnyxKey> {
1012+
function applyMerge(existingValue: OnyxValue<OnyxKey>, changes: Array<OnyxValue<OnyxKey>>, shouldRemoveNestedNulls: boolean): OnyxValue<OnyxKey> {
10111013
const lastChange = changes?.at(-1);
10121014

10131015
if (Array.isArray(lastChange)) {
@@ -1017,7 +1019,7 @@ function applyMerge(existingValue: OnyxValue<OnyxKey>, changes: Array<OnyxValue<
10171019
if (changes.some((change) => change && typeof change === 'object')) {
10181020
// Object values are then merged one after the other
10191021
return changes.reduce(
1020-
(modifiedData, change) => utils.fastMerge(modifiedData as Record<OnyxKey, unknown>, change as Record<OnyxKey, unknown>, shouldRemoveNullObjectValues),
1022+
(modifiedData, change) => utils.fastMerge(modifiedData as Record<OnyxKey, unknown>, change as Record<OnyxKey, unknown>, shouldRemoveNestedNulls),
10211023
existingValue || {},
10221024
);
10231025
}

lib/storage/providers/MemoryOnlyProvider.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,7 @@ const provider: StorageProvider = {
6868
multiSet(pairs) {
6969
const setPromises = _.map(pairs, ([key, value]) => this.setItem(key, value));
7070

71-
return new Promise((resolve) => {
72-
Promise.all(setPromises).then(() => {
73-
resolve(undefined);
74-
});
75-
});
71+
return Promise.all(setPromises).then(() => undefined);
7672
},
7773

7874
/**

lib/utils.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,15 @@ function isMergeableObject(value: unknown): value is Record<string, unknown> {
2424
* Merges the source object into the target object.
2525
* @param target - The target object.
2626
* @param source - The source object.
27-
* @param shouldRemoveNullObjectValues - If true, null object values will be removed.
27+
* @param shouldRemoveNestedNulls - If true, null object values will be removed.
2828
* @returns - The merged object.
2929
*/
30-
function mergeObject<TObject extends Record<string, unknown>>(target: TObject | null, source: TObject, shouldRemoveNullObjectValues = true): TObject {
30+
function mergeObject<TObject extends Record<string, unknown>>(target: TObject | null, source: TObject, shouldRemoveNestedNulls = true): TObject {
3131
const destination: Record<string, unknown> = {};
3232

3333
// First we want to copy over all keys from the target into the destination object,
3434
// in case "target" is a mergable object.
35-
// If "shouldRemoveNullObjectValues" is true, we want to remove null values from the merged object
35+
// If "shouldRemoveNestedNulls" is true, we want to remove null values from the merged object
3636
// and therefore we need to omit keys where either the source or target value is null.
3737
if (isMergeableObject(target)) {
3838
const targetKeys = Object.keys(target);
@@ -41,10 +41,10 @@ function mergeObject<TObject extends Record<string, unknown>>(target: TObject |
4141
const sourceValue = source?.[key];
4242
const targetValue = target?.[key];
4343

44-
// If "shouldRemoveNullObjectValues" is true, we want to remove null values from the merged object.
44+
// If "shouldRemoveNestedNulls" is true, we want to remove null values from the merged object.
4545
// Therefore, if either target or source value is null, we want to prevent the key from being set.
4646
const isSourceOrTargetNull = targetValue === null || sourceValue === null;
47-
const shouldOmitTargetKey = shouldRemoveNullObjectValues && isSourceOrTargetNull;
47+
const shouldOmitTargetKey = shouldRemoveNestedNulls && isSourceOrTargetNull;
4848

4949
if (!shouldOmitTargetKey) {
5050
destination[key] = targetValue;
@@ -60,22 +60,22 @@ function mergeObject<TObject extends Record<string, unknown>>(target: TObject |
6060
const targetValue = target?.[key];
6161

6262
// If undefined is passed as the source value for a key, we want to generally ignore it.
63-
// If "shouldRemoveNullObjectValues" is set to true and the source value is null,
63+
// If "shouldRemoveNestedNulls" is set to true and the source value is null,
6464
// we don't want to set/merge the source value into the merged object.
65-
const shouldIgnoreNullSourceValue = shouldRemoveNullObjectValues && sourceValue === null;
65+
const shouldIgnoreNullSourceValue = shouldRemoveNestedNulls && sourceValue === null;
6666
const shouldOmitSourceKey = sourceValue === undefined || shouldIgnoreNullSourceValue;
6767

6868
if (!shouldOmitSourceKey) {
6969
// If the source value is a mergable object, we want to merge it into the target value.
70-
// If "shouldRemoveNullObjectValues" is true, "fastMerge" will recursively
70+
// If "shouldRemoveNestedNulls" is true, "fastMerge" will recursively
7171
// remove nested null values from the merged object.
7272
// If source value is any other value we need to set the source value it directly.
7373
if (isMergeableObject(sourceValue)) {
7474
// If the target value is null or undefined, we need to fallback to an empty object,
7575
// so that we can still use "fastMerge" to merge the source value,
7676
// to ensure that nested null values are removed from the merged object.
7777
const targetValueWithFallback = (targetValue ?? {}) as TObject;
78-
destination[key] = fastMerge(targetValueWithFallback, sourceValue, shouldRemoveNullObjectValues);
78+
destination[key] = fastMerge(targetValueWithFallback, sourceValue, shouldRemoveNestedNulls);
7979
} else {
8080
destination[key] = sourceValue;
8181
}
@@ -86,30 +86,30 @@ function mergeObject<TObject extends Record<string, unknown>>(target: TObject |
8686
}
8787

8888
/**
89-
* Merges two objects and removes null values if "shouldRemoveNullObjectValues" is set to true
89+
* Merges two objects and removes null values if "shouldRemoveNestedNulls" is set to true
9090
*
9191
* We generally want to remove null values from objects written to disk and cache, because it decreases the amount of data stored in memory and on disk.
9292
* On native, when merging an existing value with new changes, SQLite will use JSON_PATCH, which removes top-level nullish values.
9393
* To be consistent with the behaviour for merge, we'll also want to remove null values for "set" operations.
9494
*/
95-
function fastMerge<TObject extends Record<string, unknown>>(target: TObject | null, source: TObject | null, shouldRemoveNullObjectValues = true): TObject | null {
95+
function fastMerge<TObject extends Record<string, unknown>>(target: TObject | null, source: TObject | null, shouldRemoveNestedNulls = true): TObject | null {
9696
// We have to ignore arrays and nullish values here,
9797
// otherwise "mergeObject" will throw an error,
9898
// because it expects an object as "source"
9999
if (Array.isArray(source) || source === null || source === undefined) {
100100
return source;
101101
}
102102

103-
return mergeObject(target, source, shouldRemoveNullObjectValues);
103+
return mergeObject(target, source, shouldRemoveNestedNulls);
104104
}
105105

106106
/** Deep removes the nested null values from the given value. */
107-
function removeNestedNullValues(value: unknown[] | Record<string, unknown>): Record<string, unknown> | unknown[] | null {
107+
function removeNestedNullValues<TObject extends Record<string, unknown>>(value: unknown | unknown[] | TObject): Record<string, unknown> | unknown[] | null {
108108
if (typeof value === 'object' && !Array.isArray(value)) {
109-
return fastMerge(value, value);
109+
return fastMerge(value as Record<string, unknown> | null, value as Record<string, unknown> | null);
110110
}
111111

112-
return value;
112+
return value as Record<string, unknown> | null;
113113
}
114114

115115
/** Formats the action name by uppercasing and adding the key if provided. */

lib/withOnyx.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ export default function (mapOnyxToState, shouldDelayUpdates = false) {
321321
// that should not be passed to a wrapped component
322322
let stateToPass = _.omit(this.state, 'loading');
323323
stateToPass = _.omit(stateToPass, _.isNull);
324+
const stateToPassWithoutNestedNulls = utils.removeNestedNullValues(stateToPass);
324325

325326
// Spreading props and state is necessary in an HOC where the data cannot be predicted
326327
return (
@@ -329,7 +330,7 @@ export default function (mapOnyxToState, shouldDelayUpdates = false) {
329330
// eslint-disable-next-line react/jsx-props-no-spreading
330331
{...propsToPass}
331332
// eslint-disable-next-line react/jsx-props-no-spreading
332-
{...stateToPass}
333+
{...stateToPassWithoutNestedNulls}
333334
ref={this.props.forwardedRef}
334335
/>
335336
);

tests/unit/onyxTest.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,4 +1071,78 @@ describe('Onyx', () => {
10711071
expect(testKeyValue).toEqual(null);
10721072
});
10731073
});
1074+
1075+
it('should merge a non-existing key with a nested null removed', () => {
1076+
let testKeyValue;
1077+
1078+
connectionID = Onyx.connect({
1079+
key: ONYX_KEYS.TEST_KEY,
1080+
initWithStoredValues: false,
1081+
callback: (value) => {
1082+
testKeyValue = value;
1083+
},
1084+
});
1085+
1086+
return Onyx.merge(ONYX_KEYS.TEST_KEY, {
1087+
waypoints: {
1088+
1: 'Home',
1089+
2: 'Work',
1090+
3: null,
1091+
},
1092+
}).then(() => {
1093+
expect(testKeyValue).toEqual({
1094+
waypoints: {
1095+
1: 'Home',
1096+
2: 'Work',
1097+
},
1098+
});
1099+
});
1100+
});
1101+
1102+
it('mergeCollection should omit nested null values', () => {
1103+
let result;
1104+
1105+
const routineRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}routine`;
1106+
const holidayRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}holiday`;
1107+
1108+
connectionID = Onyx.connect({
1109+
key: ONYX_KEYS.COLLECTION.TEST_KEY,
1110+
initWithStoredValues: false,
1111+
callback: (value) => (result = value),
1112+
waitForCollectionCallback: true,
1113+
});
1114+
1115+
return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, {
1116+
[routineRoute]: {
1117+
waypoints: {
1118+
1: 'Home',
1119+
2: 'Work',
1120+
3: 'Gym',
1121+
},
1122+
},
1123+
[holidayRoute]: {
1124+
waypoints: {
1125+
1: 'Home',
1126+
2: 'Beach',
1127+
3: null,
1128+
},
1129+
},
1130+
}).then(() => {
1131+
expect(result).toEqual({
1132+
[routineRoute]: {
1133+
waypoints: {
1134+
1: 'Home',
1135+
2: 'Work',
1136+
3: 'Gym',
1137+
},
1138+
},
1139+
[holidayRoute]: {
1140+
waypoints: {
1141+
1: 'Home',
1142+
2: 'Beach',
1143+
},
1144+
},
1145+
});
1146+
});
1147+
});
10741148
});

0 commit comments

Comments
 (0)