react-native-onyx 2.0.56 → 2.0.58

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.d.ts CHANGED
@@ -19,7 +19,7 @@ declare function init({ keys, initialKeyStates, safeEvictionKeys, maxCachedKeysC
19
19
  * @param [mapping.callback] a method that will be called with changed data
20
20
  * This is used by any non-React code to connect to Onyx
21
21
  * @param [mapping.initWithStoredValues] If set to false, then no data will be prefilled into the
22
- * component
22
+ * component. Default is true.
23
23
  * @param [mapping.waitForCollectionCallback] If set to true, it will return the entire collection to the callback as a single object
24
24
  * @param [mapping.selector] THIS PARAM IS ONLY USED WITH withOnyx(). If included, this will be used to subscribe to a subset of an Onyx key's data.
25
25
  * The sourceData and withOnyx state are passed to the selector and should return the simplified data. Using this setting on `withOnyx` can have very positive
package/dist/Onyx.js CHANGED
@@ -80,7 +80,7 @@ function init({ keys = {}, initialKeyStates = {}, safeEvictionKeys = [], maxCach
80
80
  * @param [mapping.callback] a method that will be called with changed data
81
81
  * This is used by any non-React code to connect to Onyx
82
82
  * @param [mapping.initWithStoredValues] If set to false, then no data will be prefilled into the
83
- * component
83
+ * component. Default is true.
84
84
  * @param [mapping.waitForCollectionCallback] If set to true, it will return the entire collection to the callback as a single object
85
85
  * @param [mapping.selector] THIS PARAM IS ONLY USED WITH withOnyx(). If included, this will be used to subscribe to a subset of an Onyx key's data.
86
86
  * The sourceData and withOnyx state are passed to the selector and should return the simplified data. Using this setting on `withOnyx` can have very positive
@@ -385,26 +385,14 @@ function merge(key, changes) {
385
385
  * @param collection Object collection keyed by individual collection member keys and values
386
386
  */
387
387
  function mergeCollection(collectionKey, collection) {
388
- if (typeof collection !== 'object' || Array.isArray(collection) || utils_1.default.isEmptyObject(collection)) {
388
+ if (!OnyxUtils_1.default.isValidNonEmptyCollectionForMerge(collection)) {
389
389
  Logger.logInfo('mergeCollection() called with invalid or empty value. Skipping this update.');
390
390
  return Promise.resolve();
391
391
  }
392
392
  const mergedCollection = collection;
393
393
  // Confirm all the collection keys belong to the same parent
394
- let hasCollectionKeyCheckFailed = false;
395
394
  const mergedCollectionKeys = Object.keys(mergedCollection);
396
- mergedCollectionKeys.forEach((dataKey) => {
397
- if (OnyxUtils_1.default.isKeyMatch(collectionKey, dataKey)) {
398
- return;
399
- }
400
- if (process.env.NODE_ENV === 'development') {
401
- throw new Error(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`);
402
- }
403
- hasCollectionKeyCheckFailed = true;
404
- Logger.logAlert(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`);
405
- });
406
- // Gracefully handle bad mergeCollection updates so it doesn't block the merge queue
407
- if (hasCollectionKeyCheckFailed) {
395
+ if (!OnyxUtils_1.default.doAllCollectionItemsBelongToSameParent(collectionKey, mergedCollectionKeys)) {
408
396
  return Promise.resolve();
409
397
  }
410
398
  return OnyxUtils_1.default.getAllKeys()
@@ -620,22 +608,53 @@ function update(data) {
620
608
  throw new Error(`Invalid ${typeof key} key provided in Onyx update. Onyx key must be of type string.`);
621
609
  }
622
610
  });
611
+ // The queue of operations within a single `update` call in the format of <item key - list of operations updating the item>.
612
+ // This allows us to batch the operations per item and merge them into one operation in the order they were requested.
613
+ const updateQueue = {};
614
+ const enqueueSetOperation = (key, value) => {
615
+ // If a `set` operation is enqueued, we should clear the whole queue.
616
+ // Since the `set` operation replaces the value entirely, there's no need to perform any previous operations.
617
+ // To do this, we first put `null` in the queue, which removes the existing value, and then merge the new value.
618
+ updateQueue[key] = [null, value];
619
+ };
620
+ const enqueueMergeOperation = (key, value) => {
621
+ if (value === null) {
622
+ // If we merge `null`, the value is removed and all the previous operations are discarded.
623
+ updateQueue[key] = [null];
624
+ }
625
+ else if (!updateQueue[key]) {
626
+ updateQueue[key] = [value];
627
+ }
628
+ else {
629
+ updateQueue[key].push(value);
630
+ }
631
+ };
623
632
  const promises = [];
624
633
  let clearPromise = Promise.resolve();
625
634
  data.forEach(({ onyxMethod, key, value }) => {
626
635
  switch (onyxMethod) {
627
636
  case OnyxUtils_1.default.METHOD.SET:
628
- promises.push(() => set(key, value));
637
+ enqueueSetOperation(key, value);
629
638
  break;
630
639
  case OnyxUtils_1.default.METHOD.MERGE:
631
- promises.push(() => merge(key, value));
640
+ enqueueMergeOperation(key, value);
632
641
  break;
633
- case OnyxUtils_1.default.METHOD.MERGE_COLLECTION:
634
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- We validated that the value is a collection
635
- promises.push(() => mergeCollection(key, value));
642
+ case OnyxUtils_1.default.METHOD.MERGE_COLLECTION: {
643
+ const collection = value;
644
+ if (!OnyxUtils_1.default.isValidNonEmptyCollectionForMerge(collection)) {
645
+ Logger.logInfo('mergeCollection enqueued within update() with invalid or empty value. Skipping this operation.');
646
+ break;
647
+ }
648
+ // Confirm all the collection keys belong to the same parent
649
+ const collectionKeys = Object.keys(collection);
650
+ if (OnyxUtils_1.default.doAllCollectionItemsBelongToSameParent(key, collectionKeys)) {
651
+ const mergedCollection = collection;
652
+ collectionKeys.forEach((collectionKey) => enqueueMergeOperation(collectionKey, mergedCollection[collectionKey]));
653
+ }
636
654
  break;
655
+ }
637
656
  case OnyxUtils_1.default.METHOD.MULTI_SET:
638
- promises.push(() => multiSet(value));
657
+ Object.entries(value).forEach(([entryKey, entryValue]) => enqueueSetOperation(entryKey, entryValue));
639
658
  break;
640
659
  case OnyxUtils_1.default.METHOD.CLEAR:
641
660
  clearPromise = clear();
@@ -644,6 +663,50 @@ function update(data) {
644
663
  break;
645
664
  }
646
665
  });
666
+ // Group all the collection-related keys and update each collection in a single `mergeCollection` call.
667
+ // This is needed to prevent multiple `mergeCollection` calls for the same collection and `merge` calls for the individual items of the said collection.
668
+ // This way, we ensure there is no race condition in the queued updates of the same key.
669
+ OnyxUtils_1.default.getCollectionKeys().forEach((collectionKey) => {
670
+ const collectionItemKeys = Object.keys(updateQueue).filter((key) => OnyxUtils_1.default.isKeyMatch(collectionKey, key));
671
+ if (collectionItemKeys.length <= 1) {
672
+ // If there are no items of this collection in the updateQueue, we should skip it.
673
+ // If there is only one item, we should update it individually, therefore retain it in the updateQueue.
674
+ return;
675
+ }
676
+ const batchedCollectionUpdates = collectionItemKeys.reduce((queue, key) => {
677
+ const operations = updateQueue[key];
678
+ // Remove the collection-related key from the updateQueue so that it won't be processed individually.
679
+ delete updateQueue[key];
680
+ const updatedValue = OnyxUtils_1.default.applyMerge(undefined, operations, false);
681
+ if (operations[0] === null) {
682
+ // eslint-disable-next-line no-param-reassign
683
+ queue.set[key] = updatedValue;
684
+ }
685
+ else {
686
+ // eslint-disable-next-line no-param-reassign
687
+ queue.merge[key] = updatedValue;
688
+ }
689
+ return queue;
690
+ }, {
691
+ merge: {},
692
+ set: {},
693
+ });
694
+ if (!utils_1.default.isEmptyObject(batchedCollectionUpdates.merge)) {
695
+ promises.push(() => mergeCollection(collectionKey, batchedCollectionUpdates.merge));
696
+ }
697
+ if (!utils_1.default.isEmptyObject(batchedCollectionUpdates.set)) {
698
+ promises.push(() => multiSet(batchedCollectionUpdates.set));
699
+ }
700
+ });
701
+ Object.entries(updateQueue).forEach(([key, operations]) => {
702
+ const batchedChanges = OnyxUtils_1.default.applyMerge(undefined, operations, false);
703
+ if (operations[0] === null) {
704
+ promises.push(() => set(key, batchedChanges));
705
+ }
706
+ else {
707
+ promises.push(() => merge(key, batchedChanges));
708
+ }
709
+ });
647
710
  return clearPromise
648
711
  .then(() => Promise.all(promises.map((p) => p())))
649
712
  .then(() => updateSnapshots(data))
@@ -1,6 +1,6 @@
1
1
  import type { ValueOf } from 'type-fest';
2
2
  import type Onyx from './Onyx';
3
- import type { CollectionKey, CollectionKeyBase, DeepRecord, KeyValueMapping, Mapping, OnyxCollection, OnyxEntry, OnyxInput, OnyxKey, OnyxValue, WithOnyxConnectOptions } from './types';
3
+ import type { CollectionKey, CollectionKeyBase, DeepRecord, KeyValueMapping, Mapping, OnyxCollection, OnyxEntry, OnyxInput, OnyxKey, OnyxMergeCollectionInput, OnyxValue, WithOnyxConnectOptions } from './types';
4
4
  declare const METHOD: {
5
5
  readonly SET: "set";
6
6
  readonly MERGE: "merge";
@@ -71,7 +71,11 @@ declare function deleteKeyByConnections(connectionID: number): void;
71
71
  /** Returns current key names stored in persisted storage */
72
72
  declare function getAllKeys(): Promise<Set<OnyxKey>>;
73
73
  /**
74
- * Checks to see if the a subscriber's supplied key
74
+ * Returns set of all registered collection keys
75
+ */
76
+ declare function getCollectionKeys(): Set<OnyxKey>;
77
+ /**
78
+ * Checks to see if the subscriber's supplied key
75
79
  * is associated with a collection of keys.
76
80
  */
77
81
  declare function isCollectionKey(key: OnyxKey): key is CollectionKeyBase;
@@ -141,7 +145,7 @@ declare function keysChanged<TKey extends CollectionKeyBase>(collectionKey: TKey
141
145
  * @example
142
146
  * keyChanged(key, value, subscriber => subscriber.initWithStoredValues === false)
143
147
  */
144
- declare function keyChanged<TKey extends OnyxKey>(key: TKey, value: OnyxValue<TKey>, previousValue: OnyxValue<TKey>, canUpdateSubscriber?: (subscriber?: Mapping<OnyxKey>) => boolean, notifyRegularSubscibers?: boolean, notifyWithOnyxSubscibers?: boolean): void;
148
+ declare function keyChanged<TKey extends OnyxKey>(key: TKey, value: OnyxValue<TKey>, previousValue: OnyxValue<TKey>, canUpdateSubscriber?: (subscriber?: Mapping<OnyxKey>) => boolean, notifyConnectSubscribers?: boolean, notifyWithOnyxSubscribers?: boolean): void;
145
149
  /**
146
150
  * Sends the data obtained from the keys to the connection. It either:
147
151
  * - sets state on the withOnyxInstances
@@ -216,6 +220,14 @@ declare function applyMerge<TValue extends OnyxInput<OnyxKey> | undefined, TChan
216
220
  * Merge user provided default key value pairs.
217
221
  */
218
222
  declare function initializeWithDefaultKeyStates(): Promise<void>;
223
+ /**
224
+ * Validate the collection is not empty and has a correct type before applying mergeCollection()
225
+ */
226
+ declare function isValidNonEmptyCollectionForMerge<TKey extends CollectionKeyBase, TMap>(collection: OnyxMergeCollectionInput<TKey, TMap>): boolean;
227
+ /**
228
+ * Verify if all the collection keys belong to the same parent
229
+ */
230
+ declare function doAllCollectionItemsBelongToSameParent<TKey extends CollectionKeyBase>(collectionKey: TKey, collectionKeys: string[]): boolean;
219
231
  declare const OnyxUtils: {
220
232
  METHOD: {
221
233
  readonly SET: "set";
@@ -234,6 +246,7 @@ declare const OnyxUtils: {
234
246
  batchUpdates: typeof batchUpdates;
235
247
  get: typeof get;
236
248
  getAllKeys: typeof getAllKeys;
249
+ getCollectionKeys: typeof getCollectionKeys;
237
250
  isCollectionKey: typeof isCollectionKey;
238
251
  isCollectionMemberKey: typeof isCollectionMemberKey;
239
252
  splitCollectionMemberKey: typeof splitCollectionMemberKey;
@@ -267,5 +280,7 @@ declare const OnyxUtils: {
267
280
  deleteKeyByConnections: typeof deleteKeyByConnections;
268
281
  getSnapshotKey: typeof getSnapshotKey;
269
282
  multiGet: typeof multiGet;
283
+ isValidNonEmptyCollectionForMerge: typeof isValidNonEmptyCollectionForMerge;
284
+ doAllCollectionItemsBelongToSameParent: typeof doAllCollectionItemsBelongToSameParent;
270
285
  };
271
286
  export default OnyxUtils;
package/dist/OnyxUtils.js CHANGED
@@ -49,10 +49,10 @@ const METHOD = {
49
49
  // Key/value store of Onyx key and arrays of values to merge
50
50
  const mergeQueue = {};
51
51
  const mergeQueuePromise = {};
52
- // Holds a mapping of all the react components that want their state subscribed to a store key
52
+ // Holds a mapping of all the React components that want their state subscribed to a store key
53
53
  const callbackToStateMapping = {};
54
54
  // Keeps a copy of the values of the onyx collection keys as a map for faster lookups
55
- let onyxCollectionKeyMap = new Map();
55
+ let onyxCollectionKeySet = new Set();
56
56
  // Holds a mapping of the connected key to the connectionID for faster lookups
57
57
  const onyxKeyToConnectionIDs = new Map();
58
58
  // Holds a list of keys that have been directly subscribed to or recently modified from least to most recent
@@ -67,6 +67,8 @@ const evictionBlocklist = {};
67
67
  let defaultKeyStates = {};
68
68
  let batchUpdatesPromise = null;
69
69
  let batchUpdatesQueue = [];
70
+ // Used for comparison with a new update to avoid invoking the Onyx.connect callback with the same data.
71
+ const lastConnectionCallbackData = new Map();
70
72
  let snapshotKey = null;
71
73
  function getSnapshotKey() {
72
74
  return snapshotKey;
@@ -107,10 +109,10 @@ function initStoreValues(keys, initialKeyStates, safeEvictionKeys) {
107
109
  // We need the value of the collection keys later for checking if a
108
110
  // key is a collection. We store it in a map for faster lookup.
109
111
  const collectionValues = Object.values((_a = keys.COLLECTION) !== null && _a !== void 0 ? _a : {});
110
- onyxCollectionKeyMap = collectionValues.reduce((acc, val) => {
111
- acc.set(val, true);
112
+ onyxCollectionKeySet = collectionValues.reduce((acc, val) => {
113
+ acc.add(val);
112
114
  return acc;
113
- }, new Map());
115
+ }, new Set());
114
116
  // Set our default key states to use when initializing and clearing Onyx data
115
117
  defaultKeyStates = initialKeyStates;
116
118
  DevTools_1.default.initState(initialKeyStates);
@@ -300,11 +302,17 @@ function getAllKeys() {
300
302
  return OnyxCache_1.default.captureTask(taskName, promise);
301
303
  }
302
304
  /**
303
- * Checks to see if the a subscriber's supplied key
305
+ * Returns set of all registered collection keys
306
+ */
307
+ function getCollectionKeys() {
308
+ return onyxCollectionKeySet;
309
+ }
310
+ /**
311
+ * Checks to see if the subscriber's supplied key
304
312
  * is associated with a collection of keys.
305
313
  */
306
314
  function isCollectionKey(key) {
307
- return onyxCollectionKeyMap.has(key);
315
+ return onyxCollectionKeySet.has(key);
308
316
  }
309
317
  function isCollectionMemberKey(collectionKey, key) {
310
318
  return Str.startsWith(key, collectionKey) && key.length > collectionKey.length;
@@ -619,7 +627,7 @@ function keysChanged(collectionKey, partialCollection, partialPreviousCollection
619
627
  * @example
620
628
  * keyChanged(key, value, subscriber => subscriber.initWithStoredValues === false)
621
629
  */
622
- function keyChanged(key, value, previousValue, canUpdateSubscriber = () => true, notifyRegularSubscibers = true, notifyWithOnyxSubscibers = true) {
630
+ function keyChanged(key, value, previousValue, canUpdateSubscriber = () => true, notifyConnectSubscribers = true, notifyWithOnyxSubscribers = true) {
623
631
  var _a, _b;
624
632
  // Add or remove this key from the recentlyAccessedKeys lists
625
633
  if (value !== null) {
@@ -651,7 +659,10 @@ function keyChanged(key, value, previousValue, canUpdateSubscriber = () => true,
651
659
  }
652
660
  // Subscriber is a regular call to connect() and provided a callback
653
661
  if (typeof subscriber.callback === 'function') {
654
- if (!notifyRegularSubscibers) {
662
+ if (!notifyConnectSubscribers) {
663
+ continue;
664
+ }
665
+ if (lastConnectionCallbackData.has(subscriber.connectionID) && lastConnectionCallbackData.get(subscriber.connectionID) === value) {
655
666
  continue;
656
667
  }
657
668
  if (isCollectionKey(subscriber.key) && subscriber.waitForCollectionCallback) {
@@ -662,11 +673,12 @@ function keyChanged(key, value, previousValue, canUpdateSubscriber = () => true,
662
673
  }
663
674
  const subscriberCallback = subscriber.callback;
664
675
  subscriberCallback(value, key);
676
+ lastConnectionCallbackData.set(subscriber.connectionID, value);
665
677
  continue;
666
678
  }
667
679
  // Subscriber connected via withOnyx() HOC
668
680
  if ('withOnyxInstance' in subscriber && subscriber.withOnyxInstance) {
669
- if (!notifyWithOnyxSubscibers) {
681
+ if (!notifyWithOnyxSubscribers) {
670
682
  continue;
671
683
  }
672
684
  const selector = subscriber.selector;
@@ -778,7 +790,14 @@ function sendDataToConnection(mapping, value, matchedKey, isBatched) {
778
790
  // If we would pass undefined to setWithOnyxInstance instead, withOnyx would not set the value in the state.
779
791
  // withOnyx will internally replace null values with undefined and never pass null values to wrapped components.
780
792
  // For regular callbacks, we never want to pass null values, but always just undefined if a value is not set in cache or storage.
781
- (_b = (_a = mapping).callback) === null || _b === void 0 ? void 0 : _b.call(_a, value === null ? undefined : value, matchedKey);
793
+ const valueToPass = value === null ? undefined : value;
794
+ const lastValue = lastConnectionCallbackData.get(mapping.connectionID);
795
+ lastConnectionCallbackData.get(mapping.connectionID);
796
+ // If the value has not changed we do not need to trigger the callback
797
+ if (lastConnectionCallbackData.has(mapping.connectionID) && valueToPass === lastValue) {
798
+ return;
799
+ }
800
+ (_b = (_a = mapping).callback) === null || _b === void 0 ? void 0 : _b.call(_a, valueToPass, matchedKey);
782
801
  }
783
802
  /**
784
803
  * We check to see if this key is flagged as safe for eviction and add it to the recentlyAccessedKeys list so that when we
@@ -955,6 +974,29 @@ function initializeWithDefaultKeyStates() {
955
974
  Object.entries(merged !== null && merged !== void 0 ? merged : {}).forEach(([key, value]) => keyChanged(key, value, existingDataAsObject));
956
975
  });
957
976
  }
977
+ /**
978
+ * Validate the collection is not empty and has a correct type before applying mergeCollection()
979
+ */
980
+ function isValidNonEmptyCollectionForMerge(collection) {
981
+ return typeof collection === 'object' && !Array.isArray(collection) && !utils_1.default.isEmptyObject(collection);
982
+ }
983
+ /**
984
+ * Verify if all the collection keys belong to the same parent
985
+ */
986
+ function doAllCollectionItemsBelongToSameParent(collectionKey, collectionKeys) {
987
+ let hasCollectionKeyCheckFailed = false;
988
+ collectionKeys.forEach((dataKey) => {
989
+ if (OnyxUtils.isKeyMatch(collectionKey, dataKey)) {
990
+ return;
991
+ }
992
+ if (process.env.NODE_ENV === 'development') {
993
+ throw new Error(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`);
994
+ }
995
+ hasCollectionKeyCheckFailed = true;
996
+ Logger.logAlert(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`);
997
+ });
998
+ return !hasCollectionKeyCheckFailed;
999
+ }
958
1000
  const OnyxUtils = {
959
1001
  METHOD,
960
1002
  getMergeQueue,
@@ -967,6 +1009,7 @@ const OnyxUtils = {
967
1009
  batchUpdates,
968
1010
  get,
969
1011
  getAllKeys,
1012
+ getCollectionKeys,
970
1013
  isCollectionKey,
971
1014
  isCollectionMemberKey,
972
1015
  splitCollectionMemberKey,
@@ -1000,5 +1043,7 @@ const OnyxUtils = {
1000
1043
  deleteKeyByConnections,
1001
1044
  getSnapshotKey,
1002
1045
  multiGet,
1046
+ isValidNonEmptyCollectionForMerge,
1047
+ doAllCollectionItemsBelongToSameParent,
1003
1048
  };
1004
1049
  exports.default = OnyxUtils;
package/dist/types.d.ts CHANGED
@@ -365,4 +365,11 @@ type InitOptions = {
365
365
  debugSetState?: boolean;
366
366
  };
367
367
  type GenericFunction = (...args: any[]) => any;
368
- export type { BaseConnectOptions, Collection, CollectionConnectCallback, CollectionConnectOptions, CollectionKey, CollectionKeyBase, ConnectOptions, CustomTypeOptions, DeepRecord, DefaultConnectCallback, DefaultConnectOptions, ExtractOnyxCollectionValue, GenericFunction, InitOptions, Key, KeyValueMapping, Mapping, NonNull, NonUndefined, OnyxInputKeyValueMapping, NullishDeep, OnyxCollection, OnyxEntry, OnyxKey, OnyxInputValue, OnyxCollectionInputValue, OnyxInput, OnyxSetInput, OnyxMultiSetInput, OnyxMergeInput, OnyxMergeCollectionInput, OnyxUpdate, OnyxValue, Selector, WithOnyxConnectOptions, };
368
+ /**
369
+ * Represents a combination of Merge and Set operations that should be executed in Onyx
370
+ */
371
+ type MixedOperationsQueue = {
372
+ merge: OnyxInputKeyValueMapping;
373
+ set: OnyxInputKeyValueMapping;
374
+ };
375
+ export type { BaseConnectOptions, Collection, CollectionConnectCallback, CollectionConnectOptions, CollectionKey, CollectionKeyBase, ConnectOptions, CustomTypeOptions, DeepRecord, DefaultConnectCallback, DefaultConnectOptions, ExtractOnyxCollectionValue, GenericFunction, InitOptions, Key, KeyValueMapping, Mapping, NonNull, NonUndefined, OnyxInputKeyValueMapping, NullishDeep, OnyxCollection, OnyxEntry, OnyxKey, OnyxInputValue, OnyxCollectionInputValue, OnyxInput, OnyxSetInput, OnyxMultiSetInput, OnyxMergeInput, OnyxMergeCollectionInput, OnyxUpdate, OnyxValue, Selector, WithOnyxConnectOptions, MixedOperationsQueue, };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-onyx",
3
- "version": "2.0.56",
3
+ "version": "2.0.58",
4
4
  "author": "Expensify, Inc.",
5
5
  "homepage": "https://expensify.com",
6
6
  "description": "State management for React Native",