diff --git a/__mocks__/lib/storage/index.js b/__mocks__/lib/storage/index.js new file mode 100644 index 000000000..97de83c8e --- /dev/null +++ b/__mocks__/lib/storage/index.js @@ -0,0 +1,3 @@ +import WebStorage from '../../../lib/storage/WebStorage'; + +export default WebStorage; diff --git a/lib/Onyx.js b/lib/Onyx.js index 056fd9887..216e69a23 100644 --- a/lib/Onyx.js +++ b/lib/Onyx.js @@ -1,7 +1,8 @@ import _ from 'underscore'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import Str from 'expensify-common/lib/str'; import lodashMerge from 'lodash/merge'; +import Storage from './storage'; + import {registerLogger, logInfo, logAlert} from './Logger'; import cache from './OnyxCache'; import createDeferredTask from './createDeferredTask'; @@ -53,11 +54,10 @@ function get(key) { } // Otherwise retrieve the value from storage and capture a promise to aid concurrent usages - const promise = AsyncStorage.getItem(key) + const promise = Storage.getItem(key) .then((val) => { - const parsed = val && JSON.parse(val); - cache.set(key, parsed); - return parsed; + cache.set(key, val); + return val; }) .catch(err => logInfo(`Unable to get item from persistent storage. Key: ${key} Error: ${err}`)); @@ -84,7 +84,7 @@ function getAllKeys() { } // Otherwise retrieve the keys from storage and capture a promise to aid concurrent usages - const promise = AsyncStorage.getAllKeys() + const promise = Storage.getAllKeys() .then((keys) => { _.each(keys, key => cache.addKey(key)); return keys; @@ -304,8 +304,8 @@ function keysChanged(collectionKey, collection) { * When a key change happens, search for any callbacks matching the key or collection key and trigger those callbacks * * @private - * @param {string} key - * @param {mixed} data + * @param {String} key + * @param {*} data */ function keyChanged(key, data) { // Add or remove this key from the recentlyAccessedKeys lists @@ -483,7 +483,7 @@ function remove(key) { // Optimistically inform subscribers on the next tick Promise.resolve().then(() => keyChanged(key, null)); - return AsyncStorage.removeItem(key); + return Storage.removeItem(key); } /** @@ -534,20 +534,20 @@ function set(key, value) { Promise.resolve().then(() => keyChanged(key, value)); // Write the thing to persistent storage, which will trigger a storage event for any other tabs open on this domain - return AsyncStorage.setItem(key, JSON.stringify(value)) + return Storage.setItem(key, value) .catch(error => evictStorageAndRetry(error, set, key, value)); } /** - * AsyncStorage expects array like: [["@MyApp_user", "value_1"], ["@MyApp_key", "value_2"]] - * This method transforms an object like {'@MyApp_user': 'myUserValue', '@MyApp_key': 'myKeyValue'} + * Storage expects array like: [["@MyApp_user", value_1], ["@MyApp_key", value_2]] + * This method transforms an object like {'@MyApp_user': myUserValue, '@MyApp_key': myKeyValue} * to an array of key-value pairs in the above format * @private * @param {Record} data - * @return {Array} an array of stringified key - value pairs + * @return {Array<[key, value]>} an array of key - value pairs */ function prepareKeyValuePairsForStorage(data) { - return _.keys(data).map(key => [key, JSON.stringify(data[key])]); + return _.map(data, (value, key) => [key, value]); } /** @@ -567,7 +567,7 @@ function multiSet(data) { Promise.resolve().then(() => keyChanged(key, val)); }); - return AsyncStorage.multiSet(keyValuePairs) + return Storage.multiSet(keyValuePairs) .catch(error => evictStorageAndRetry(error, multiSet, data)); } @@ -669,12 +669,9 @@ function merge(key, value) { * @returns {Promise} */ function initializeWithDefaultKeyStates() { - return AsyncStorage.multiGet(_.keys(defaultKeyStates)) + return Storage.multiGet(_.keys(defaultKeyStates)) .then((pairs) => { - const asObject = _.chain(pairs) - .map(([key, val]) => [key, val && JSON.parse(val)]) - .object() - .value(); + const asObject = _.object(pairs); const merged = lodashMerge(asObject, defaultKeyStates); cache.merge(merged); @@ -695,7 +692,7 @@ function clear() { cache.set(key, null); }); }) - .then(AsyncStorage.clear) + .then(Storage.clear) .then(initializeWithDefaultKeyStates); } @@ -740,11 +737,11 @@ function mergeCollection(collectionKey, collection) { // New keys will be added via multiSet while existing keys will be updated using multiMerge // This is because setting a key that doesn't exist yet with multiMerge will throw errors if (keyValuePairsForExistingCollection.length > 0) { - promises.push(AsyncStorage.multiMerge(keyValuePairsForExistingCollection)); + promises.push(Storage.multiMerge(keyValuePairsForExistingCollection)); } if (keyValuePairsForNewCollection.length > 0) { - promises.push(AsyncStorage.multiSet(keyValuePairsForNewCollection)); + promises.push(Storage.multiSet(keyValuePairsForNewCollection)); } // Merge original data to cache @@ -768,13 +765,12 @@ function mergeCollection(collectionKey, collection) { * (individual or collection patterns) that when provided to Onyx are flagged * as "safe" for removal. Any components subscribing to these keys must also * implement a canEvict option. See the README for more info. - * @param {Function} [options.registerStorageEventListener=() => {}] a callback when a storage event happens. - * This applies to web platforms where the local storage emits storage events - * across all open tabs and allows Onyx to stay in sync across all open tabs. * @param {Number} [options.maxCachedKeysCount=55] Sets how many recent keys should we try to keep in cache * Setting this to 0 would practically mean no cache * We try to free cache when we connect to a safe eviction key * @param {Boolean} [options.captureMetrics] Enables Onyx benchmarking and exposes the get/print/reset functions + * @param {Boolean} [options.shouldSyncMultipleInstances] Auto synchronize storage events between multiple instances + * of Onyx running in different tabs/windows. Defaults to true for platforms that support local storage (web/desktop) * * @example * Onyx.init({ @@ -788,9 +784,9 @@ function init({ keys = {}, initialKeyStates = {}, safeEvictionKeys = [], - registerStorageEventListener = (() => {}), maxCachedKeysCount = 55, captureMetrics = false, + shouldSyncMultipleInstances = Boolean(global.localStorage), } = {}) { if (captureMetrics) { // The code here is only bundled and applied when the captureMetrics is set @@ -818,11 +814,12 @@ function init({ ]) .then(deferredInitTask.resolve); - // Update any key whose value changes in storage - registerStorageEventListener((key, newValue) => { - cache.set(key, newValue); - keyChanged(key, newValue); - }); + if (shouldSyncMultipleInstances && _.isFunction(Storage.keepInstancesSync)) { + Storage.keepInstancesSync((key, value) => { + cache.set(key, value); + keyChanged(key, value); + }); + } } const Onyx = { diff --git a/lib/storage/NativeStorage.js b/lib/storage/NativeStorage.js new file mode 100644 index 000000000..a4aaade0e --- /dev/null +++ b/lib/storage/NativeStorage.js @@ -0,0 +1,3 @@ +import Storage from './providers/AsyncStorage'; + +export default Storage; diff --git a/lib/storage/WebStorage.js b/lib/storage/WebStorage.js new file mode 100644 index 000000000..0d2d9d128 --- /dev/null +++ b/lib/storage/WebStorage.js @@ -0,0 +1,50 @@ +import _ from 'underscore'; +import Storage from './providers/LocalForage'; + +const SYNC_ONYX = 'SYNC_ONYX'; + +/** + * Raise an event thorough `localStorage` to let other tabs know a value changed + * @param {String} onyxKey + */ +function raiseStorageSyncEvent(onyxKey) { + global.localStorage.setItem(SYNC_ONYX, onyxKey); + global.localStorage.removeItem(SYNC_ONYX, onyxKey); +} + +const webStorage = { + ...Storage, + + /** + * Storage synchronization mechanism keeping all opened tabs in sync + * @param {function(key: String, data: *)} onStorageKeyChanged + */ + keepInstancesSync(onStorageKeyChanged) { + // Override set, remove and clear to raise storage events that we intercept in other tabs + this.setItem = (key, value) => Storage.setItem(key, value) + .then(() => raiseStorageSyncEvent(key)); + + this.removeItem = key => Storage.removeItem(key) + .then(() => raiseStorageSyncEvent(key)); + + // If we just call Storage.clear other tabs will have no idea which keys were available previously + // so that they can call keysChanged for them. That's why we iterate and remove keys one by one + this.clear = () => Storage.getAllKeys() + .then(keys => _.map(keys, key => this.removeItem(key))) + .then(tasks => Promise.all(tasks)); + + // This listener will only be triggered by events coming from other tabs + global.addEventListener('storage', (event) => { + // Ignore events that don't originate from the SYNC_ONYX logic + if (event.key !== SYNC_ONYX || !event.newValue) { + return; + } + + const onyxKey = event.newValue; + Storage.getItem(onyxKey) + .then(value => onStorageKeyChanged(onyxKey, value)); + }); + }, +}; + +export default webStorage; diff --git a/lib/storage/index.js b/lib/storage/index.js new file mode 100644 index 000000000..f5a621f46 --- /dev/null +++ b/lib/storage/index.js @@ -0,0 +1,8 @@ +import {Platform} from 'react-native'; + +const Storage = Platform.select({ + default: () => require('./WebStorage').default, + native: () => require('./NativeStorage').default, +})(); + +export default Storage; diff --git a/lib/storage/providers/AsyncStorage.js b/lib/storage/providers/AsyncStorage.js new file mode 100644 index 000000000..2e753e53d --- /dev/null +++ b/lib/storage/providers/AsyncStorage.js @@ -0,0 +1,97 @@ +/** + * The AsyncStorage provider stores everything in a key/value store by + * converting the value to a JSON string + */ + +import _ from 'underscore'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +/** + * Values other than null and undefined should be stringified before + * they are saved in storage + * @param {*} value + * @returns {string|null} + */ +function prepareValueForStorage(value) { + if (_.isUndefined(value) || _.isNull(value)) { + return value; + } + + return JSON.stringify(value); +} + +const provider = { + /** + * Get the value of a given key or return `null` if it's not available in storage + * @param {String} key + * @return {Promise<*>} + */ + getItem(key) { + return AsyncStorage.getItem(key) + .then((value) => { + const parsed = value && JSON.parse(value); + return parsed; + }); + }, + + /** + * Get multiple key-value pairs for the give array of keys in a batch + * @param {String[]} keys + * @return {Promise>} + */ + multiGet(keys) { + return AsyncStorage.multiGet(keys) + .then(pairs => _.map(pairs, ([key, value]) => [key, value && JSON.parse(value)])); + }, + + /** + * Sets the value for a given key. The only requirement is that the value should be serializable to JSON string + * @param {String} key + * @param {*} value + * @return {Promise} + */ + setItem(key, value) { + return AsyncStorage.setItem(key, prepareValueForStorage(value)); + }, + + /** + * Stores multiple key-value pairs in a batch + * @param {Array<[key, value]>} pairs + * @return {Promise} + */ + multiSet(pairs) { + const stringPairs = _.map(pairs, ([key, value]) => [key, prepareValueForStorage(value)]); + return AsyncStorage.multiSet(stringPairs); + }, + + /** + * Multiple merging of existing and new values in a batch + * @param {Array<[key, value]>} pairs + * @return {Promise} + */ + multiMerge(pairs) { + const stringPairs = _.map(pairs, ([key, value]) => [key, prepareValueForStorage(value)]); + return AsyncStorage.multiMerge(stringPairs); + }, + + /** + * Returns all keys available in storage + * @returns {Promise} + */ + getAllKeys: AsyncStorage.getAllKeys, + + /** + * Remove given key and it's value from storage + * @param {String} key + * @returns {Promise} + */ + removeItem: AsyncStorage.removeItem, + + /** + * Clear absolutely everything from storage + * @returns {Promise} + */ + clear: AsyncStorage.clear, +}; + +export default provider; diff --git a/lib/storage/providers/LocalForage.js b/lib/storage/providers/LocalForage.js new file mode 100644 index 000000000..3a8c4634a --- /dev/null +++ b/lib/storage/providers/LocalForage.js @@ -0,0 +1,96 @@ +/** + * @file + * The storage provider based on localforage allows us to store most anything in its + * natural form in the underlying DB without having to stringify or de-stringify it + */ + +import localforage from 'localforage'; +import _ from 'underscore'; +import lodashMerge from 'lodash/merge'; + +localforage.config({ + name: 'OnyxDB' +}); + +const provider = { + /** + * Get multiple key-value pairs for the give array of keys in a batch + * @param {String[]} keys + * @return {Promise>} + */ + multiGet(keys) { + const pairs = _.map( + keys, + key => localforage.getItem(key) + .then(value => [key, value]) + ); + + return Promise.all(pairs); + }, + + /** + * Multiple merging of existing and new values in a batch + * @param {Array<[key, value]>} pairs + * @return {Promise} + */ + multiMerge(pairs) { + const tasks = _.map(pairs, ([key, partialValue]) => this.getItem(key) + .then((existingValue) => { + const newValue = _.isObject(existingValue) + ? lodashMerge(existingValue, partialValue) + : partialValue; + + return this.setItem(key, newValue); + })); + + // We're returning Promise.resolve, otherwise the array of task results will be returned to the caller + return Promise.all(tasks).then(() => Promise.resolve()); + }, + + /** + * Stores multiple key-value pairs in a batch + * @param {Array<[key, value]>} pairs + * @return {Promise} + */ + multiSet(pairs) { + // We're returning Promise.resolve, otherwise the array of task results will be returned to the caller + const tasks = _.map(pairs, ([key, value]) => this.setItem(key, value)); + return Promise.all(tasks).then(() => Promise.resolve()); + }, + + /** + * Clear absolutely everything from storage + * @returns {Promise} + */ + clear: localforage.clear, + + /** + * Returns all keys available in storage + * @returns {Promise} + */ + getAllKeys: localforage.keys, + + /** + * Get the value of a given key or return `null` if it's not available in storage + * @param {String} key + * @return {Promise<*>} + */ + getItem: localforage.getItem, + + /** + * Remove given key and it's value from storage + * @param {String} key + * @returns {Promise} + */ + removeItem: localforage.removeItem, + + /** + * Sets the value for a given key. The only requirement is that the value should be serializable to JSON string + * @param {String} key + * @param {*} value + * @return {Promise} + */ + setItem: localforage.setItem, +}; + +export default provider; diff --git a/package.json b/package.json index 9720da18c..c08ad27fd 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ }, "peerDependencies": { "@react-native-async-storage/async-storage": "^1.15.5", + "localforage": "^1.10.0", "react": "^17.0.2", "react-native-performance": "^2.0.0" }, diff --git a/tests/unit/cacheEvictionTest.js b/tests/unit/cacheEvictionTest.js index e9555a901..67291dc69 100644 --- a/tests/unit/cacheEvictionTest.js +++ b/tests/unit/cacheEvictionTest.js @@ -1,4 +1,4 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; +import Storage from '../../lib/storage'; import Onyx from '../../index'; import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; @@ -14,7 +14,7 @@ test('Cache eviction', () => { const collection = {}; // Given an evictable key previously set in storage - return AsyncStorage.setItem(`${ONYX_KEYS.COLLECTION.TEST_KEY}${RECORD_TO_EVICT}`, JSON.stringify({test: 'evict'})) + return Storage.setItem(`${ONYX_KEYS.COLLECTION.TEST_KEY}${RECORD_TO_EVICT}`, {test: 'evict'}) .then(() => { // When we initialize Onyx and mark the set collection key as a safeEvictionKey Onyx.init({ @@ -42,10 +42,10 @@ test('Cache eviction', () => { expect(collection[`${ONYX_KEYS.COLLECTION.TEST_KEY}${RECORD_TO_EVICT}`]).toStrictEqual({test: 'evict'}); // When we set a new key we want to add and force the first attempt to fail - const originalSetItem = AsyncStorage.setItem; + const originalSetItem = Storage.setItem; const setItemMock = jest.fn(originalSetItem) .mockImplementationOnce(() => new Promise((_resolve, reject) => reject())); - AsyncStorage.setItem = setItemMock; + Storage.setItem = setItemMock; return Onyx.set(`${ONYX_KEYS.COLLECTION.TEST_KEY}${RECORD_TO_ADD}`, {test: 'add'}) .then(() => {