Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
693684a
Extract ProviderInterface
kidroca Aug 7, 2021
535cf48
Add expo-file-system as peerDependency
kidroca Aug 25, 2021
c8c8a61
chore: add expo-file-system as dev dependency for intellisense
kidroca Aug 27, 2021
95df46d
Create storage/fs.native.js
kidroca Aug 27, 2021
d77e1c5
Create storage/fs.js for web and desktop
kidroca Aug 27, 2021
b53c79a
Add `localforage` as peer dependency
kidroca Aug 27, 2021
e45b66a
Create LocalForage provider
kidroca Aug 27, 2021
55afdb2
Create AsyncStorage provider
kidroca Aug 27, 2021
0513625
Move NativeFileHandler to providers
kidroca Aug 27, 2021
628c56d
Remove JSON parse/stringify handling from Onyx
kidroca Aug 27, 2021
42eab2a
Update NativeFileHandler
kidroca Aug 27, 2021
355fa19
Update Storage type definitions
kidroca Aug 27, 2021
661990a
Remove fs.js - this is completely covered by localForage
kidroca Aug 27, 2021
019ea8c
Update package with unimodules
kidroca Aug 27, 2021
ef48097
Update stringification
kidroca Aug 28, 2021
86d8ad9
NativeStorage implementation handling File items
kidroca Aug 28, 2021
20dd112
Simplify Native file save handling
kidroca Aug 28, 2021
4448684
Simplify providers, move away from class based implementations
kidroca Aug 28, 2021
c4441b5
remove unused methods from FileHandler
kidroca Aug 28, 2021
449d802
rename "storeFileLocally" to "prepareFile"
kidroca Aug 28, 2021
ec22e2c
Update prepareFile so it can be called sync
kidroca Aug 28, 2021
577091f
Native Storage stats
kidroca Aug 28, 2021
dcce126
Create folder for the local path
kidroca Aug 30, 2021
2968c30
Feat: synchronize IndexDB updates across different tabs
kidroca Aug 30, 2021
0bffc81
Update tests after changes
kidroca Aug 30, 2021
449e4ba
Update docs
kidroca Aug 30, 2021
b6ad244
Use a hidden folder `.onyx_files`
kidroca Aug 30, 2021
cc01240
Merge branch 'master' into kidroca/file-handling-poc
kidroca Nov 1, 2021
dac932e
Delete storage typings index
kidroca Nov 1, 2021
81af8f7
Address part of the requested changes (comments and clarification cha…
kidroca Nov 1, 2021
6f4abd3
Get rid of empty `synchronizeInstances` method in index.native.js
kidroca Nov 1, 2021
c6a5d69
LocalForage Simplify multiGet implementation
kidroca Nov 1, 2021
f409c68
LocalForage apply multiMerge suggested changes, use underscore
kidroca Nov 1, 2021
35c2281
NativeFIleHandler: Apply review suggestion
kidroca Nov 1, 2021
cb7a8c0
Document provider methods
kidroca Nov 1, 2021
273080f
NativeFileHandler extract bytesToMB, use underscore
kidroca Nov 1, 2021
6429c70
Remove empty `prepareFile` and add a check in Onyx
kidroca Nov 1, 2021
029a7a9
Replace index.native with Platform.select
kidroca Nov 1, 2021
abf0339
Update to expo-file-system 13 which does not require unimodules
kidroca Nov 1, 2021
41bed4d
Remove accidental JSON.stringify resulted from merge
kidroca Nov 1, 2021
fe0646c
Remove NativeFileHandler, prepareFile and the expo-file-system package
kidroca Nov 2, 2021
45ddac4
WebStorage make keepInstancesSync handle removeItem and clear
kidroca Nov 2, 2021
4c941f8
WebStorage: wait for all remove calls to finish and then resolve clear
kidroca Nov 3, 2021
c738e7e
tab-sync cache fix - update cache as well as raise keyChanged
kidroca Nov 3, 2021
9b02f63
Move `storage/__mocks__` to the root level mocks
kidroca Nov 3, 2021
3d5a675
Small code style cleanup
kidroca Nov 3, 2021
3c8aa3f
package: remove localforage from dev dependencies
kidroca Nov 3, 2021
5e95175
docs: fix typo
kidroca Nov 3, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions __mocks__/lib/storage/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import WebStorage from '../../../lib/storage/WebStorage';

export default WebStorage;
61 changes: 29 additions & 32 deletions lib/Onyx.js
Original file line number Diff line number Diff line change
@@ -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';
Comment thread
kidroca marked this conversation as resolved.

import {registerLogger, logInfo, logAlert} from './Logger';
import cache from './OnyxCache';
import createDeferredTask from './createDeferredTask';
Expand Down Expand Up @@ -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;
Comment thread
kidroca marked this conversation as resolved.
})
.catch(err => logInfo(`Unable to get item from persistent storage. Key: ${key} Error: ${err}`));

Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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]);
}

/**
Expand All @@ -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));
}

Expand Down Expand Up @@ -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);
Expand All @@ -695,7 +692,7 @@ function clear() {
cache.set(key, null);
});
})
.then(AsyncStorage.clear)
.then(Storage.clear)
.then(initializeWithDefaultKeyStates);
}

Expand Down Expand Up @@ -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
Expand All @@ -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({
Expand All @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
3 changes: 3 additions & 0 deletions lib/storage/NativeStorage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Storage from './providers/AsyncStorage';

export default Storage;
50 changes: 50 additions & 0 deletions lib/storage/WebStorage.js
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 8 additions & 0 deletions lib/storage/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {Platform} from 'react-native';

const Storage = Platform.select({
default: () => require('./WebStorage').default,
native: () => require('./NativeStorage').default,
})();

export default Storage;
97 changes: 97 additions & 0 deletions lib/storage/providers/AsyncStorage.js
Original file line number Diff line number Diff line change
@@ -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<Array<[key, value]>>}
*/
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<void>}
*/
setItem(key, value) {
return AsyncStorage.setItem(key, prepareValueForStorage(value));
},

/**
* Stores multiple key-value pairs in a batch
* @param {Array<[key, value]>} pairs
* @return {Promise<void>}
*/
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<void>}
*/
multiMerge(pairs) {
const stringPairs = _.map(pairs, ([key, value]) => [key, prepareValueForStorage(value)]);
return AsyncStorage.multiMerge(stringPairs);
},

/**
* Returns all keys available in storage
* @returns {Promise<String[]>}
*/
getAllKeys: AsyncStorage.getAllKeys,

/**
* Remove given key and it's value from storage
* @param {String} key
* @returns {Promise<void>}
*/
removeItem: AsyncStorage.removeItem,

/**
* Clear absolutely everything from storage
* @returns {Promise<void>}
*/
clear: AsyncStorage.clear,
};

export default provider;
Loading