react-native-onyx 3.0.48 → 3.0.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Onyx.js CHANGED
@@ -221,7 +221,13 @@ function merge(key, changes) {
221
221
  }
222
222
  try {
223
223
  const validChanges = mergeQueue[key].filter((change) => {
224
- const { isCompatible, existingValueType, newValueType } = utils_1.default.checkCompatibilityWithExistingValue(change, existingValue);
224
+ const { isCompatible, existingValueType, newValueType, isEmptyArrayCoercion } = utils_1.default.checkCompatibilityWithExistingValue(change, existingValue);
225
+ if (isEmptyArrayCoercion) {
226
+ // Merging an object into an empty array isn't semantically correct, but we allow it
227
+ // in case we accidentally encoded an empty object as an empty array in PHP. If you're
228
+ // looking at a bugbot from this message, we're probably missing that key in OnyxKeys::KEYS_REQUIRING_EMPTY_OBJECT
229
+ Logger.logAlert(`[ENSURE_BUGBOT] Onyx merge called on key "${key}" whose existing value is an empty array. Will coerce to object.`);
230
+ }
225
231
  if (!isCompatible) {
226
232
  Logger.logAlert(logMessages_1.default.incompatibleUpdateAlert(key, 'merge', existingValueType, newValueType));
227
233
  }
@@ -240,8 +246,9 @@ function merge(key, changes) {
240
246
  OnyxUtils_1.default.logKeyRemoved(OnyxUtils_1.default.METHOD.MERGE, key);
241
247
  return Promise.resolve();
242
248
  }
243
- return OnyxMerge_1.default.applyMerge(key, existingValue, validChanges).then(({ mergedValue }) => {
249
+ return OnyxMerge_1.default.applyMerge(key, existingValue, validChanges).then(({ mergedValue, updatePromise }) => {
244
250
  OnyxUtils_1.default.sendActionToDevTools(OnyxUtils_1.default.METHOD.MERGE, key, changes, mergedValue);
251
+ return updatePromise;
245
252
  });
246
253
  }
247
254
  catch (error) {
@@ -340,6 +347,14 @@ function clear(keysToPreserve = []) {
340
347
  // If it isn't preserved and doesn't have a default, we'll remove it
341
348
  keysToBeClearedFromStorage.push(key);
342
349
  }
350
+ const updatePromises = [];
351
+ // Notify the subscribers for each key/value group so they can receive the new values
352
+ for (const [key, value] of Object.entries(keyValuesToResetIndividually)) {
353
+ updatePromises.push(OnyxUtils_1.default.scheduleSubscriberUpdate(key, value));
354
+ }
355
+ for (const [key, value] of Object.entries(keyValuesToResetAsCollection)) {
356
+ updatePromises.push(OnyxUtils_1.default.scheduleNotifyCollectionSubscribers(key, value.newValues, value.oldValues));
357
+ }
343
358
  // Exclude RAM-only keys to prevent them from being saved to storage
344
359
  const defaultKeyValuePairs = Object.entries(Object.keys(defaultKeyStates)
345
360
  .filter((key) => !keysToPreserve.includes(key) && !OnyxUtils_1.default.isRamOnlyKey(key))
@@ -356,13 +371,7 @@ function clear(keysToPreserve = []) {
356
371
  .then(() => storage_1.default.multiSet(defaultKeyValuePairs))
357
372
  .then(() => {
358
373
  DevTools_1.default.clearState(keysToPreserve);
359
- // Notify the subscribers for each key/value group so they can receive the new values
360
- for (const [key, value] of Object.entries(keyValuesToResetIndividually)) {
361
- OnyxUtils_1.default.keyChanged(key, value);
362
- }
363
- for (const [key, value] of Object.entries(keyValuesToResetAsCollection)) {
364
- OnyxUtils_1.default.keysChanged(key, value.newValues, value.oldValues);
365
- }
374
+ return Promise.all(updatePromises);
366
375
  });
367
376
  })
368
377
  .then(() => undefined);
@@ -13,16 +13,17 @@ const applyMerge = (key, existingValue, validChanges) => {
13
13
  // Logging properties only since values could be sensitive things we don't want to log.
14
14
  OnyxUtils_1.default.logKeyChanged(OnyxUtils_1.default.METHOD.MERGE, key, mergedValue, hasChanged);
15
15
  // This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
16
- OnyxUtils_1.default.broadcastUpdate(key, mergedValue, hasChanged);
16
+ const updatePromise = OnyxUtils_1.default.broadcastUpdate(key, mergedValue, hasChanged);
17
17
  const shouldSkipStorageOperations = !hasChanged || OnyxUtils_1.default.isRamOnlyKey(key);
18
18
  // If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead.
19
19
  // If the key is marked as RAM-only, it should not be saved nor updated in the storage.
20
20
  if (shouldSkipStorageOperations) {
21
- return Promise.resolve({ mergedValue });
21
+ return Promise.resolve({ mergedValue, updatePromise });
22
22
  }
23
23
  // For web platforms we use `setItem` since the object was already merged with its changes before.
24
24
  return storage_1.default.setItem(key, mergedValue).then(() => ({
25
25
  mergedValue,
26
+ updatePromise,
26
27
  }));
27
28
  };
28
29
  const OnyxMerge = {
@@ -19,17 +19,18 @@ const applyMerge = (key, existingValue, validChanges) => {
19
19
  // Logging properties only since values could be sensitive things we don't want to log.
20
20
  OnyxUtils_1.default.logKeyChanged(OnyxUtils_1.default.METHOD.MERGE, key, mergedValue, hasChanged);
21
21
  // This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
22
- OnyxUtils_1.default.broadcastUpdate(key, mergedValue, hasChanged);
22
+ const updatePromise = OnyxUtils_1.default.broadcastUpdate(key, mergedValue, hasChanged);
23
23
  const shouldSkipStorageOperations = !hasChanged || OnyxUtils_1.default.isRamOnlyKey(key);
24
24
  // If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead.
25
25
  // If the key is marked as RAM-only, it should not be saved nor updated in the storage.
26
26
  if (shouldSkipStorageOperations) {
27
- return Promise.resolve({ mergedValue });
27
+ return Promise.resolve({ mergedValue, updatePromise });
28
28
  }
29
29
  // For native platforms we use `mergeItem` that will take advantage of JSON_PATCH and JSON_REPLACE SQL operations to
30
30
  // merge the object in a performant way.
31
31
  return storage_1.default.mergeItem(key, batchedChanges, replaceNullPatches).then(() => ({
32
32
  mergedValue,
33
+ updatePromise,
33
34
  }));
34
35
  };
35
36
  const OnyxMerge = {
@@ -1,6 +1,7 @@
1
1
  import type { OnyxInput, OnyxKey } from '../types';
2
2
  type ApplyMergeResult<TValue> = {
3
3
  mergedValue: TValue;
4
+ updatePromise: Promise<void>;
4
5
  };
5
6
  type ApplyMerge = <TKey extends OnyxKey, TValue extends OnyxInput<OnyxKey> | undefined, TChange extends OnyxInput<OnyxKey> | null>(key: TKey, existingValue: TValue, validChanges: TChange[]) => Promise<ApplyMergeResult<TChange>>;
6
7
  export type { ApplyMerge, ApplyMergeResult };
@@ -186,7 +186,7 @@ declare function keyChanged<TKey extends OnyxKey>(key: TKey, value: OnyxValue<TK
186
186
  /**
187
187
  * Sends the data obtained from the keys to the connection.
188
188
  */
189
- declare function sendDataToConnection<TKey extends OnyxKey>(mapping: CallbackToStateMapping<TKey>, matchedKey: TKey | undefined): void;
189
+ declare function sendDataToConnection<TKey extends OnyxKey>(mapping: CallbackToStateMapping<TKey>, value: OnyxValue<TKey> | null, matchedKey: TKey | undefined): void;
190
190
  /**
191
191
  * We check to see if this key is flagged as safe for eviction and add it to the recentlyAccessedKeys list so that when we
192
192
  * run out of storage the least recently accessed key can be removed.
@@ -196,6 +196,19 @@ declare function addKeyToRecentlyAccessedIfNeeded<TKey extends OnyxKey>(key: TKe
196
196
  * Gets the data for a given an array of matching keys, combines them into an object, and sends the result back to the subscriber.
197
197
  */
198
198
  declare function getCollectionDataAndSendAsObject<TKey extends OnyxKey>(matchingKeys: CollectionKeyBase[], mapping: CallbackToStateMapping<TKey>): void;
199
+ /**
200
+ * Schedules an update that will be appended to the macro task queue (so it doesn't update the subscribers immediately).
201
+ *
202
+ * @example
203
+ * scheduleSubscriberUpdate(key, value, subscriber => subscriber.initWithStoredValues === false)
204
+ */
205
+ declare function scheduleSubscriberUpdate<TKey extends OnyxKey>(key: TKey, value: OnyxValue<TKey>, canUpdateSubscriber?: (subscriber?: CallbackToStateMapping<OnyxKey>) => boolean, isProcessingCollectionUpdate?: boolean): Promise<void>;
206
+ /**
207
+ * This method is similar to scheduleSubscriberUpdate but it is built for working specifically with collections
208
+ * so that keysChanged() is triggered for the collection and not keyChanged(). If this was not done, then the
209
+ * subscriber callbacks receive the data in a different format than they normally expect and it breaks code.
210
+ */
211
+ declare function scheduleNotifyCollectionSubscribers<TKey extends OnyxKey>(key: TKey, value: OnyxCollection<KeyValueMapping[TKey]>, previousValue?: OnyxCollection<KeyValueMapping[TKey]>): Promise<void>;
199
212
  /**
200
213
  * Remove a key from Onyx and update the subscribers
201
214
  */
@@ -211,7 +224,7 @@ declare function retryOperation<TMethod extends RetriableOnyxOperation>(error: E
211
224
  /**
212
225
  * Notifies subscribers and writes current value to cache
213
226
  */
214
- declare function broadcastUpdate<TKey extends OnyxKey>(key: TKey, value: OnyxValue<TKey>, hasChanged?: boolean): void;
227
+ declare function broadcastUpdate<TKey extends OnyxKey>(key: TKey, value: OnyxValue<TKey>, hasChanged?: boolean): Promise<void>;
215
228
  declare function hasPendingMergeForKey(key: OnyxKey): boolean;
216
229
  /**
217
230
  * Storage expects array like: [["@MyApp_user", value_1], ["@MyApp_key", value_2]]
@@ -357,6 +370,8 @@ declare const OnyxUtils: {
357
370
  sendDataToConnection: typeof sendDataToConnection;
358
371
  getCollectionKey: typeof getCollectionKey;
359
372
  getCollectionDataAndSendAsObject: typeof getCollectionDataAndSendAsObject;
373
+ scheduleSubscriberUpdate: typeof scheduleSubscriberUpdate;
374
+ scheduleNotifyCollectionSubscribers: typeof scheduleNotifyCollectionSubscribers;
360
375
  remove: typeof remove;
361
376
  reportStorageQuota: typeof reportStorageQuota;
362
377
  retryOperation: typeof retryOperation;
package/dist/OnyxUtils.js CHANGED
@@ -74,6 +74,8 @@ const MAX_STORAGE_OPERATION_RETRY_ATTEMPTS = 5;
74
74
  // Key/value store of Onyx key and arrays of values to merge
75
75
  let mergeQueue = {};
76
76
  let mergeQueuePromise = {};
77
+ // Used to schedule subscriber update to the macro task queue
78
+ let nextMacrotaskPromise = null;
77
79
  // Holds a mapping of all the React components that want their state subscribed to a store key
78
80
  let callbackToStateMapping = {};
79
81
  // Keeps a copy of the values of the onyx collection keys as a map for faster lookups
@@ -584,7 +586,6 @@ function keysChanged(collectionKey, partialCollection, partialPreviousCollection
584
586
  // If they are subscribed to the collection key and using waitForCollectionCallback then we'll
585
587
  // send the whole cached collection.
586
588
  if (isSubscribedToCollectionKey) {
587
- lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection);
588
589
  if (subscriber.waitForCollectionCallback) {
589
590
  subscriber.callback(cachedCollection, subscriber.key, partialCollection);
590
591
  continue;
@@ -667,7 +668,6 @@ function keyChanged(key, value, canUpdateSubscriber = () => true, isProcessingCo
667
668
  cachedCollections[subscriber.key] = cachedCollection;
668
669
  }
669
670
  cachedCollection[key] = value;
670
- lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection);
671
671
  subscriber.callback(cachedCollection, subscriber.key, { [key]: value });
672
672
  continue;
673
673
  }
@@ -682,32 +682,22 @@ function keyChanged(key, value, canUpdateSubscriber = () => true, isProcessingCo
682
682
  /**
683
683
  * Sends the data obtained from the keys to the connection.
684
684
  */
685
- function sendDataToConnection(mapping, matchedKey) {
685
+ function sendDataToConnection(mapping, value, matchedKey) {
686
686
  var _a, _b;
687
687
  // If the mapping no longer exists then we should not send any data.
688
688
  // This means our subscriber was disconnected.
689
689
  if (!callbackToStateMapping[mapping.subscriptionID]) {
690
690
  return;
691
691
  }
692
- // Always read the latest value from cache to avoid stale or duplicate data.
693
- // For collection subscribers with waitForCollectionCallback, read the full collection.
694
- // For individual key subscribers, read just that key's value.
695
- let value;
696
- if (isCollectionKey(mapping.key) && mapping.waitForCollectionCallback) {
697
- const collection = getCachedCollection(mapping.key);
698
- value = Object.keys(collection).length > 0 ? collection : undefined;
699
- }
700
- else {
701
- value = OnyxCache_1.default.get(matchedKey !== null && matchedKey !== void 0 ? matchedKey : mapping.key);
702
- }
703
692
  // For regular callbacks, we never want to pass null values, but always just undefined if a value is not set in cache or storage.
704
- value = value === null ? undefined : value;
693
+ const valueToPass = value === null ? undefined : value;
705
694
  const lastValue = lastConnectionCallbackData.get(mapping.subscriptionID);
695
+ lastConnectionCallbackData.get(mapping.subscriptionID);
706
696
  // If the value has not changed we do not need to trigger the callback
707
- if (lastConnectionCallbackData.has(mapping.subscriptionID) && (0, fast_equals_1.shallowEqual)(lastValue, value)) {
697
+ if (lastConnectionCallbackData.has(mapping.subscriptionID) && valueToPass === lastValue) {
708
698
  return;
709
699
  }
710
- (_b = (_a = mapping).callback) === null || _b === void 0 ? void 0 : _b.call(_a, value, matchedKey);
700
+ (_b = (_a = mapping).callback) === null || _b === void 0 ? void 0 : _b.call(_a, valueToPass, matchedKey);
711
701
  }
712
702
  /**
713
703
  * We check to see if this key is flagged as safe for eviction and add it to the recentlyAccessedKeys list so that when we
@@ -726,16 +716,50 @@ function addKeyToRecentlyAccessedIfNeeded(key) {
726
716
  * Gets the data for a given an array of matching keys, combines them into an object, and sends the result back to the subscriber.
727
717
  */
728
718
  function getCollectionDataAndSendAsObject(matchingKeys, mapping) {
729
- multiGet(matchingKeys).then(() => {
730
- sendDataToConnection(mapping, mapping.key);
719
+ multiGet(matchingKeys).then((dataMap) => {
720
+ const data = Object.fromEntries(dataMap.entries());
721
+ sendDataToConnection(mapping, data, mapping.key);
731
722
  });
732
723
  }
724
+ /**
725
+ * Delays promise resolution until the next macrotask to prevent race condition if the key subscription is in progress.
726
+ *
727
+ * @param callback The keyChanged/keysChanged callback
728
+ * */
729
+ function prepareSubscriberUpdate(callback) {
730
+ if (!nextMacrotaskPromise) {
731
+ nextMacrotaskPromise = new Promise((resolve) => {
732
+ setTimeout(() => {
733
+ nextMacrotaskPromise = null;
734
+ resolve();
735
+ }, 0);
736
+ });
737
+ }
738
+ return Promise.all([nextMacrotaskPromise, Promise.resolve().then(callback)]).then();
739
+ }
740
+ /**
741
+ * Schedules an update that will be appended to the macro task queue (so it doesn't update the subscribers immediately).
742
+ *
743
+ * @example
744
+ * scheduleSubscriberUpdate(key, value, subscriber => subscriber.initWithStoredValues === false)
745
+ */
746
+ function scheduleSubscriberUpdate(key, value, canUpdateSubscriber = () => true, isProcessingCollectionUpdate = false) {
747
+ return prepareSubscriberUpdate(() => keyChanged(key, value, canUpdateSubscriber, isProcessingCollectionUpdate));
748
+ }
749
+ /**
750
+ * This method is similar to scheduleSubscriberUpdate but it is built for working specifically with collections
751
+ * so that keysChanged() is triggered for the collection and not keyChanged(). If this was not done, then the
752
+ * subscriber callbacks receive the data in a different format than they normally expect and it breaks code.
753
+ */
754
+ function scheduleNotifyCollectionSubscribers(key, value, previousValue) {
755
+ return prepareSubscriberUpdate(() => keysChanged(key, value, previousValue));
756
+ }
733
757
  /**
734
758
  * Remove a key from Onyx and update the subscribers
735
759
  */
736
760
  function remove(key, isProcessingCollectionUpdate) {
737
761
  OnyxCache_1.default.drop(key);
738
- keyChanged(key, undefined, undefined, isProcessingCollectionUpdate);
762
+ scheduleSubscriberUpdate(key, undefined, undefined, isProcessingCollectionUpdate);
739
763
  if (isRamOnlyKey(key)) {
740
764
  return Promise.resolve();
741
765
  }
@@ -803,7 +827,7 @@ function broadcastUpdate(key, value, hasChanged) {
803
827
  else {
804
828
  OnyxCache_1.default.addToAccessedKeys(key);
805
829
  }
806
- keyChanged(key, value, (subscriber) => hasChanged || (subscriber === null || subscriber === void 0 ? void 0 : subscriber.initWithStoredValues) === false);
830
+ return scheduleSubscriberUpdate(key, value, (subscriber) => hasChanged || (subscriber === null || subscriber === void 0 ? void 0 : subscriber.initWithStoredValues) === false).then(() => undefined);
807
831
  }
808
832
  function hasPendingMergeForKey(key) {
809
833
  return !!mergeQueue[key];
@@ -978,7 +1002,7 @@ function subscribeToKey(connectOptions) {
978
1002
  const matchedKey = isCollectionKey(mapping.key) && mapping.waitForCollectionCallback ? mapping.key : undefined;
979
1003
  // Here we cannot use batching because the nullish value is expected to be set immediately for default props
980
1004
  // or they will be undefined.
981
- sendDataToConnection(mapping, matchedKey);
1005
+ sendDataToConnection(mapping, null, matchedKey);
982
1006
  return;
983
1007
  }
984
1008
  // When using a callback subscriber we will either trigger the provided callback for each key we find or combine all values
@@ -991,15 +1015,15 @@ function subscribeToKey(connectOptions) {
991
1015
  return;
992
1016
  }
993
1017
  // We did not opt into using waitForCollectionCallback mode so the callback is called for every matching key.
994
- multiGet(matchingKeys).then(() => {
995
- for (const key of matchingKeys) {
996
- sendDataToConnection(mapping, key);
1018
+ multiGet(matchingKeys).then((values) => {
1019
+ for (const [key, val] of values.entries()) {
1020
+ sendDataToConnection(mapping, val, key);
997
1021
  }
998
1022
  });
999
1023
  return;
1000
1024
  }
1001
1025
  // If we are not subscribed to a collection key then there's only a single key to send an update for.
1002
- get(mapping.key).then(() => sendDataToConnection(mapping, mapping.key));
1026
+ get(mapping.key).then((val) => sendDataToConnection(mapping, val, mapping.key));
1003
1027
  return;
1004
1028
  }
1005
1029
  console.error('Warning: Onyx.connect() was found without a callback');
@@ -1111,7 +1135,13 @@ function setWithRetry({ key, value, options }, retryAttempt) {
1111
1135
  return Promise.resolve();
1112
1136
  }
1113
1137
  // Check if the value is compatible with the existing value in the storage
1114
- const { isCompatible, existingValueType, newValueType } = utils_1.default.checkCompatibilityWithExistingValue(value, existingValue);
1138
+ const { isCompatible, existingValueType, newValueType, isEmptyArrayCoercion } = utils_1.default.checkCompatibilityWithExistingValue(value, existingValue);
1139
+ if (isEmptyArrayCoercion) {
1140
+ // Setting an object over empty array isn't semantically correct, but we allow it
1141
+ // in case we accidentally encoded an empty object as an empty array in PHP. If you're
1142
+ // looking at a bugbot from this message, we're probably missing that key in OnyxKeys::KEYS_REQUIRING_EMPTY_OBJECT
1143
+ Logger.logAlert(`[ENSURE_BUGBOT] Onyx setWithRetry called on key "${key}" whose existing value is an empty array. Will coerce to object.`);
1144
+ }
1115
1145
  if (!isCompatible) {
1116
1146
  Logger.logAlert(logMessages_1.default.incompatibleUpdateAlert(key, 'set', existingValueType, newValueType));
1117
1147
  return Promise.resolve();
@@ -1127,20 +1157,21 @@ function setWithRetry({ key, value, options }, retryAttempt) {
1127
1157
  const hasChanged = (options === null || options === void 0 ? void 0 : options.skipCacheCheck) ? true : OnyxCache_1.default.hasValueChanged(key, valueWithoutNestedNullValues);
1128
1158
  OnyxUtils.logKeyChanged(OnyxUtils.METHOD.SET, key, value, hasChanged);
1129
1159
  // This approach prioritizes fast UI changes without waiting for data to be stored in device storage.
1130
- OnyxUtils.broadcastUpdate(key, valueWithoutNestedNullValues, hasChanged);
1160
+ const updatePromise = OnyxUtils.broadcastUpdate(key, valueWithoutNestedNullValues, hasChanged);
1131
1161
  // If the value has not changed and this isn't a retry attempt, calling Storage.setItem() would be redundant and a waste of performance, so return early instead.
1132
1162
  if (!hasChanged && !retryAttempt) {
1133
- return Promise.resolve();
1163
+ return updatePromise;
1134
1164
  }
1135
1165
  // If a key is a RAM-only key or a member of RAM-only collection, we skip the step that modifies the storage
1136
1166
  if (isRamOnlyKey(key)) {
1137
1167
  OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET, key, valueWithoutNestedNullValues);
1138
- return Promise.resolve();
1168
+ return updatePromise;
1139
1169
  }
1140
1170
  return storage_1.default.setItem(key, valueWithoutNestedNullValues)
1141
1171
  .catch((error) => OnyxUtils.retryOperation(error, setWithRetry, { key, value: valueWithoutNestedNullValues, options }, retryAttempt))
1142
1172
  .then(() => {
1143
1173
  OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET, key, valueWithoutNestedNullValues);
1174
+ return updatePromise;
1144
1175
  });
1145
1176
  }
1146
1177
  /**
@@ -1170,16 +1201,16 @@ function multiSetWithRetry(data, retryAttempt) {
1170
1201
  }, {});
1171
1202
  }
1172
1203
  const keyValuePairsToSet = OnyxUtils.prepareKeyValuePairsForStorage(newData, true);
1173
- for (const [key, value] of keyValuePairsToSet) {
1204
+ const updatePromises = keyValuePairsToSet.map(([key, value]) => {
1174
1205
  // When we use multiSet to set a key we want to clear the current delta changes from Onyx.merge that were queued
1175
1206
  // before the value was set. If Onyx.merge is currently reading the old value from storage, it will then not apply the changes.
1176
1207
  if (OnyxUtils.hasPendingMergeForKey(key)) {
1177
1208
  delete OnyxUtils.getMergeQueue()[key];
1178
1209
  }
1179
- // Update cache and optimistically inform subscribers
1210
+ // Update cache and optimistically inform subscribers on the next tick
1180
1211
  OnyxCache_1.default.set(key, value);
1181
- keyChanged(key, value);
1182
- }
1212
+ return OnyxUtils.scheduleSubscriberUpdate(key, value);
1213
+ });
1183
1214
  const keyValuePairsToStore = keyValuePairsToSet.filter((keyValuePair) => {
1184
1215
  const [key] = keyValuePair;
1185
1216
  // Filter out the RAM-only key value pairs, as they should not be saved to storage
@@ -1189,7 +1220,9 @@ function multiSetWithRetry(data, retryAttempt) {
1189
1220
  .catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt))
1190
1221
  .then(() => {
1191
1222
  OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MULTI_SET, undefined, newData);
1192
- });
1223
+ return Promise.all(updatePromises);
1224
+ })
1225
+ .then(() => undefined);
1193
1226
  }
1194
1227
  /**
1195
1228
  * Sets a collection by replacing all existing collection members with new values.
@@ -1242,16 +1275,17 @@ function setCollectionWithRetry({ collectionKey, collection }, retryAttempt) {
1242
1275
  const previousCollection = OnyxUtils.getCachedCollection(collectionKey);
1243
1276
  for (const [key, value] of keyValuePairs)
1244
1277
  OnyxCache_1.default.set(key, value);
1245
- keysChanged(collectionKey, mutableCollection, previousCollection);
1278
+ const updatePromise = OnyxUtils.scheduleNotifyCollectionSubscribers(collectionKey, mutableCollection, previousCollection);
1246
1279
  // RAM-only keys are not supposed to be saved to storage
1247
1280
  if (isRamOnlyKey(collectionKey)) {
1248
1281
  OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection);
1249
- return;
1282
+ return updatePromise;
1250
1283
  }
1251
1284
  return storage_1.default.multiSet(keyValuePairs)
1252
1285
  .catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, { collectionKey, collection }, retryAttempt))
1253
1286
  .then(() => {
1254
1287
  OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection);
1288
+ return updatePromise;
1255
1289
  });
1256
1290
  });
1257
1291
  }
@@ -1309,7 +1343,13 @@ function mergeCollectionWithPatches({ collectionKey, collection, mergeReplaceNul
1309
1343
  const existingKeys = keys.filter((key) => persistedKeys.has(key));
1310
1344
  const cachedCollectionForExistingKeys = getCachedCollection(collectionKey, existingKeys);
1311
1345
  const existingKeyCollection = existingKeys.reduce((obj, key) => {
1312
- const { isCompatible, existingValueType, newValueType } = utils_1.default.checkCompatibilityWithExistingValue(resultCollection[key], cachedCollectionForExistingKeys[key]);
1346
+ const { isCompatible, existingValueType, newValueType, isEmptyArrayCoercion } = utils_1.default.checkCompatibilityWithExistingValue(resultCollection[key], cachedCollectionForExistingKeys[key]);
1347
+ if (isEmptyArrayCoercion) {
1348
+ // Merging an object into an empty array isn't semantically correct, but we allow it
1349
+ // in case we accidentally encoded an empty object as an empty array in PHP. If you're
1350
+ // looking at a bugbot from this message, we're probably missing that key in OnyxKeys::KEYS_REQUIRING_EMPTY_OBJECT
1351
+ Logger.logAlert(`[ENSURE_BUGBOT] Onyx mergeCollection called on key "${key}" whose existing value is an empty array. Will coerce to object.`);
1352
+ }
1313
1353
  if (!isCompatible) {
1314
1354
  Logger.logAlert(logMessages_1.default.incompatibleUpdateAlert(key, 'mergeCollection', existingValueType, newValueType));
1315
1355
  return obj;
@@ -1352,7 +1392,7 @@ function mergeCollectionWithPatches({ collectionKey, collection, mergeReplaceNul
1352
1392
  // and update all subscribers
1353
1393
  const promiseUpdate = previousCollectionPromise.then((previousCollection) => {
1354
1394
  OnyxCache_1.default.merge(finalMergedCollection);
1355
- keysChanged(collectionKey, finalMergedCollection, previousCollection);
1395
+ return scheduleNotifyCollectionSubscribers(collectionKey, finalMergedCollection, previousCollection);
1356
1396
  });
1357
1397
  return Promise.all(promises)
1358
1398
  .catch((error) => retryOperation(error, mergeCollectionWithPatches, { collectionKey, collection: resultCollection, mergeReplaceNullPatches, isProcessingCollectionUpdate }, retryAttempt))
@@ -1405,15 +1445,16 @@ function partialSetCollection({ collectionKey, collection }, retryAttempt) {
1405
1445
  const keyValuePairs = prepareKeyValuePairsForStorage(mutableCollection, true, undefined, true);
1406
1446
  for (const [key, value] of keyValuePairs)
1407
1447
  OnyxCache_1.default.set(key, value);
1408
- keysChanged(collectionKey, mutableCollection, previousCollection);
1448
+ const updatePromise = scheduleNotifyCollectionSubscribers(collectionKey, mutableCollection, previousCollection);
1409
1449
  if (isRamOnlyKey(collectionKey)) {
1410
1450
  sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection);
1411
- return;
1451
+ return updatePromise;
1412
1452
  }
1413
1453
  return storage_1.default.multiSet(keyValuePairs)
1414
1454
  .catch((error) => retryOperation(error, partialSetCollection, { collectionKey, collection }, retryAttempt))
1415
1455
  .then(() => {
1416
1456
  sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection);
1457
+ return updatePromise;
1417
1458
  });
1418
1459
  });
1419
1460
  }
@@ -1457,6 +1498,8 @@ const OnyxUtils = {
1457
1498
  sendDataToConnection,
1458
1499
  getCollectionKey,
1459
1500
  getCollectionDataAndSendAsObject,
1501
+ scheduleSubscriberUpdate,
1502
+ scheduleNotifyCollectionSubscribers,
1460
1503
  remove,
1461
1504
  reportStorageQuota,
1462
1505
  retryOperation,
@@ -1511,6 +1554,10 @@ GlobalSettings.addGlobalSettingsChangeListener(({ enablePerformanceMetrics }) =>
1511
1554
  // @ts-expect-error Reassign
1512
1555
  sendDataToConnection = (0, metrics_1.default)(sendDataToConnection, 'OnyxUtils.sendDataToConnection');
1513
1556
  // @ts-expect-error Reassign
1557
+ scheduleSubscriberUpdate = (0, metrics_1.default)(scheduleSubscriberUpdate, 'OnyxUtils.scheduleSubscriberUpdate');
1558
+ // @ts-expect-error Reassign
1559
+ scheduleNotifyCollectionSubscribers = (0, metrics_1.default)(scheduleNotifyCollectionSubscribers, 'OnyxUtils.scheduleNotifyCollectionSubscribers');
1560
+ // @ts-expect-error Reassign
1514
1561
  remove = (0, metrics_1.default)(remove, 'OnyxUtils.remove');
1515
1562
  // @ts-expect-error Reassign
1516
1563
  reportStorageQuota = (0, metrics_1.default)(reportStorageQuota, 'OnyxUtils.reportStorageQuota');
package/dist/utils.d.ts CHANGED
@@ -46,6 +46,7 @@ declare function checkCompatibilityWithExistingValue(value: unknown, existingVal
46
46
  isCompatible: boolean;
47
47
  existingValueType?: string;
48
48
  newValueType?: string;
49
+ isEmptyArrayCoercion?: boolean;
49
50
  };
50
51
  /**
51
52
  * Picks entries from an object based on a condition.
package/dist/utils.js CHANGED
@@ -142,6 +142,16 @@ function checkCompatibilityWithExistingValue(value, existingValue) {
142
142
  isCompatible: true,
143
143
  };
144
144
  }
145
+ // PHP's associative arrays cannot distinguish between an empty list and an
146
+ // empty object, so it encodes both as []. A key that should hold an
147
+ // object may arrive from the server as [] and be stored that way. If
148
+ // we then try to MERGE an object into that key, the array-vs-object type check
149
+ // would normally block it. Since an empty array carries no data worth
150
+ // preserving, we treat it as compatible with an object update and coerce it.
151
+ const isObjectValue = typeof value === 'object' && !Array.isArray(value);
152
+ if (Array.isArray(existingValue) && existingValue.length === 0 && isObjectValue) {
153
+ return { isCompatible: true, isEmptyArrayCoercion: true };
154
+ }
145
155
  const existingValueType = Array.isArray(existingValue) ? 'array' : 'non-array';
146
156
  const newValueType = Array.isArray(value) ? 'array' : 'non-array';
147
157
  if (existingValueType !== newValueType) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-onyx",
3
- "version": "3.0.48",
3
+ "version": "3.0.50",
4
4
  "author": "Expensify, Inc.",
5
5
  "homepage": "https://expensify.com",
6
6
  "description": "State management for React Native",