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.
@@ -0,0 +1,195 @@
1
+ import _ from 'underscore';
2
+ import lodashMerge from 'lodash/merge';
3
+
4
+
5
+ const isDefined = _.negate(_.isUndefined);
6
+
7
+ /**
8
+ * In memory cache providing data by reference
9
+ * Encapsulates Onyx cache related functionality
10
+ */
11
+ class OnyxCache {
12
+ constructor() {
13
+ /**
14
+ * @private
15
+ * Cache of all the storage keys available in persistent storage
16
+ * @type {Set<string>}
17
+ */
18
+ this.storageKeys = new Set();
19
+
20
+ /**
21
+ * @private
22
+ * Unique list of keys maintained in access order (most recent at the end)
23
+ * @type {Set<string>}
24
+ */
25
+ this.recentKeys = new Set();
26
+
27
+ /**
28
+ * @private
29
+ * A map of cached values
30
+ * @type {Record<string, *>}
31
+ */
32
+ this.storageMap = {};
33
+
34
+ /**
35
+ * @private
36
+ * Captured pending tasks for already running storage methods
37
+ * @type {Record<string, Promise>}
38
+ */
39
+ this.pendingPromises = {};
40
+
41
+ // bind all public methods to prevent problems with `this`
42
+ _.bindAll(
43
+ this,
44
+ 'getAllKeys', 'getValue', 'hasCacheForKey', 'addKey', 'set', 'drop', 'merge',
45
+ 'hasPendingTask', 'getTaskPromise', 'captureTask', 'removeLeastRecentlyUsedKeys',
46
+ 'setRecentKeysLimit'
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Get all the storage keys
52
+ * @returns {string[]}
53
+ */
54
+ getAllKeys() {
55
+ return Array.from(this.storageKeys);
56
+ }
57
+
58
+ /**
59
+ * Get a cached value from storage
60
+ * @param {string} key
61
+ * @returns {*}
62
+ */
63
+ getValue(key) {
64
+ this.addToAccessedKeys(key);
65
+ return this.storageMap[key];
66
+ }
67
+
68
+ /**
69
+ * Check whether cache has data for the given key
70
+ * @param {string} key
71
+ * @returns {boolean}
72
+ */
73
+ hasCacheForKey(key) {
74
+ return isDefined(this.storageMap[key]);
75
+ }
76
+
77
+ /**
78
+ * Saves a key in the storage keys list
79
+ * Serves to keep the result of `getAllKeys` up to date
80
+ * @param {string} key
81
+ */
82
+ addKey(key) {
83
+ this.storageKeys.add(key);
84
+ }
85
+
86
+ /**
87
+ * Set's a key value in cache
88
+ * Adds the key to the storage keys list as well
89
+ * @param {string} key
90
+ * @param {*} value
91
+ * @returns {*} value - returns the cache value
92
+ */
93
+ set(key, value) {
94
+ this.addKey(key);
95
+ this.addToAccessedKeys(key);
96
+ this.storageMap[key] = value;
97
+
98
+ return value;
99
+ }
100
+
101
+ /**
102
+ * Forget the cached value for the given key
103
+ * @param {string} key
104
+ */
105
+ drop(key) {
106
+ delete this.storageMap[key];
107
+ }
108
+
109
+ /**
110
+ * Deep merge data to cache, any non existing keys will be created
111
+ * @param {Record<string, *>} data - a map of (cache) key - values
112
+ */
113
+ merge(data) {
114
+ this.storageMap = lodashMerge({}, this.storageMap, data);
115
+
116
+ const storageKeys = this.getAllKeys();
117
+ const mergedKeys = _.keys(data);
118
+ this.storageKeys = new Set([...storageKeys, ...mergedKeys]);
119
+ _.each(mergedKeys, key => this.addToAccessedKeys(key));
120
+ }
121
+
122
+ /**
123
+ * Check whether the given task is already running
124
+ * @param {string} taskName - unique name given for the task
125
+ * @returns {*}
126
+ */
127
+ hasPendingTask(taskName) {
128
+ return isDefined(this.pendingPromises[taskName]);
129
+ }
130
+
131
+ /**
132
+ * Use this method to prevent concurrent calls for the same thing
133
+ * Instead of calling the same task again use the existing promise
134
+ * provided from this function
135
+ * @template T
136
+ * @param {string} taskName - unique name given for the task
137
+ * @returns {Promise<T>}
138
+ */
139
+ getTaskPromise(taskName) {
140
+ return this.pendingPromises[taskName];
141
+ }
142
+
143
+ /**
144
+ * Capture a promise for a given task so other caller can
145
+ * hook up to the promise if it's still pending
146
+ * @template T
147
+ * @param {string} taskName - unique name for the task
148
+ * @param {Promise<T>} promise
149
+ * @returns {Promise<T>}
150
+ */
151
+ captureTask(taskName, promise) {
152
+ this.pendingPromises[taskName] = promise.finally(() => {
153
+ delete this.pendingPromises[taskName];
154
+ });
155
+
156
+ return this.pendingPromises[taskName];
157
+ }
158
+
159
+ /**
160
+ * @private
161
+ * Adds a key to the top of the recently accessed keys
162
+ * @param {string} key
163
+ */
164
+ addToAccessedKeys(key) {
165
+ // Removing and re-adding a key ensures it's at the end of the list
166
+ this.recentKeys.delete(key);
167
+ this.recentKeys.add(key);
168
+ }
169
+
170
+ /**
171
+ * Remove keys that don't fall into the range of recently used keys
172
+ */
173
+ removeLeastRecentlyUsedKeys() {
174
+ if (this.recentKeys.size > this.maxRecentKeysSize) {
175
+ // Get the last N keys by doing a negative slice
176
+ const recentlyAccessed = [...this.recentKeys].slice(-this.maxRecentKeysSize);
177
+ const storageKeys = _.keys(this.storageMap);
178
+ const keysToRemove = _.difference(storageKeys, recentlyAccessed);
179
+
180
+ _.each(keysToRemove, this.drop);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Set the recent keys list size
186
+ * @param {number} limit
187
+ */
188
+ setRecentKeysLimit(limit) {
189
+ this.maxRecentKeysSize = limit;
190
+ }
191
+ }
192
+
193
+ const instance = new OnyxCache();
194
+
195
+ export default instance;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Synchronous queue that can be used to ensure promise based tasks are run in sequence.
3
+ * Pass to the constructor a function that returns a promise to run the task then add data.
4
+ *
5
+ * @example
6
+ *
7
+ * const queue = new SyncQueue(({key, val}) => {
8
+ * return someAsyncProcess(key, val);
9
+ * });
10
+ *
11
+ * queue.push({key: 1, val: '1'});
12
+ * queue.push({key: 2, val: '2'});
13
+ */
14
+ export default class SyncQueue {
15
+ /**
16
+ * @param {Function} run - must return a promise
17
+ */
18
+ constructor(run) {
19
+ this.queue = [];
20
+ this.isProcessing = false;
21
+ this.run = run;
22
+ }
23
+
24
+ process() {
25
+ if (this.isProcessing || this.queue.length === 0) {
26
+ return;
27
+ }
28
+
29
+ this.isProcessing = true;
30
+
31
+ const {data, resolve, reject} = this.queue.shift();
32
+ this.run(data)
33
+ .then(resolve)
34
+ .catch(reject)
35
+ .finally(() => {
36
+ this.isProcessing = false;
37
+ this.process();
38
+ });
39
+ }
40
+
41
+ /**
42
+ * @param {*} data
43
+ * @returns {Promise}
44
+ */
45
+ push(data) {
46
+ return new Promise((resolve, reject) => {
47
+ this.queue.push({resolve, reject, data});
48
+ this.process();
49
+ });
50
+ }
51
+ }
package/lib/compose.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * This is a utility function taken directly from Redux. (We don't want to add Redux as a dependency)
3
+ * It enables functional composition, useful for the chaining/composition of HOCs.
4
+ *
5
+ * For example, instead of:
6
+ *
7
+ * export default hoc1(config1, hoc2(config2, hoc3(config3)))(Component);
8
+ *
9
+ * Use this instead:
10
+ *
11
+ * export default compose(
12
+ * hoc1(config1),
13
+ * hoc2(config2),
14
+ * hoc3(config3),
15
+ * )(Component)
16
+ *
17
+ * @returns {Function}
18
+ */
19
+ export default function compose(...funcs) {
20
+ if (funcs.length === 0) {
21
+ return arg => arg;
22
+ }
23
+
24
+ if (funcs.length === 1) {
25
+ return funcs[0];
26
+ }
27
+
28
+ return funcs.reduce((a, b) => (...args) => a(b(...args)));
29
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Create a deferred task that can be resolved when we call `resolve()`
3
+ * The returned promise will complete when we call `resolve`
4
+ * Useful when we want to wait for a tasks that is resolved from an external action
5
+ *
6
+ * @template T
7
+ * @returns {{ resolve: function(*), promise: Promise<T|void> }}
8
+ */
9
+ export default function createDeferredTask() {
10
+ const deferred = {};
11
+ deferred.promise = new Promise((res) => {
12
+ deferred.resolve = res;
13
+ });
14
+
15
+ return deferred;
16
+ }
package/lib/index.js ADDED
@@ -0,0 +1,5 @@
1
+ import Onyx from './Onyx';
2
+ import withOnyx from './withOnyx';
3
+
4
+ export default Onyx;
5
+ export {withOnyx};
@@ -0,0 +1,263 @@
1
+ import _ from 'underscore';
2
+ import performance from 'react-native-performance';
3
+ import MDTable from '../MDTable';
4
+
5
+ const decoratedAliases = new Set();
6
+
7
+ /**
8
+ * Capture a start mark to performance entries
9
+ * @param {string} alias
10
+ * @param {Array<*>} args
11
+ * @returns {{name: string, startTime:number, detail: {args: [], alias: string}}}
12
+ */
13
+ function addMark(alias, args) {
14
+ return performance.mark(alias, {detail: {args, alias}});
15
+ }
16
+
17
+ /**
18
+ * Capture a measurement between the start mark and now
19
+ * @param {{name: string, startTime:number, detail: {args: []}}} startMark
20
+ * @param {*} detail
21
+ */
22
+ function measureMarkToNow(startMark, detail) {
23
+ performance.measure(`${startMark.name} [${startMark.detail.args.toString()}]`, {
24
+ start: startMark.startTime,
25
+ end: performance.now(),
26
+ detail: {...startMark.detail, ...detail}
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Wraps a function with metrics capturing logic
32
+ * @param {function} func
33
+ * @param {String} [alias]
34
+ * @returns {function} The wrapped function
35
+ */
36
+ function decorateWithMetrics(func, alias = func.name) {
37
+ if (decoratedAliases.has(alias)) {
38
+ throw new Error(`"${alias}" is already decorated`);
39
+ }
40
+
41
+ decoratedAliases.add(alias);
42
+
43
+ function decorated(...args) {
44
+ const mark = addMark(alias, args);
45
+
46
+ const originalPromise = func.apply(this, args);
47
+
48
+ /*
49
+ * Then handlers added here are not affecting the original promise
50
+ * They create a separate chain that's not exposed (returned) to the original caller
51
+ * */
52
+ originalPromise
53
+ .then((result) => {
54
+ measureMarkToNow(mark, {result});
55
+ })
56
+ .catch((error) => {
57
+ measureMarkToNow(mark, {error});
58
+ });
59
+
60
+ return originalPromise;
61
+ }
62
+
63
+ return decorated;
64
+ }
65
+
66
+ /**
67
+ * Calculate the total sum of a given key in a list
68
+ * @param {Array<Record<prop, Number>>} list
69
+ * @param {string} prop
70
+ * @returns {number}
71
+ */
72
+ function sum(list, prop) {
73
+ return _.reduce(list, (memo, next) => memo + next[prop], 0);
74
+ }
75
+
76
+ /**
77
+ * Aggregates and returns benchmark information
78
+ * @returns {{summaries: Record<string, Object>, totalTime: number, lastCompleteCall: *}}
79
+ * An object with
80
+ * - `totalTime` - total time spent by decorated methods
81
+ * - `lastCompleteCall` - millisecond since launch the last call completed at
82
+ * - `summaries` - mapping of all captured stats: summaries.methodName -> method stats
83
+ */
84
+ function getMetrics() {
85
+ const summaries = _.chain(performance.getEntriesByType('measure'))
86
+ .filter(entry => entry.detail && decoratedAliases.has(entry.detail.alias))
87
+ .groupBy(entry => entry.detail.alias)
88
+ .map((calls, methodName) => {
89
+ const total = sum(calls, 'duration');
90
+ const avg = (total / calls.length) || 0;
91
+ const max = _.max(calls, 'duration').duration || 0;
92
+ const min = _.min(calls, 'duration').duration || 0;
93
+
94
+ // Latest complete call (by end time) for all the calls made to the current method
95
+ const lastCall = _.max(calls, call => call.startTime + call.duration);
96
+
97
+ return [methodName, {
98
+ methodName,
99
+ total,
100
+ max,
101
+ min,
102
+ avg,
103
+ lastCall,
104
+ calls,
105
+ }];
106
+ })
107
+ .object() // Create a map like methodName -> StatSummary
108
+ .value();
109
+
110
+ const totalTime = sum(_.values(summaries), 'total');
111
+
112
+ // Latest complete call (by end time) of all methods up to this point
113
+ const lastCompleteCall = _.max(
114
+ _.values(summaries),
115
+ summary => summary.lastCall.startTime + summary.lastCall.duration,
116
+ ).lastCall;
117
+
118
+ return {
119
+ totalTime,
120
+ summaries,
121
+ lastCompleteCall,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Convert milliseconds to human readable time
127
+ * @param {number} millis
128
+ * @param {boolean} [raw=false]
129
+ * @returns {string|number}
130
+ */
131
+ function toDuration(millis, raw = false) {
132
+ if (raw) {
133
+ return millis;
134
+ }
135
+
136
+ const minute = 60 * 1000;
137
+ if (millis > minute) {
138
+ return `${(millis / minute).toFixed(1)}min`;
139
+ }
140
+
141
+ const second = 1000;
142
+ if (millis > second) {
143
+ return `${(millis / second).toFixed(2)}sec`;
144
+ }
145
+
146
+ return `${millis.toFixed(3)}ms`;
147
+ }
148
+
149
+ /**
150
+ * Print extensive information on the dev console
151
+ * max, min, average, total time for each method
152
+ * and a table of individual calls
153
+ *
154
+ * @param {Object} [options]
155
+ * @param {boolean} [options.raw=false] - setting this to true will print raw instead of human friendly times
156
+ * Useful when you copy the printed table to excel and let excel do the number formatting
157
+ * @param {'console'|'csv'|'json'|'string'} [options.format=console] The output format of this function
158
+ * `string` is useful when __DEV__ is set to `false` as writing to the console is disabled, but the result of this
159
+ * method would still get printed as output
160
+ * @param {string[]} [options.methods] Print stats only for these method names
161
+ * @returns {string|undefined}
162
+ */
163
+ function printMetrics({raw = false, format = 'console', methods} = {}) {
164
+ const {totalTime, summaries, lastCompleteCall} = getMetrics();
165
+
166
+ const tableSummary = MDTable.factory({
167
+ heading: ['method', 'total time spent', 'max', 'min', 'avg', 'time last call completed', 'calls made'],
168
+ leftAlignedCols: [0],
169
+ });
170
+
171
+ /* Performance marks (startTimes) are relative to system uptime
172
+ * timeOrigin is the point at which the app started to init
173
+ * We use timeOrigin to display times relative to app launch time
174
+ * See: https://github.com/oblador/react-native-performance/issues/50 */
175
+ const timeOrigin = performance.timeOrigin;
176
+ const methodNames = _.isArray(methods) ? methods : _.keys(summaries);
177
+
178
+ const methodCallTables = _.chain(methodNames)
179
+ .filter(methodName => summaries[methodName] && summaries[methodName].avg > 0)
180
+ .map((methodName) => {
181
+ const {calls, ...methodStats} = summaries[methodName];
182
+ tableSummary.addRow(
183
+ methodName,
184
+ toDuration(methodStats.total, raw),
185
+ toDuration(methodStats.max, raw),
186
+ toDuration(methodStats.min, raw),
187
+ toDuration(methodStats.avg, raw),
188
+ toDuration((methodStats.lastCall.startTime + methodStats.lastCall.duration) - timeOrigin, raw),
189
+ calls.length,
190
+ );
191
+
192
+ return MDTable.factory({
193
+ title: methodName,
194
+ heading: ['start time', 'end time', 'duration', 'args'],
195
+ leftAlignedCols: [3],
196
+ rows: calls.map(call => ([
197
+ toDuration(call.startTime - performance.timeOrigin, raw),
198
+ toDuration((call.startTime + call.duration) - timeOrigin, raw),
199
+ toDuration(call.duration, raw),
200
+ call.detail.args.map(String).join(', ').slice(0, 60), // Restrict cell width to 60 chars max
201
+ ]))
202
+ });
203
+ })
204
+ .value();
205
+
206
+ if (/csv|json|string/i.test(format)) {
207
+ const allTables = [tableSummary, ...methodCallTables];
208
+
209
+ return allTables.map((table) => {
210
+ switch (format.toLowerCase()) {
211
+ case 'csv':
212
+ return table.toCSV();
213
+ case 'json':
214
+ return table.toJSON();
215
+ default:
216
+ return table.toString();
217
+ }
218
+ }).join('\n\n');
219
+ }
220
+
221
+ const lastComplete = lastCompleteCall && toDuration(
222
+ (lastCompleteCall.startTime + lastCompleteCall.duration) - timeOrigin, raw
223
+ );
224
+
225
+ const mainOutput = [
226
+ '### Onyx Benchmark',
227
+ ` - Total: ${toDuration(totalTime, raw)}`,
228
+ ` - Last call finished at: ${lastComplete || 'N/A'}`,
229
+ '',
230
+ tableSummary.toString()
231
+ ];
232
+
233
+ /* eslint-disable no-console */
234
+ console.info(mainOutput.join('\n'));
235
+ methodCallTables.forEach((table) => {
236
+ console.groupCollapsed(table.getTitle());
237
+ console.info(table.toString());
238
+ console.groupEnd();
239
+ });
240
+ /* eslint-enable */
241
+ }
242
+
243
+ /**
244
+ * Clears all collected metrics.
245
+ */
246
+ function resetMetrics() {
247
+ const {summaries} = getMetrics();
248
+
249
+ _.chain(summaries)
250
+ .map(summary => summary.calls)
251
+ .flatten()
252
+ .each((measure) => {
253
+ performance.clearMarks(measure.detail.alias);
254
+ performance.clearMeasures(measure.name);
255
+ });
256
+ }
257
+
258
+ export {
259
+ decorateWithMetrics,
260
+ getMetrics,
261
+ resetMetrics,
262
+ printMetrics,
263
+ };
@@ -0,0 +1,13 @@
1
+ // For web-only implementations of Onyx, this module will just be a no-op
2
+
3
+ function decorateWithMetrics() {}
4
+ function getMetrics() {}
5
+ function printMetrics() {}
6
+ function resetMetrics() {}
7
+
8
+ export {
9
+ decorateWithMetrics,
10
+ getMetrics,
11
+ resetMetrics,
12
+ printMetrics,
13
+ };
@@ -0,0 +1,3 @@
1
+ import Storage from './providers/AsyncStorage';
2
+
3
+ export default Storage;
@@ -0,0 +1,56 @@
1
+ import _ from 'underscore';
2
+ import Storage from './providers/LocalForage';
3
+
4
+ const SYNC_ONYX = 'SYNC_ONYX';
5
+
6
+ /**
7
+ * Raise an event thorough `localStorage` to let other tabs know a value changed
8
+ * @param {String} onyxKey
9
+ */
10
+ function raiseStorageSyncEvent(onyxKey) {
11
+ global.localStorage.setItem(SYNC_ONYX, onyxKey);
12
+ global.localStorage.removeItem(SYNC_ONYX, onyxKey);
13
+ }
14
+
15
+ const webStorage = {
16
+ ...Storage,
17
+
18
+ /**
19
+ * Contains keys for which we want to disable sync event across tabs.
20
+ * @param {String[]} keysToDisableSyncEvents
21
+ * Storage synchronization mechanism keeping all opened tabs in sync
22
+ * @param {function(key: String, data: *)} onStorageKeyChanged
23
+ */
24
+ keepInstancesSync(keysToDisableSyncEvents, onStorageKeyChanged) {
25
+ // Override set, remove and clear to raise storage events that we intercept in other tabs
26
+ this.setItem = (key, value) => Storage.setItem(key, value)
27
+ .then(() => raiseStorageSyncEvent(key));
28
+
29
+ this.removeItem = key => Storage.removeItem(key)
30
+ .then(() => raiseStorageSyncEvent(key));
31
+
32
+ // If we just call Storage.clear other tabs will have no idea which keys were available previously
33
+ // so that they can call keysChanged for them. That's why we iterate and remove keys one by one
34
+ this.clear = () => Storage.getAllKeys()
35
+ .then(keys => _.map(keys, key => this.removeItem(key)))
36
+ .then(tasks => Promise.all(tasks));
37
+
38
+ // This listener will only be triggered by events coming from other tabs
39
+ global.addEventListener('storage', (event) => {
40
+ // Ignore events that don't originate from the SYNC_ONYX logic
41
+ if (event.key !== SYNC_ONYX || !event.newValue) {
42
+ return;
43
+ }
44
+
45
+ const onyxKey = event.newValue;
46
+ if (_.contains(keysToDisableSyncEvents, onyxKey)) {
47
+ return;
48
+ }
49
+
50
+ Storage.getItem(onyxKey)
51
+ .then(value => onStorageKeyChanged(onyxKey, value));
52
+ });
53
+ },
54
+ };
55
+
56
+ export default webStorage;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Because we're using the `react-native` preset of jest this file extension
3
+ * is .native.js. Otherwise, since jest prefers index.native.js over index.js
4
+ * it'll skip loading the mock
5
+ */
6
+ import WebStorage from '../WebStorage';
7
+
8
+ export default WebStorage;
@@ -0,0 +1,8 @@
1
+ import {Platform} from 'react-native';
2
+
3
+ const Storage = Platform.select({
4
+ default: () => require('./WebStorage').default,
5
+ native: () => require('./NativeStorage').default,
6
+ })();
7
+
8
+ export default Storage;
@@ -0,0 +1,3 @@
1
+ import WebStorage from './WebStorage';
2
+
3
+ export default WebStorage;