react-native-onyx 1.0.1

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/lib/Onyx.js ADDED
@@ -0,0 +1,901 @@
1
+ import _ from 'underscore';
2
+ import Str from 'expensify-common/lib/str';
3
+ import lodashMerge from 'lodash/merge';
4
+ import Storage from './storage';
5
+
6
+ import {registerLogger, logInfo, logAlert} from './Logger';
7
+ import cache from './OnyxCache';
8
+ import createDeferredTask from './createDeferredTask';
9
+
10
+ // Keeps track of the last connectionID that was used so we can keep incrementing it
11
+ let lastConnectionID = 0;
12
+
13
+ // Holds a mapping of all the react components that want their state subscribed to a store key
14
+ const callbackToStateMapping = {};
15
+
16
+ // Stores all of the keys that Onyx can use. Must be defined in init().
17
+ let onyxKeys = {};
18
+
19
+ // Holds a list of keys that have been directly subscribed to or recently modified from least to most recent
20
+ let recentlyAccessedKeys = [];
21
+
22
+ // Holds a list of keys that are safe to remove when we reach max storage. If a key does not match with
23
+ // whatever appears in this list it will NEVER be a candidate for eviction.
24
+ let evictionAllowList = [];
25
+
26
+ // Holds a map of keys and connectionID arrays whose keys will never be automatically evicted as
27
+ // long as we have at least one subscriber that returns false for the canEvict property.
28
+ const evictionBlocklist = {};
29
+
30
+ // Optional user-provided key value states set when Onyx initializes or clears
31
+ let defaultKeyStates = {};
32
+
33
+ // Connections can be made before `Onyx.init`. They would wait for this task before resolving
34
+ const deferredInitTask = createDeferredTask();
35
+
36
+ /**
37
+ * Get some data from the store
38
+ *
39
+ * @private
40
+ * @param {string} key
41
+ * @returns {Promise<*>}
42
+ */
43
+ function get(key) {
44
+ // When we already have the value in cache - resolve right away
45
+ if (cache.hasCacheForKey(key)) {
46
+ return Promise.resolve(cache.getValue(key));
47
+ }
48
+
49
+ const taskName = `get:${key}`;
50
+
51
+ // When a value retrieving task for this key is still running hook to it
52
+ if (cache.hasPendingTask(taskName)) {
53
+ return cache.getTaskPromise(taskName);
54
+ }
55
+
56
+ // Otherwise retrieve the value from storage and capture a promise to aid concurrent usages
57
+ const promise = Storage.getItem(key)
58
+ .then((val) => {
59
+ cache.set(key, val);
60
+ return val;
61
+ })
62
+ .catch(err => logInfo(`Unable to get item from persistent storage. Key: ${key} Error: ${err}`));
63
+
64
+ return cache.captureTask(taskName, promise);
65
+ }
66
+
67
+ /**
68
+ * Returns current key names stored in persisted storage
69
+ * @private
70
+ * @returns {Promise<string[]>}
71
+ */
72
+ function getAllKeys() {
73
+ // When we've already read stored keys, resolve right away
74
+ const storedKeys = cache.getAllKeys();
75
+ if (storedKeys.length > 0) {
76
+ return Promise.resolve(storedKeys);
77
+ }
78
+
79
+ const taskName = 'getAllKeys';
80
+
81
+ // When a value retrieving task for all keys is still running hook to it
82
+ if (cache.hasPendingTask(taskName)) {
83
+ return cache.getTaskPromise(taskName);
84
+ }
85
+
86
+ // Otherwise retrieve the keys from storage and capture a promise to aid concurrent usages
87
+ const promise = Storage.getAllKeys()
88
+ .then((keys) => {
89
+ _.each(keys, key => cache.addKey(key));
90
+ return keys;
91
+ });
92
+
93
+ return cache.captureTask(taskName, promise);
94
+ }
95
+
96
+ /**
97
+ * Checks to see if the a subscriber's supplied key
98
+ * is associated with a collection of keys.
99
+ *
100
+ * @private
101
+ * @param {String} key
102
+ * @returns {Boolean}
103
+ */
104
+ function isCollectionKey(key) {
105
+ return _.contains(_.values(onyxKeys.COLLECTION), key);
106
+ }
107
+
108
+ /**
109
+ * Checks to see if a given key matches with the
110
+ * configured key of our connected subscriber
111
+ *
112
+ * @private
113
+ * @param {String} configKey
114
+ * @param {String} key
115
+ * @return {Boolean}
116
+ */
117
+ function isKeyMatch(configKey, key) {
118
+ return isCollectionKey(configKey)
119
+ ? Str.startsWith(key, configKey)
120
+ : configKey === key;
121
+ }
122
+
123
+ /**
124
+ * Checks to see if this key has been flagged as
125
+ * safe for removal.
126
+ *
127
+ * @private
128
+ * @param {String} testKey
129
+ * @returns {Boolean}
130
+ */
131
+ function isSafeEvictionKey(testKey) {
132
+ return _.some(evictionAllowList, key => isKeyMatch(key, testKey));
133
+ }
134
+
135
+ /**
136
+ * Remove a key from the recently accessed key list.
137
+ *
138
+ * @private
139
+ * @param {String} key
140
+ */
141
+ function removeLastAccessedKey(key) {
142
+ recentlyAccessedKeys = _.without(recentlyAccessedKeys, key);
143
+ }
144
+
145
+ /**
146
+ * Add a key to the list of recently accessed keys. The least
147
+ * recently accessed key should be at the head and the most
148
+ * recently accessed key at the tail.
149
+ *
150
+ * @private
151
+ * @param {String} key
152
+ */
153
+ function addLastAccessedKey(key) {
154
+ // Only specific keys belong in this list since we cannot remove an entire collection.
155
+ if (isCollectionKey(key) || !isSafeEvictionKey(key)) {
156
+ return;
157
+ }
158
+
159
+ removeLastAccessedKey(key);
160
+ recentlyAccessedKeys.push(key);
161
+ }
162
+
163
+ /**
164
+ * Removes a key previously added to this list
165
+ * which will enable it to be deleted again.
166
+ *
167
+ * @private
168
+ * @param {String} key
169
+ * @param {Number} connectionID
170
+ */
171
+ function removeFromEvictionBlockList(key, connectionID) {
172
+ evictionBlocklist[key] = _.without(evictionBlocklist[key] || [], connectionID);
173
+
174
+ // Remove the key if there are no more subscribers
175
+ if (evictionBlocklist[key].length === 0) {
176
+ delete evictionBlocklist[key];
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Keys added to this list can never be deleted.
182
+ *
183
+ * @private
184
+ * @param {String} key
185
+ * @param {Number} connectionID
186
+ */
187
+ function addToEvictionBlockList(key, connectionID) {
188
+ removeFromEvictionBlockList(key, connectionID);
189
+
190
+ if (!evictionBlocklist[key]) {
191
+ evictionBlocklist[key] = [];
192
+ }
193
+
194
+ evictionBlocklist[key].push(connectionID);
195
+ }
196
+
197
+ /**
198
+ * Take all the keys that are safe to evict and add them to
199
+ * the recently accessed list when initializing the app. This
200
+ * enables keys that have not recently been accessed to be
201
+ * removed.
202
+ *
203
+ * @private
204
+ * @returns {Promise}
205
+ */
206
+ function addAllSafeEvictionKeysToRecentlyAccessedList() {
207
+ return getAllKeys()
208
+ .then((keys) => {
209
+ _.each(evictionAllowList, (safeEvictionKey) => {
210
+ _.each(keys, (key) => {
211
+ if (isKeyMatch(safeEvictionKey, key)) {
212
+ addLastAccessedKey(key);
213
+ }
214
+ });
215
+ });
216
+ });
217
+ }
218
+
219
+ /**
220
+ * @private
221
+ * @param {String} collectionKey
222
+ * @returns {Object}
223
+ */
224
+ function getCachedCollection(collectionKey) {
225
+ const collectionMemberKeys = _.filter(cache.getAllKeys(), (
226
+ storedKey => isKeyMatch(collectionKey, storedKey)
227
+ ));
228
+
229
+ return _.reduce(collectionMemberKeys, (prev, curr) => {
230
+ const cachedValue = cache.getValue(curr);
231
+ if (!cachedValue) {
232
+ return prev;
233
+ }
234
+ return ({
235
+ ...prev,
236
+ [curr]: cachedValue,
237
+ });
238
+ }, {});
239
+ }
240
+
241
+ /**
242
+ * When a collection of keys change, search for any callbacks matching the collection key and trigger those callbacks
243
+ *
244
+ * @private
245
+ * @param {String} collectionKey
246
+ * @param {Object} collection
247
+ */
248
+ function keysChanged(collectionKey, collection) {
249
+ // Find all subscribers that were added with connect() and trigger the callback or setState() with the new data
250
+ _.each(callbackToStateMapping, (subscriber) => {
251
+ if (!subscriber) {
252
+ return;
253
+ }
254
+
255
+ const isSubscribedToCollectionKey = isKeyMatch(subscriber.key, collectionKey)
256
+ && isCollectionKey(subscriber.key);
257
+ const isSubscribedToCollectionMemberKey = subscriber.key.startsWith(collectionKey);
258
+
259
+ if (isSubscribedToCollectionKey) {
260
+ if (_.isFunction(subscriber.callback)) {
261
+ // eslint-disable-next-line no-use-before-define
262
+ const cachedCollection = getCachedCollection(collectionKey);
263
+ _.each(collection, (data, dataKey) => {
264
+ subscriber.callback(cachedCollection[dataKey], dataKey);
265
+ });
266
+ } else if (subscriber.withOnyxInstance) {
267
+ subscriber.withOnyxInstance.setState((prevState) => {
268
+ const finalCollection = _.clone(prevState[subscriber.statePropertyName] || {});
269
+ _.each(collection, (data, dataKey) => {
270
+ if (finalCollection[dataKey]) {
271
+ lodashMerge(finalCollection[dataKey], data);
272
+ } else {
273
+ finalCollection[dataKey] = data;
274
+ }
275
+ });
276
+
277
+ return {
278
+ [subscriber.statePropertyName]: finalCollection,
279
+ };
280
+ });
281
+ }
282
+ } else if (isSubscribedToCollectionMemberKey) {
283
+ const dataFromCollection = collection[subscriber.key];
284
+
285
+ // If `dataFromCollection` happens to not exist, then return early so that there are no unnecessary
286
+ // re-renderings of the component
287
+ if (_.isUndefined(dataFromCollection)) {
288
+ return;
289
+ }
290
+
291
+ subscriber.withOnyxInstance.setState(prevState => ({
292
+ [subscriber.statePropertyName]: _.isObject(dataFromCollection)
293
+ ? {
294
+ ...prevState[subscriber.statePropertyName],
295
+ ...dataFromCollection,
296
+ }
297
+ : dataFromCollection,
298
+ }));
299
+ }
300
+ });
301
+ }
302
+
303
+ /**
304
+ * When a key change happens, search for any callbacks matching the key or collection key and trigger those callbacks
305
+ *
306
+ * @private
307
+ * @param {String} key
308
+ * @param {*} data
309
+ */
310
+ function keyChanged(key, data) {
311
+ // Add or remove this key from the recentlyAccessedKeys lists
312
+ if (!_.isNull(data)) {
313
+ addLastAccessedKey(key);
314
+ } else {
315
+ removeLastAccessedKey(key);
316
+ }
317
+
318
+ // Find all subscribers that were added with connect() and trigger the callback or setState() with the new data
319
+ _.each(callbackToStateMapping, (subscriber) => {
320
+ if (subscriber && isKeyMatch(subscriber.key, key)) {
321
+ if (_.isFunction(subscriber.callback)) {
322
+ subscriber.callback(data, key);
323
+ }
324
+
325
+ if (!subscriber.withOnyxInstance) {
326
+ return;
327
+ }
328
+
329
+ // Check if we are subscribing to a collection key and add this item as a collection
330
+ if (isCollectionKey(subscriber.key)) {
331
+ subscriber.withOnyxInstance.setState((prevState) => {
332
+ const collection = _.clone(prevState[subscriber.statePropertyName] || {});
333
+ collection[key] = data;
334
+ return {
335
+ [subscriber.statePropertyName]: collection,
336
+ };
337
+ });
338
+ } else {
339
+ subscriber.withOnyxInstance.setState({
340
+ [subscriber.statePropertyName]: data,
341
+ });
342
+ }
343
+ }
344
+ });
345
+ }
346
+
347
+ /**
348
+ * Sends the data obtained from the keys to the connection. It either:
349
+ * - sets state on the withOnyxInstances
350
+ * - triggers the callback function
351
+ *
352
+ * @private
353
+ * @param {object} config
354
+ * @param {object} [config.withOnyxInstance]
355
+ * @param {string} [config.statePropertyName]
356
+ * @param {function} [config.callback]
357
+ * @param {*|null} val
358
+ * @param {String} key
359
+ */
360
+ function sendDataToConnection(config, val, key) {
361
+ // If the mapping no longer exists then we should not send any data.
362
+ // This means our subscriber disconnected or withOnyx wrapped component unmounted.
363
+ if (!callbackToStateMapping[config.connectionID]) {
364
+ return;
365
+ }
366
+
367
+ if (config.withOnyxInstance) {
368
+ config.withOnyxInstance.setWithOnyxState(config.statePropertyName, val);
369
+ } else if (_.isFunction(config.callback)) {
370
+ config.callback(val, key);
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Subscribes a react component's state directly to a store key
376
+ *
377
+ * @example
378
+ * const connectionID = Onyx.connect({
379
+ * key: ONYXKEYS.SESSION,
380
+ * callback: onSessionChange,
381
+ * });
382
+ *
383
+ * @param {Object} mapping the mapping information to connect Onyx to the components state
384
+ * @param {String} mapping.key ONYXKEY to subscribe to
385
+ * @param {String} [mapping.statePropertyName] the name of the property in the state to connect the data to
386
+ * @param {Object} [mapping.withOnyxInstance] whose setState() method will be called with any changed data
387
+ * This is used by React components to connect to Onyx
388
+ * @param {Function} [mapping.callback] a method that will be called with changed data
389
+ * This is used by any non-React code to connect to Onyx
390
+ * @param {Boolean} [mapping.initWithStoredValues] If set to false, then no data will be prefilled into the
391
+ * component
392
+ * @returns {Number} an ID to use when calling disconnect
393
+ */
394
+ function connect(mapping) {
395
+ const connectionID = lastConnectionID++;
396
+ callbackToStateMapping[connectionID] = mapping;
397
+ callbackToStateMapping[connectionID].connectionID = connectionID;
398
+
399
+ if (mapping.initWithStoredValues === false) {
400
+ return connectionID;
401
+ }
402
+
403
+ // Commit connection only after init passes
404
+ deferredInitTask.promise
405
+ .then(() => {
406
+ // Check to see if this key is flagged as a safe eviction key and add it to the recentlyAccessedKeys list
407
+ if (isSafeEvictionKey(mapping.key)) {
408
+ // Try to free some cache whenever we connect to a safe eviction key
409
+ cache.removeLeastRecentlyUsedKeys();
410
+
411
+ if (mapping.withOnyxInstance && !isCollectionKey(mapping.key)) {
412
+ // All React components subscribing to a key flagged as a safe eviction
413
+ // key must implement the canEvict property.
414
+ if (_.isUndefined(mapping.canEvict)) {
415
+ throw new Error(
416
+ `Cannot subscribe to safe eviction key '${mapping.key}' without providing a canEvict value.`
417
+ );
418
+ }
419
+
420
+ addLastAccessedKey(mapping.key);
421
+ }
422
+ }
423
+ })
424
+ .then(getAllKeys)
425
+ .then((keys) => {
426
+ // Find all the keys matched by the config key
427
+ const matchingKeys = _.filter(keys, key => isKeyMatch(mapping.key, key));
428
+
429
+ // If the key being connected to does not exist, initialize the value with null
430
+ if (matchingKeys.length === 0) {
431
+ sendDataToConnection(mapping, null);
432
+ return;
433
+ }
434
+
435
+ // When using a callback subscriber we will trigger the callback
436
+ // for each key we find. It's up to the subscriber to know whether
437
+ // to expect a single key or multiple keys in the case of a collection.
438
+ // React components are an exception since we'll want to send their
439
+ // initial data as a single object when using collection keys.
440
+ if (mapping.withOnyxInstance && isCollectionKey(mapping.key)) {
441
+ Promise.all(_.map(matchingKeys, key => get(key)))
442
+ .then(values => _.reduce(values, (finalObject, value, i) => ({
443
+ ...finalObject,
444
+ [matchingKeys[i]]: value,
445
+ }), {}))
446
+ .then(val => sendDataToConnection(mapping, val));
447
+ } else {
448
+ _.each(matchingKeys, (key) => {
449
+ get(key).then(val => sendDataToConnection(mapping, val, key));
450
+ });
451
+ }
452
+ });
453
+
454
+ return connectionID;
455
+ }
456
+
457
+ /**
458
+ * Remove the listener for a react component
459
+ * @example
460
+ * Onyx.disconnect(connectionID);
461
+ *
462
+ * @param {Number} connectionID unique id returned by call to Onyx.connect()
463
+ * @param {String} [keyToRemoveFromEvictionBlocklist]
464
+ */
465
+ function disconnect(connectionID, keyToRemoveFromEvictionBlocklist) {
466
+ if (!callbackToStateMapping[connectionID]) {
467
+ return;
468
+ }
469
+
470
+ // Remove this key from the eviction block list as we are no longer
471
+ // subscribing to it and it should be safe to delete again
472
+ if (keyToRemoveFromEvictionBlocklist) {
473
+ removeFromEvictionBlockList(keyToRemoveFromEvictionBlocklist, connectionID);
474
+ }
475
+
476
+ delete callbackToStateMapping[connectionID];
477
+ }
478
+
479
+ /**
480
+ * Remove a key from Onyx and update the subscribers
481
+ *
482
+ * @private
483
+ * @param {String} key
484
+ * @return {Promise}
485
+ */
486
+ function remove(key) {
487
+ // Cache the fact that the value was removed
488
+ cache.set(key, null);
489
+
490
+ // Optimistically inform subscribers on the next tick
491
+ Promise.resolve().then(() => keyChanged(key, null));
492
+
493
+ return Storage.removeItem(key);
494
+ }
495
+
496
+ /**
497
+ * If we fail to set or merge we must handle this by
498
+ * evicting some data from Onyx and then retrying to do
499
+ * whatever it is we attempted to do.
500
+ *
501
+ * @private
502
+ * @param {Error} error
503
+ * @param {Function} onyxMethod
504
+ * @param {...any} args
505
+ * @return {Promise}
506
+ */
507
+ function evictStorageAndRetry(error, onyxMethod, ...args) {
508
+ logInfo(`Handled error: ${error}`);
509
+
510
+ if (error && Str.startsWith(error.message, 'Failed to execute \'put\' on \'IDBObjectStore\'')) {
511
+ logAlert('Attempted to set invalid data set in Onyx. Please ensure all data is serializable.');
512
+ throw error;
513
+ }
514
+
515
+ // Find the first key that we can remove that has no subscribers in our blocklist
516
+ const keyForRemoval = _.find(recentlyAccessedKeys, key => !evictionBlocklist[key]);
517
+
518
+ if (!keyForRemoval) {
519
+ logAlert('Out of storage. But found no acceptable keys to remove.');
520
+ throw error;
521
+ }
522
+
523
+ // Remove the least recently viewed key that is not currently being accessed and retry.
524
+ logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying.`);
525
+ return remove(keyForRemoval)
526
+ .then(() => onyxMethod(...args));
527
+ }
528
+
529
+ /**
530
+ * Write a value to our store with the given key
531
+ *
532
+ * @param {String} key ONYXKEY to set
533
+ * @param {*} value value to store
534
+ *
535
+ * @returns {Promise}
536
+ */
537
+ function set(key, value) {
538
+ // Logging properties only since values could be sensitive things we don't want to log
539
+ logInfo(`set() called for key: ${key}${_.isObject(value) ? ` properties: ${_.keys(value).join(',')}` : ''}`);
540
+
541
+ // eslint-disable-next-line no-use-before-define
542
+ if (hasPendingMergeForKey(key)) {
543
+ // eslint-disable-next-line max-len
544
+ logAlert(`Onyx.set() called after Onyx.merge() for key: ${key}. It is recommended to use set() or merge() not both.`);
545
+ }
546
+
547
+ // Adds the key to cache when it's not available
548
+ cache.set(key, value);
549
+
550
+ // Optimistically inform subscribers on the next tick
551
+ Promise.resolve().then(() => keyChanged(key, value));
552
+
553
+ // Write the thing to persistent storage, which will trigger a storage event for any other tabs open on this domain
554
+ return Storage.setItem(key, value)
555
+ .catch(error => evictStorageAndRetry(error, set, key, value));
556
+ }
557
+
558
+ /**
559
+ * Storage expects array like: [["@MyApp_user", value_1], ["@MyApp_key", value_2]]
560
+ * This method transforms an object like {'@MyApp_user': myUserValue, '@MyApp_key': myKeyValue}
561
+ * to an array of key-value pairs in the above format
562
+ * @private
563
+ * @param {Record} data
564
+ * @return {Array} an array of key - value pairs <[key, value]>
565
+ */
566
+ function prepareKeyValuePairsForStorage(data) {
567
+ return _.map(data, (value, key) => [key, value]);
568
+ }
569
+
570
+ /**
571
+ * Sets multiple keys and values
572
+ *
573
+ * @example Onyx.multiSet({'key1': 'a', 'key2': 'b'});
574
+ *
575
+ * @param {Object} data object keyed by ONYXKEYS and the values to set
576
+ * @returns {Promise}
577
+ */
578
+ function multiSet(data) {
579
+ const keyValuePairs = prepareKeyValuePairsForStorage(data);
580
+
581
+ _.each(data, (val, key) => {
582
+ // Update cache and optimistically inform subscribers on the next tick
583
+ cache.set(key, val);
584
+ Promise.resolve().then(() => keyChanged(key, val));
585
+ });
586
+
587
+ return Storage.multiSet(keyValuePairs)
588
+ .catch(error => evictStorageAndRetry(error, multiSet, data));
589
+ }
590
+
591
+ // Key/value store of Onyx key and arrays of values to merge
592
+ const mergeQueue = {};
593
+
594
+ /**
595
+ * @private
596
+ * @param {String} key
597
+ * @returns {Boolean}
598
+ */
599
+ function hasPendingMergeForKey(key) {
600
+ return Boolean(mergeQueue[key]);
601
+ }
602
+
603
+ /**
604
+ * Given an Onyx key and value this method will combine all queued
605
+ * value updates and return a single value. Merge attempts are
606
+ * batched. They must occur after a single call to get() so we
607
+ * can avoid race conditions.
608
+ *
609
+ * @private
610
+ * @param {String} key
611
+ * @param {*} data
612
+ *
613
+ * @returns {*}
614
+ */
615
+ function applyMerge(key, data) {
616
+ const mergeValues = mergeQueue[key];
617
+ if (_.isArray(data) || _.every(mergeValues, _.isArray)) {
618
+ // Array values will always just concatenate
619
+ // more items onto the end of the array
620
+ return _.reduce(mergeValues, (modifiedData, mergeValue) => [
621
+ ...modifiedData,
622
+ ...mergeValue,
623
+ ], data || []);
624
+ }
625
+
626
+ if (_.isObject(data) || _.every(mergeValues, _.isObject)) {
627
+ // Object values are merged one after the other
628
+ return _.reduce(mergeValues, (modifiedData, mergeValue) => {
629
+ const newData = lodashMerge({}, modifiedData, mergeValue);
630
+
631
+ // We will also delete any object keys that are undefined or null.
632
+ // Deleting keys is not supported by AsyncStorage so we do it this way.
633
+ // Remove all first level keys that are explicitly set to null.
634
+ return _.omit(newData, (value, finalObjectKey) => _.isNull(mergeValue[finalObjectKey]));
635
+ }, data || {});
636
+ }
637
+
638
+ // If we have anything else we can't merge it so we'll
639
+ // simply return the last value that was queued
640
+ return _.last(mergeValues);
641
+ }
642
+
643
+ /**
644
+ * Merge a new value into an existing value at a key.
645
+ *
646
+ * The types of values that can be merged are `Object` and `Array`. To set another type of value use `Onyx.set()`. Merge
647
+ * behavior uses lodash/merge under the hood for `Object` and simple concatenation for `Array`. However, it's important
648
+ * to note that if you have an array value property on an `Object` that the default behavior of lodash/merge is not to
649
+ * concatenate. See here: https://github.com/lodash/lodash/issues/2872
650
+ *
651
+ * Calls to `Onyx.merge()` are batched so that any calls performed in a single tick will stack in a queue and get
652
+ * applied in the order they were called. Note: `Onyx.set()` calls do not work this way so use caution when mixing
653
+ * `Onyx.merge()` and `Onyx.set()`.
654
+ *
655
+ * @example
656
+ * Onyx.merge(ONYXKEYS.EMPLOYEE_LIST, ['Joe']); // -> ['Joe']
657
+ * Onyx.merge(ONYXKEYS.EMPLOYEE_LIST, ['Jack']); // -> ['Joe', 'Jack']
658
+ * Onyx.merge(ONYXKEYS.POLICY, {id: 1}); // -> {id: 1}
659
+ * Onyx.merge(ONYXKEYS.POLICY, {name: 'My Workspace'}); // -> {id: 1, name: 'My Workspace'}
660
+ *
661
+ * @param {String} key ONYXKEYS key
662
+ * @param {(Object|Array)} value Object or Array value to merge
663
+ * @returns {Promise}
664
+ */
665
+ function merge(key, value) {
666
+ if (mergeQueue[key]) {
667
+ mergeQueue[key].push(value);
668
+ return Promise.resolve();
669
+ }
670
+
671
+ mergeQueue[key] = [value];
672
+ return get(key)
673
+ .then((data) => {
674
+ try {
675
+ const modifiedData = applyMerge(key, data);
676
+
677
+ // Clean up the write queue so we
678
+ // don't apply these changes again
679
+ delete mergeQueue[key];
680
+
681
+ return set(key, modifiedData);
682
+ } catch (error) {
683
+ logAlert(`An error occurred while applying merge for key: ${key}, Error: ${error}`);
684
+ }
685
+
686
+ return Promise.resolve();
687
+ });
688
+ }
689
+
690
+ /**
691
+ * Merge user provided default key value pairs.
692
+ * @private
693
+ * @returns {Promise}
694
+ */
695
+ function initializeWithDefaultKeyStates() {
696
+ return Storage.multiGet(_.keys(defaultKeyStates))
697
+ .then((pairs) => {
698
+ const asObject = _.object(pairs);
699
+
700
+ const merged = lodashMerge(asObject, defaultKeyStates);
701
+ cache.merge(merged);
702
+ _.each(merged, (val, key) => keyChanged(key, val));
703
+ });
704
+ }
705
+
706
+ /**
707
+ * Clear out all the data in the store
708
+ *
709
+ * @returns {Promise<void>}
710
+ */
711
+ function clear() {
712
+ return getAllKeys()
713
+ .then((keys) => {
714
+ _.each(keys, (key) => {
715
+ keyChanged(key, null);
716
+ cache.set(key, null);
717
+ });
718
+ })
719
+ .then(Storage.clear)
720
+ .then(initializeWithDefaultKeyStates);
721
+ }
722
+
723
+ /**
724
+ * Merges a collection based on their keys
725
+ *
726
+ * @example
727
+ *
728
+ * Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, {
729
+ * [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1,
730
+ * [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2,
731
+ * });
732
+ *
733
+ * @param {String} collectionKey e.g. `ONYXKEYS.COLLECTION.REPORT`
734
+ * @param {Object} collection Object collection keyed by individual collection member keys and values
735
+ * @returns {Promise}
736
+ */
737
+ function mergeCollection(collectionKey, collection) {
738
+ // Confirm all the collection keys belong to the same parent
739
+ _.each(collection, (data, dataKey) => {
740
+ if (!isKeyMatch(collectionKey, dataKey)) {
741
+ // eslint-disable-next-line max-len
742
+ throw new Error(`Provided collection does not have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`);
743
+ }
744
+ });
745
+
746
+ return getAllKeys()
747
+ .then((persistedKeys) => {
748
+ // Split to keys that exist in storage and keys that don't
749
+ const [existingKeys, newKeys] = _.chain(collection)
750
+ .keys()
751
+ .partition(key => persistedKeys.includes(key))
752
+ .value();
753
+
754
+ const existingKeyCollection = _.pick(collection, existingKeys);
755
+ const newCollection = _.pick(collection, newKeys);
756
+ const keyValuePairsForExistingCollection = prepareKeyValuePairsForStorage(existingKeyCollection);
757
+ const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage(newCollection);
758
+
759
+ const promises = [];
760
+
761
+ // New keys will be added via multiSet while existing keys will be updated using multiMerge
762
+ // This is because setting a key that doesn't exist yet with multiMerge will throw errors
763
+ if (keyValuePairsForExistingCollection.length > 0) {
764
+ promises.push(Storage.multiMerge(keyValuePairsForExistingCollection));
765
+ }
766
+
767
+ if (keyValuePairsForNewCollection.length > 0) {
768
+ promises.push(Storage.multiSet(keyValuePairsForNewCollection));
769
+ }
770
+
771
+ // Prefill cache if necessary by calling get() on any existing keys and then merge original data to cache
772
+ // and update all subscribers
773
+ Promise.all(_.map(existingKeys, get)).then(() => {
774
+ cache.merge(collection);
775
+ keysChanged(collectionKey, collection);
776
+ });
777
+
778
+ return Promise.all(promises)
779
+ .catch(error => evictStorageAndRetry(error, mergeCollection, collection));
780
+ });
781
+ }
782
+
783
+ /**
784
+ * Initialize the store with actions and listening for storage events
785
+ *
786
+ * @param {Object} [options={}] config object
787
+ * @param {Object} [options.keys={}] `ONYXKEYS` constants object
788
+ * @param {Object} [options.initialKeyStates={}] initial data to set when `init()` and `clear()` is called
789
+ * @param {String[]} [options.safeEvictionKeys=[]] This is an array of keys
790
+ * (individual or collection patterns) that when provided to Onyx are flagged
791
+ * as "safe" for removal. Any components subscribing to these keys must also
792
+ * implement a canEvict option. See the README for more info.
793
+ * @param {Number} [options.maxCachedKeysCount=55] Sets how many recent keys should we try to keep in cache
794
+ * Setting this to 0 would practically mean no cache
795
+ * We try to free cache when we connect to a safe eviction key
796
+ * @param {Boolean} [options.captureMetrics] Enables Onyx benchmarking and exposes the get/print/reset functions
797
+ * @param {Boolean} [options.shouldSyncMultipleInstances] Auto synchronize storage events between multiple instances
798
+ * of Onyx running in different tabs/windows. Defaults to true for platforms that support local storage (web/desktop)
799
+ * @param {String[]} [option.keysToDisableSyncEvents=[]] Contains keys for which
800
+ * we want to disable sync event across tabs.
801
+ * @example
802
+ * Onyx.init({
803
+ * keys: ONYXKEYS,
804
+ * initialKeyStates: {
805
+ * [ONYXKEYS.SESSION]: {loading: false},
806
+ * },
807
+ * });
808
+ */
809
+ function init({
810
+ keys = {},
811
+ initialKeyStates = {},
812
+ safeEvictionKeys = [],
813
+ maxCachedKeysCount = 1000,
814
+ captureMetrics = false,
815
+ shouldSyncMultipleInstances = Boolean(global.localStorage),
816
+ keysToDisableSyncEvents = [],
817
+ } = {}) {
818
+ if (captureMetrics) {
819
+ // The code here is only bundled and applied when the captureMetrics is set
820
+ // eslint-disable-next-line no-use-before-define
821
+ applyDecorators();
822
+ }
823
+
824
+ if (maxCachedKeysCount > 0) {
825
+ cache.setRecentKeysLimit(maxCachedKeysCount);
826
+ }
827
+
828
+ // Let Onyx know about all of our keys
829
+ onyxKeys = keys;
830
+
831
+ // Set our default key states to use when initializing and clearing Onyx data
832
+ defaultKeyStates = initialKeyStates;
833
+
834
+ // Let Onyx know about which keys are safe to evict
835
+ evictionAllowList = safeEvictionKeys;
836
+
837
+ // Initialize all of our keys with data provided then give green light to any pending connections
838
+ Promise.all([
839
+ addAllSafeEvictionKeysToRecentlyAccessedList(),
840
+ initializeWithDefaultKeyStates()
841
+ ])
842
+ .then(deferredInitTask.resolve);
843
+
844
+ if (shouldSyncMultipleInstances && _.isFunction(Storage.keepInstancesSync)) {
845
+ Storage.keepInstancesSync(keysToDisableSyncEvents, (key, value) => {
846
+ cache.set(key, value);
847
+ keyChanged(key, value);
848
+ });
849
+ }
850
+ }
851
+
852
+ const Onyx = {
853
+ connect,
854
+ disconnect,
855
+ set,
856
+ multiSet,
857
+ merge,
858
+ mergeCollection,
859
+ clear,
860
+ init,
861
+ registerLogger,
862
+ addToEvictionBlockList,
863
+ removeFromEvictionBlockList,
864
+ isSafeEvictionKey,
865
+ };
866
+
867
+ /**
868
+ * Apply calls statistic decorators to benchmark Onyx
869
+ *
870
+ * @private
871
+ */
872
+ function applyDecorators() {
873
+ // We're requiring the script dynamically here so that it's only evaluated when decorators are used
874
+ const decorate = require('./metrics');
875
+
876
+ // Re-assign with decorated functions
877
+ /* eslint-disable no-func-assign */
878
+ get = decorate.decorateWithMetrics(get, 'Onyx:get');
879
+ set = decorate.decorateWithMetrics(set, 'Onyx:set');
880
+ multiSet = decorate.decorateWithMetrics(multiSet, 'Onyx:multiSet');
881
+ clear = decorate.decorateWithMetrics(clear, 'Onyx:clear');
882
+ merge = decorate.decorateWithMetrics(merge, 'Onyx:merge');
883
+ mergeCollection = decorate.decorateWithMetrics(mergeCollection, 'Onyx:mergeCollection');
884
+ getAllKeys = decorate.decorateWithMetrics(getAllKeys, 'Onyx:getAllKeys');
885
+ initializeWithDefaultKeyStates = decorate.decorateWithMetrics(initializeWithDefaultKeyStates, 'Onyx:defaults');
886
+ /* eslint-enable */
887
+
888
+ // Re-expose decorated methods
889
+ Onyx.set = set;
890
+ Onyx.multiSet = multiSet;
891
+ Onyx.clear = clear;
892
+ Onyx.merge = merge;
893
+ Onyx.mergeCollection = mergeCollection;
894
+
895
+ // Expose stats methods on Onyx
896
+ Onyx.getMetrics = decorate.getMetrics;
897
+ Onyx.resetMetrics = decorate.resetMetrics;
898
+ Onyx.printMetrics = decorate.printMetrics;
899
+ }
900
+
901
+ export default Onyx;