diff --git a/lib/Onyx.js b/lib/Onyx.js index 5210a3347..d6a2d13a8 100644 --- a/lib/Onyx.js +++ b/lib/Onyx.js @@ -3,6 +3,7 @@ import AsyncStorage from '@react-native-community/async-storage'; import Str from 'expensify-common/lib/str'; import lodashMerge from 'lodash/merge'; import {registerLogger, logInfo, logAlert} from './Logger'; +import cache from './OnyxCache'; // Keeps track of the last connectionID that was used so we can keep incrementing it let lastConnectionID = 0; @@ -34,9 +35,28 @@ let defaultKeyStates = {}; * @returns {Promise<*>} */ function get(key) { - return AsyncStorage.getItem(key) - .then(val => JSON.parse(val)) + // When we already have the value in cache - resolve right away + if (cache.hasCacheForKey(key)) { + return Promise.resolve(cache.getValue(key)); + } + + const taskName = `get:${key}`; + + // When a value retrieving task for this key is still running hook to it + if (cache.hasPendingTask(taskName)) { + return cache.getTaskPromise(taskName); + } + + // Otherwise retrieve the value from storage and capture a promise to aid concurrent usages + const promise = AsyncStorage.getItem(key) + .then((val) => { + const parsed = val && JSON.parse(val); + cache.set(key, parsed); + return parsed; + }) .catch(err => logInfo(`Unable to get item from persistent storage. Key: ${key} Error: ${err}`)); + + return cache.captureTask(taskName, promise); } /** @@ -44,7 +64,27 @@ function get(key) { * @returns {Promise} */ function getAllKeys() { - return AsyncStorage.getAllKeys(); + // When we've already read stored keys, resolve right away + const storedKeys = cache.getAllKeys(); + if (storedKeys.length > 0) { + return Promise.resolve(storedKeys); + } + + const taskName = 'getAllKeys'; + + // When a value retrieving task for all keys is still running hook to it + if (cache.hasPendingTask(taskName)) { + return cache.getTaskPromise(taskName); + } + + // Otherwise retrieve the keys from storage and capture a promise to aid concurrent usages + const promise = AsyncStorage.getAllKeys() + .then((keys) => { + _.each(keys, key => cache.addKey(key)); + return keys; + }); + + return cache.captureTask(taskName, promise); } /** @@ -58,6 +98,19 @@ function isCollectionKey(key) { return _.contains(_.values(onyxKeys.COLLECTION), key); } +/** + * Find the collection a collection item belongs to + * or return null if them item is not a part of a collection + * @param {string} key + * @returns {string|null} + */ +function getCollectionKeyForItem(key) { + return _.chain(onyxKeys.COLLECTION) + .values() + .find(name => key.startsWith(name)) + .value(); +} + /** * Checks to see if a given key matches with the * configured key of our connected subscriber @@ -350,6 +403,43 @@ function connect(mapping) { return connectionID; } +/** + * Remove cache items that are no longer connected through Onyx + * @param {string} key + */ +function cleanCache(key) { + const hasRemainingConnections = _.some(callbackToStateMapping, {key}); + + // When the key is still used in other places don't remove it from cache + if (hasRemainingConnections) { + return; + } + + // When this is a collection - also recursively remove any unused individual items + if (isCollectionKey(key)) { + cache.remove(key); + + getAllKeys().then(cachedKeys => _.chain(cachedKeys) + .filter(name => name.startsWith(key)) + .forEach(cleanCache)); + + return; + } + + // When this is a collection item - check if the collection is still used + const collectionKey = getCollectionKeyForItem(key); + if (collectionKey) { + // When there's an active subscription for a collection don't remove the item + const hasRemainingConnectionsForCollection = _.some(callbackToStateMapping, {key: collectionKey}); + if (hasRemainingConnectionsForCollection) { + return; + } + } + + // Otherwise remove the item from cache + cache.remove(key); +} + /** * Remove the listener for a react component * @@ -367,7 +457,11 @@ function disconnect(connectionID, keyToRemoveFromEvictionBlocklist) { removeFromEvictionBlockList(keyToRemoveFromEvictionBlocklist, connectionID); } + const key = callbackToStateMapping[connectionID].key; delete callbackToStateMapping[connectionID]; + + // When the last subscriber disconnects, drop cache as well + cleanCache(key); } /** @@ -377,8 +471,13 @@ function disconnect(connectionID, keyToRemoveFromEvictionBlocklist) { * @return {Promise} */ function remove(key) { - return AsyncStorage.removeItem(key) - .then(() => keyChanged(key, null)); + // Remove from cache + cache.remove(key); + + // Optimistically inform subscribers + keyChanged(key, null); + + return AsyncStorage.removeItem(key); } /** @@ -414,12 +513,28 @@ function evictStorageAndRetry(error, ionMethod, ...args) { * @returns {Promise} */ function set(key, val) { + // Adds the key to cache when it's not available + cache.set(key, val); + + // Optimistically inform subscribers + keyChanged(key, val); + // 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(val)) - .then(() => keyChanged(key, val)) .catch(error => evictStorageAndRetry(error, set, key, val)); } +/** + * AsyncStorage 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 + * @param {Record} data + * @return {Array<[string, string]>} an array of stringified key - value pairs + */ +function prepareKeyValuePairsForStorage(data) { + return _.keys(data).map(key => [key, JSON.stringify(data[key])]); +} + /** * Sets multiple keys and values. Example * Onyx.multiSet({'key1': 'a', 'key2': 'b'}); @@ -428,17 +543,15 @@ function set(key, val) { * @returns {Promise} */ function multiSet(data) { - // AsyncStorage expenses the data in an array like: - // [["@MyApp_user", "value_1"], ["@MyApp_key", "value_2"]] - // This method will transform the params from a better JSON format like: - // {'@MyApp_user': 'myUserValue', '@MyApp_key': 'myKeyValue'} - const keyValuePairs = _.reduce(data, (finalArray, val, key) => ([ - ...finalArray, - [key, JSON.stringify(val)], - ]), []); + const keyValuePairs = prepareKeyValuePairsForStorage(data); + + _.each(data, (val, key) => { + // Update cache and optimistically inform subscribers + cache.set(key, val); + keyChanged(key, val); + }); return AsyncStorage.multiSet(keyValuePairs) - .then(() => _.each(data, (val, key) => keyChanged(key, val))) .catch(error => evictStorageAndRetry(error, multiSet, data)); } @@ -522,17 +635,15 @@ function initializeWithDefaultKeyStates() { * @returns {Promise} */ function clear() { - let allKeys; return getAllKeys() - .then(keys => allKeys = keys) - .then(() => AsyncStorage.clear()) - .then(() => { - _.each(allKeys, (key) => { + .then((keys) => { + _.each(keys, (key) => { keyChanged(key, null); + cache.remove(key); }); - - initializeWithDefaultKeyStates(); - }); + }) + .then(AsyncStorage.clear) + .then(initializeWithDefaultKeyStates); } /** @@ -551,34 +662,31 @@ function mergeCollection(collectionKey, collection) { } }); - const existingKeyCollection = {}; - const newCollection = {}; return getAllKeys() - .then((keys) => { - _.each(collection, (data, dataKey) => { - if (keys.includes(dataKey)) { - existingKeyCollection[dataKey] = data; - } else { - newCollection[dataKey] = data; - } - }); - - const keyValuePairsForExistingCollection = _.reduce(existingKeyCollection, (finalArray, val, key) => ([ - ...finalArray, - [key, JSON.stringify(val)], - ]), []); - const keyValuePairsForNewCollection = _.reduce(newCollection, (finalArray, val, key) => ([ - ...finalArray, - [key, JSON.stringify(val)], - ]), []); + .then((persistedKeys) => { + // Split to keys that exist in storage and keys that don't + const [existingKeys, newKeys] = _.chain(collection) + .keys() + .partition(key => persistedKeys.includes(key)) + .value(); + + const existingKeyCollection = _.pick(collection, existingKeys); + const newCollection = _.pick(collection, newKeys); + const keyValuePairsForExistingCollection = prepareKeyValuePairsForStorage(existingKeyCollection); + const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage(newCollection); // 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 const existingCollectionPromise = AsyncStorage.multiMerge(keyValuePairsForExistingCollection); const newCollectionPromise = AsyncStorage.multiSet(keyValuePairsForNewCollection); + // Merge original data to cache + cache.merge(collection); + + // Optimistically inform collection subscribers + keysChanged(collectionKey, collection); + return Promise.all([existingCollectionPromise, newCollectionPromise]) - .then(() => keysChanged(collectionKey, collection)) .catch(error => evictStorageAndRetry(error, mergeCollection, collection)); }); } diff --git a/lib/OnyxCache.js b/lib/OnyxCache.js new file mode 100644 index 000000000..841f6bc97 --- /dev/null +++ b/lib/OnyxCache.js @@ -0,0 +1,148 @@ +import _ from 'underscore'; +import lodashMerge from 'lodash/merge'; + + +const isDefined = _.negate(_.isUndefined); + +/** + * In memory cache providing data by reference + * Encapsulates Onyx cache related functionality + */ +class OnyxCache { + constructor() { + /** + * @private + * Cache of all the storage keys available in persistent storage + * @type {Set} + */ + this.storageKeys = new Set(); + + /** + * @private + * A map of cached values + * @type {Record} + */ + this.storageMap = {}; + + /** + * @private + * Captured pending tasks for already running storage methods + * @type {Record} + */ + this.pendingPromises = {}; + + // bind all methods to prevent problems with `this` + _.bindAll( + this, + 'getAllKeys', 'getValue', 'hasCacheForKey', 'addKey', 'set', 'remove', 'merge', + 'hasPendingTask', 'getTaskPromise', 'captureTask', + ); + } + + /** + * Get all the storage keys + * @returns {string[]} + */ + getAllKeys() { + return Array.from(this.storageKeys); + } + + /** + * Get a cached value from storage + * @param {string} key + * @returns {*} + */ + getValue(key) { + return this.storageMap[key]; + } + + /** + * Check whether cache has data for the given key + * @param {string} key + * @returns {boolean} + */ + hasCacheForKey(key) { + return isDefined(this.storageMap[key]); + } + + /** + * Saves a key in the storage keys list + * Serves to keep the result of `getAllKeys` up to date + * @param {string} key + */ + addKey(key) { + this.storageKeys.add(key); + } + + /** + * Set's a key value in cache + * Adds the key to the storage keys list as well + * @param {string} key + * @param {*} value + * @returns {*} value - returns the cache value + */ + set(key, value) { + this.addKey(key); + this.storageMap[key] = value; + + return value; + } + + /** + * Remove data from cache + * @param {string} key + */ + remove(key) { + this.storageKeys.delete(key); + delete this.storageMap[key]; + } + + /** + * Deep merge data to cache, any non existing keys will be crated + * @param {Record} data - a map of (cache) key - values + */ + merge(data) { + this.storageMap = lodashMerge({}, this.storageMap, data); + } + + /** + * Check whether the given task is already running + * @param {string} taskName - unique name given for the task + * @returns {*} + */ + hasPendingTask(taskName) { + return isDefined(this.pendingPromises[taskName]); + } + + /** + * Use this method to prevent concurrent calls for the same thing + * Instead of calling the same task again use the existing promise + * provided from this function + * @template T + * @param {string} taskName - unique name given for the task + * @returns {Promise} + */ + getTaskPromise(taskName) { + return this.pendingPromises[taskName]; + } + + /** + * Capture a promise for a given task so other caller can + * hook up to the promise if it's still pending + * @template T + * @param {string} taskName - unique name for the task + * @param {Promise} promise + * @returns {Promise} + */ + captureTask(taskName, promise) { + this.pendingPromises[taskName] = promise.finally(() => { + delete this.pendingPromises[taskName]; + }); + + return this.pendingPromises[taskName]; + } +} + +const instance = new OnyxCache(); + +export default instance; diff --git a/tests/components/ViewWithCollections.js b/tests/components/ViewWithCollections.js index fb7f2e721..c11e8cb43 100644 --- a/tests/components/ViewWithCollections.js +++ b/tests/components/ViewWithCollections.js @@ -24,8 +24,8 @@ const ViewWithCollections = (props) => { return ( - {_.map(props.collections, collection => ( - {collection.ID} + {_.map(props.collections, (collection, i) => ( + {collection.ID} ))} ); diff --git a/tests/unit/decorateWithMetrics.js b/tests/unit/decorateWithMetricsTest.js similarity index 100% rename from tests/unit/decorateWithMetrics.js rename to tests/unit/decorateWithMetricsTest.js diff --git a/tests/unit/onyxCacheTest.js b/tests/unit/onyxCacheTest.js new file mode 100644 index 000000000..de5f0a356 --- /dev/null +++ b/tests/unit/onyxCacheTest.js @@ -0,0 +1,680 @@ +import React from 'react'; +import {render} from '@testing-library/react-native'; + +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; +import ViewWithText from '../components/ViewWithText'; +import ViewWithCollections from '../components/ViewWithCollections'; + +describe('Onyx', () => { + describe('Cache Service', () => { + /** @type OnyxCache */ + let cache; + + // Always use a "fresh" instance + beforeEach(() => { + jest.resetModules(); + cache = require('../../lib/OnyxCache').default; + }); + + describe('getAllKeys', () => { + it('Should be empty initially', () => { + // GIVEN empty cache + + // WHEN all keys are retrieved + const allKeys = cache.getAllKeys(); + + // THEN the result should be empty + expect(allKeys).toEqual([]); + }); + + it('Should keep storage keys', () => { + // GIVEN cache with some items + cache.set('mockKey', 'mockValue'); + cache.set('mockKey2', 'mockValue'); + cache.set('mockKey3', 'mockValue'); + + // THEN the keys should be stored in cache + const allKeys = cache.getAllKeys(); + expect(allKeys).toEqual(['mockKey', 'mockKey2', 'mockKey3']); + }); + + it('Should keep storage keys even when no values are provided', () => { + // GIVEN cache with some items + cache.set('mockKey'); + cache.set('mockKey2'); + cache.set('mockKey3'); + + // THEN the keys should be stored in cache + const allKeys = cache.getAllKeys(); + expect(allKeys).toEqual(['mockKey', 'mockKey2', 'mockKey3']); + }); + + it('Should not store duplicate keys', () => { + // GIVEN cache with some items + cache.set('mockKey', 'mockValue'); + cache.set('mockKey2', 'mockValue'); + cache.set('mockKey3', 'mockValue'); + + // WHEN an existing keys is later updated + cache.set('mockKey2', 'new mock value'); + + // THEN getAllKeys should not include a duplicate value + const allKeys = cache.getAllKeys(); + expect(allKeys).toEqual(['mockKey', 'mockKey2', 'mockKey3']); + }); + }); + + describe('getValue', () => { + it('Should return undefined when there is no stored value', () => { + // GIVEN empty cache + + // WHEN a value is retrieved + const result = cache.getValue('mockKey'); + + // THEN it should be undefined + expect(result).not.toBeDefined(); + }); + + it('Should return cached value when it exists', () => { + // GIVEN cache with some items + cache.set('mockKey', {items: ['mockValue', 'mockValue2']}); + cache.set('mockKey2', 'mockValue3'); + + // WHEN a value is retrieved + // THEN it should be the correct value + expect(cache.getValue('mockKey')).toEqual({items: ['mockValue', 'mockValue2']}); + expect(cache.getValue('mockKey2')).toEqual('mockValue3'); + }); + }); + + describe('hasCacheForKey', () => { + it('Should return false when there is no stored value', () => { + // GIVEN empty cache + + // WHEN a value does not exist in cache + // THEN it should return false + expect(cache.hasCacheForKey('mockKey')).toBe(false); + }); + + it('Should return true when cached value exists', () => { + // GIVEN cache with some items + cache.set('mockKey', {items: ['mockValue', 'mockValue2']}); + cache.set('mockKey2', 'mockValue3'); + + // WHEN a value exists in cache + // THEN it should return true + expect(cache.hasCacheForKey('mockKey')).toBe(true); + expect(cache.hasCacheForKey('mockKey2')).toBe(true); + }); + }); + + describe('addKey', () => { + it('Should store the key so that it is returned by `getAllKeys`', () => { + // GIVEN empty cache + + // WHEN set is called with key and value + cache.addKey('mockKey'); + + // THEN there should be no cached value + expect(cache.hasCacheForKey('mockKey')).toBe(false); + + // THEN but a key should be available + expect(cache.getAllKeys()).toEqual(expect.arrayContaining(['mockKey'])); + }); + + it('Should not make duplicate keys', () => { + // GIVEN empty cache + + // WHEN the same item is added multiple times + cache.addKey('mockKey'); + cache.addKey('mockKey'); + cache.addKey('mockKey2'); + cache.addKey('mockKey'); + + // THEN getAllKeys should not include a duplicate value + const allKeys = cache.getAllKeys(); + expect(allKeys).toEqual(['mockKey', 'mockKey2']); + }); + }); + + describe('set', () => { + it('Should add data to cache when both key and value are provided', () => { + // GIVEN empty cache + + // WHEN set is called with key and value + cache.set('mockKey', {value: 'mockValue'}); + + // THEN data should be cached + const data = cache.getValue('mockKey'); + expect(data).toEqual({value: 'mockValue'}); + }); + + it('Should store the key so that it is returned by `getAllKeys`', () => { + // GIVEN empty cache + + // WHEN set is called with key and value + cache.set('mockKey', {value: 'mockValue'}); + + // THEN but a key should be available + expect(cache.getAllKeys()).toEqual(expect.arrayContaining(['mockKey'])); + }); + + it('Should overwrite existing cache items for the given key', () => { + // GIVEN cache with some items + cache.set('mockKey', {value: 'mockValue'}); + cache.set('mockKey2', {other: 'otherMockValue'}); + + // WHEN set is called for an existing key + cache.set('mockKey2', {value: []}); + + // THEN the value should be overwritten + expect(cache.getValue('mockKey2')).toEqual({value: []}); + }); + }); + + describe('remove', () => { + it('Should remove the key from all keys', () => { + // GIVEN cache with some items + cache.set('mockKey', 'mockValue'); + cache.set('mockKey2', 'mockValue'); + cache.set('mockKey3', 'mockValue'); + + // WHEN an key is removed + cache.remove('mockKey2'); + + // THEN getAllKeys should not include the removed value + const allKeys = cache.getAllKeys(); + expect(allKeys).toEqual(['mockKey', 'mockKey3']); + }); + + it('Should remove the key from cache', () => { + // GIVEN cache with some items + cache.set('mockKey', {items: ['mockValue', 'mockValue2']}); + cache.set('mockKey2', 'mockValue3'); + + // WHEN a key is removed + cache.remove('mockKey'); + + // THEN a value should not be available in cache + expect(cache.hasCacheForKey('mockKey')).toBe(false); + expect(cache.getValue('mockKey')).not.toBeDefined(); + }); + }); + + describe('merge', () => { + it('Should create the value in cache when it does not exist', () => { + // GIVEN empty cache + + // WHEN merge is called with new key value pairs + cache.merge({ + mockKey: {value: 'mockValue'}, + mockKey2: {value: 'mockValue2'} + }); + + // THEN data should be created in cache + expect(cache.getValue('mockKey')).toEqual({value: 'mockValue'}); + expect(cache.getValue('mockKey2')).toEqual({value: 'mockValue2'}); + }); + + it('Should merge data to existing cache value', () => { + // GIVEN cache with some items + cache.set('mockKey', {value: 'mockValue'}); + cache.set('mockKey2', {other: 'otherMockValue', mock: 'mock', items: [3, 4, 5]}); + + // WHEN merge is called with existing key value pairs + cache.merge({ + mockKey: {mockItems: []}, + mockKey2: {items: [1, 2], other: 'overwrittenMockValue'} + }); + + // THEN the values should be merged together in cache + expect(cache.getValue('mockKey')).toEqual({ + value: 'mockValue', + mockItems: [], + }); + + expect(cache.getValue('mockKey2')).toEqual({ + other: 'overwrittenMockValue', + items: [1, 2, 5], + mock: 'mock', + }); + }); + + it('Should merge objects correctly', () => { + // GIVEN cache with existing object data + cache.set('mockKey', {value: 'mockValue', anotherValue: 'overwrite me'}); + + // WHEN merge is called for a key with object value + cache.merge({ + mockKey: {mockItems: [], anotherValue: 'overwritten'} + }); + + // THEN the values should be merged together in cache + expect(cache.getValue('mockKey')).toEqual({ + value: 'mockValue', + mockItems: [], + anotherValue: 'overwritten', + }); + }); + + it('Should merge arrays correctly', () => { + // GIVEN cache with existing array data + cache.set('mockKey', [{ID: 1}, {ID: 2}, {ID: 3}]); + + // WHEN merge is called with an array + cache.merge({ + mockKey: [{ID: 3}, {added: 'field'}, {}, {ID: 1000}] + }); + + // THEN the arrays should be merged as expected + expect(cache.getValue('mockKey')).toEqual([ + {ID: 3}, {ID: 2, added: 'field'}, {ID: 3}, {ID: 1000} + ]); + }); + + it('Should work with primitive values', () => { + // GIVEN cache with existing data + cache.set('mockKey', {}); + + // WHEN merge is called with bool + cache.merge({mockKey: false}); + + // THEN the object should be overwritten with a bool value + expect(cache.getValue('mockKey')).toEqual(false); + + // WHEN merge is called with number + cache.merge({mockKey: 0}); + + // THEN the value should be overwritten + expect(cache.getValue('mockKey')).toEqual(0); + + // WHEN merge is called with string + cache.merge({mockKey: '123'}); + + // THEN the value should be overwritten + expect(cache.getValue('mockKey')).toEqual('123'); + + // WHEN merge is called with string again + cache.merge({mockKey: '123'}); + + // THEN strings should not have been concatenated + expect(cache.getValue('mockKey')).toEqual('123'); + + // WHEN merge is called with an object + cache.merge({mockKey: {value: 'myMockObject'}}); + + // THEN the old primitive value should be overwritten with the object + expect(cache.getValue('mockKey')).toEqual({value: 'myMockObject'}); + }); + + it('Should do nothing to a key which value is `undefined`', () => { + // GIVEN cache with existing data + cache.set('mockKey', {ID: 5}); + + // WHEN merge is called key value pair and the value is undefined + cache.merge({mockKey: undefined}); + + // THEN the key should still be in cache and the value unchanged + expect(cache.hasCacheForKey('mockKey')).toBe(true); + expect(cache.getValue('mockKey')).toEqual({ID: 5}); + }); + }); + + describe('hasPendingTask', () => { + it('Should return false when there is no started task', () => { + // GIVEN empty cache with no started tasks + // WHEN a task has not been started + // THEN it should return false + expect(cache.hasPendingTask('mockTask')).toBe(false); + }); + + it('Should return true when a task is running', () => { + // GIVEN empty cache with no started tasks + // WHEN a unique task is started + const promise = Promise.resolve(); + cache.captureTask('mockTask', promise); + + // THEN `hasPendingTask` should return true + expect(cache.hasPendingTask('mockTask')).toBe(true); + + // WHEN the promise is completed + return waitForPromisesToResolve() + .then(() => { + // THEN `hasPendingTask` should return false + expect(cache.hasPendingTask('mockTask')).toBe(false); + }); + }); + }); + + describe('getTaskPromise', () => { + it('Should return undefined when there is no stored value', () => { + // GIVEN empty cache with no started tasks + + // WHEN a task is retrieved + const task = cache.getTaskPromise('mockTask'); + + // THEN it should be undefined + expect(task).not.toBeDefined(); + }); + + it('Should return captured task when it exists', () => { + // GIVEN empty cache with no started tasks + // WHEN a unique task is started + const promise = Promise.resolve({mockResult: true}); + cache.captureTask('mockTask', promise); + + // WHEN a task is retrieved + const taskPromise = cache.getTaskPromise('mockTask'); + + // THEN it should resolve with the same result as the captured task + return taskPromise.then((result) => { + expect(result).toEqual({mockResult: true}); + }); + }); + }); + }); + + describe('Onyx with Cache', () => { + let Onyx; + let withOnyx; + + /** @type OnyxCache */ + let cache; + + const ONYX_KEYS = { + TEST_KEY: 'test', + ANOTHER_TEST: 'anotherTest', + COLLECTION: { + MOCK_COLLECTION: 'mock_collection_', + }, + }; + + function initOnyx() { + const OnyxModule = require('../../index'); + Onyx = OnyxModule.default; + withOnyx = OnyxModule.withOnyx; + cache = require('../../lib/OnyxCache').default; + + Onyx.init({ + keys: ONYX_KEYS, + registerStorageEventListener: jest.fn(), + }); + + // Onyx init introduces some side effects e.g. calls the getAllKeys + // We're clearing mocks to have a fresh calls history + return waitForPromisesToResolve() + .then(() => jest.clearAllMocks()); + } + + // Always use a "fresh" instance + beforeEach(() => { + jest.resetModules(); + return initOnyx(); + }); + + it('Expect a single call to getItem when multiple components use the same key', () => { + const AsyncStorageMock = require('@react-native-community/async-storage/jest/async-storage-mock'); + + // GIVEN a component connected to Onyx + const TestComponentWithOnyx = withOnyx({ + text: { + key: ONYX_KEYS.TEST_KEY, + }, + })(ViewWithText); + + // GIVEN some string value for that key exists in storage + AsyncStorageMock.getItem.mockResolvedValue('"mockValue"'); + AsyncStorageMock.getAllKeys.mockResolvedValue([ONYX_KEYS.TEST_KEY]); + return initOnyx() + .then(() => { + // WHEN multiple components are rendered + render( + <> + + + + + ); + }) + .then(waitForPromisesToResolve) + .then(() => { + // THEN Async storage `getItem` should be called only once + expect(AsyncStorageMock.getItem).toHaveBeenCalledTimes(1); + }); + }); + + it('Expect a single call to getAllKeys when multiple components use the same key', () => { + const AsyncStorageMock = require('@react-native-community/async-storage/jest/async-storage-mock'); + + // GIVEN a component connected to Onyx + const TestComponentWithOnyx = withOnyx({ + text: { + key: ONYX_KEYS.TEST_KEY, + }, + })(ViewWithText); + + // GIVEN some string value for that key exists in storage + return initOnyx() + .then(() => { + AsyncStorageMock.getItem.mockResolvedValue('"mockValue"'); + AsyncStorageMock.getAllKeys.mockResolvedValue([ONYX_KEYS.TEST_KEY]); + + // WHEN multiple components are rendered + render( + <> + + + + + ); + }) + .then(waitForPromisesToResolve) + .then(() => { + // THEN Async storage `getItem` should be called only once + expect(AsyncStorageMock.getAllKeys).toHaveBeenCalledTimes(1); + }); + }); + + it('Expect multiple calls to getItem when no existing component is using a key', () => { + const AsyncStorageMock = require('@react-native-community/async-storage/jest/async-storage-mock'); + + // GIVEN a component connected to Onyx + const TestComponentWithOnyx = withOnyx({ + text: { + key: ONYX_KEYS.TEST_KEY, + }, + })(ViewWithText); + + // GIVEN some string value for that key exists in storage + AsyncStorageMock.getItem.mockResolvedValue('"mockValue"'); + AsyncStorageMock.getAllKeys.mockResolvedValue([ONYX_KEYS.TEST_KEY]); + let result; + + return initOnyx() + .then(() => { + // WHEN a component is rendered and unmounted and no longer available + result = render(); + }) + .then(waitForPromisesToResolve) + .then(() => result.unmount()) + .then(waitForPromisesToResolve) + + // THEN When another component using the same storage key is rendered + .then(() => render()) + .then(waitForPromisesToResolve) + .then(() => { + // THEN Async storage `getItem` should be called twice + expect(AsyncStorageMock.getItem).toHaveBeenCalledTimes(2); + }); + }); + + it('Expect multiple calls to getItem when multiple keys are used', () => { + const AsyncStorageMock = require('@react-native-community/async-storage/jest/async-storage-mock'); + + // GIVEN two component + const TestComponentWithOnyx = withOnyx({ + testObject: { + key: ONYX_KEYS.TEST_KEY, + }, + })(ViewWithCollections); + + const AnotherTestComponentWithOnyx = withOnyx({ + text: { + key: ONYX_KEYS.ANOTHER_TEST, + }, + })(ViewWithText); + + // GIVEN some values exist in storage + AsyncStorageMock.setItem(ONYX_KEYS.TEST_KEY, JSON.stringify({ID: 15, data: 'mock object with ID'})); + AsyncStorageMock.setItem(ONYX_KEYS.ANOTHER_TEST, JSON.stringify('mock text')); + AsyncStorageMock.getAllKeys.mockResolvedValue([ONYX_KEYS.TEST_KEY, ONYX_KEYS.ANOTHER_TEST]); + return initOnyx() + .then(() => { + // WHEN the components are rendered multiple times + render(); + render(); + render(); + render(); + render(); + render(); + }) + .then(waitForPromisesToResolve) + .then(() => { + // THEN Async storage `getItem` should be called exactly two times (once for each key) + expect(AsyncStorageMock.getItem).toHaveBeenCalledTimes(2); + expect(AsyncStorageMock.getItem.mock.calls).toEqual([ + [ONYX_KEYS.TEST_KEY], + [ONYX_KEYS.ANOTHER_TEST] + ]); + }); + }); + + it('Expect a single call to getItem when at least one component is still subscribed to a key', () => { + const AsyncStorageMock = require('@react-native-community/async-storage/jest/async-storage-mock'); + + // GIVEN a component connected to Onyx + const TestComponentWithOnyx = withOnyx({ + text: { + key: ONYX_KEYS.TEST_KEY, + }, + })(ViewWithText); + + // GIVEN some string value for that key exists in storage + AsyncStorageMock.getItem.mockResolvedValue('"mockValue"'); + AsyncStorageMock.getAllKeys.mockResolvedValue([ONYX_KEYS.TEST_KEY]); + let result2; + let result3; + return initOnyx() + .then(() => { + // WHEN multiple components are rendered + render(); + result2 = render(); + result3 = render(); + }) + .then(waitForPromisesToResolve) + .then(() => { + // WHEN components unmount but at least one remain mounted + result2.unmount(); + result3.unmount(); + }) + .then(waitForPromisesToResolve) + + // THEN When another component using the same storage key is rendered + .then(() => render()) + .then(waitForPromisesToResolve) + .then(() => { + // THEN Async storage `getItem` should be called once + expect(AsyncStorageMock.getItem).toHaveBeenCalledTimes(1); + }); + }); + + it('Should remove collection items from cache when collection is disconnected', () => { + const AsyncStorageMock = require('@react-native-community/async-storage/jest/async-storage-mock'); + + // GIVEN a component subscribing to a collection + const TestComponentWithOnyx = withOnyx({ + collections: { + key: ONYX_KEYS.COLLECTION.MOCK_COLLECTION, + }, + })(ViewWithCollections); + + // GIVEN some collection item values exist in storage + const keys = [`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}15`, `${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}16`]; + AsyncStorageMock.setItem(keys[0], JSON.stringify({ID: 15})); + AsyncStorageMock.setItem(keys[1], JSON.stringify({ID: 16})); + AsyncStorageMock.getAllKeys.mockResolvedValue(keys); + let result; + let result2; + return initOnyx() + .then(() => { + // WHEN the collection using components render + result = render(); + result2 = render(); + }) + .then(waitForPromisesToResolve) + .then(() => { + // THEN the collection items should be in cache + expect(cache.hasCacheForKey(keys[0])).toBe(true); + expect(cache.hasCacheForKey(keys[1])).toBe(true); + + // WHEN one of the components unmounts + result.unmount(); + return waitForPromisesToResolve(); + }) + .then(() => { + // THEN the collection items should still be in cache + expect(cache.hasCacheForKey(keys[0])).toBe(true); + expect(cache.hasCacheForKey(keys[1])).toBe(true); + + // WHEN the last component using the collection unmounts + result2.unmount(); + return waitForPromisesToResolve(); + }) + .then(() => { + // THEN the collection items should be removed from cache + expect(cache.hasCacheForKey(keys[0])).toBe(false); + expect(cache.hasCacheForKey(keys[1])).toBe(false); + }); + }); + + it('Should not remove item from cache when it still used in a collection', () => { + const AsyncStorageMock = require('@react-native-community/async-storage/jest/async-storage-mock'); + + // GIVEN component that uses a collection and a component that uses a collection item + const COLLECTION_ITEM_KEY = `${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}10`; + const TestComponentWithOnyx = withOnyx({ + collections: { + key: ONYX_KEYS.COLLECTION.MOCK_COLLECTION, + }, + })(ViewWithCollections); + + const AnotherTestComponentWithOnyx = withOnyx({ + testObject: { + key: COLLECTION_ITEM_KEY, + }, + })(ViewWithCollections); + + // GIVEN some values exist in storage + AsyncStorageMock.setItem(COLLECTION_ITEM_KEY, JSON.stringify({ID: 10})); + AsyncStorageMock.getAllKeys.mockResolvedValue([COLLECTION_ITEM_KEY]); + let result; + + return initOnyx() + .then(() => { + // WHEN both components render + render(); + result = render(); + return waitForPromisesToResolve(); + }) + .then(() => { + // WHEN the component using the individual item unmounts + result.unmount(); + return waitForPromisesToResolve(); + }) + .then(() => { + // THEN The item should not be removed from cache as it's used in a collection + expect(cache.hasCacheForKey(COLLECTION_ITEM_KEY)).toBe(true); + }); + }); + }); +}); diff --git a/tests/unit/onyxMetricsDecorationTest.js b/tests/unit/onyxMetricsDecorationTest.js new file mode 100644 index 000000000..d7b1ce65e --- /dev/null +++ b/tests/unit/onyxMetricsDecorationTest.js @@ -0,0 +1,80 @@ +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; + +describe('Onyx', () => { + describe('Metrics Capturing Decoration', () => { + let Onyx; + + const ONYX_KEYS = { + TEST_KEY: 'test', + ANOTHER_TEST: 'anotherTest', + }; + + // Always use a "fresh" (and undecorated) instance + beforeEach(() => { + jest.resetModules(); + Onyx = require('../../index').default; + }); + + it('Should expose metrics methods when `captureMetrics` is true', () => { + // WHEN Onyx is initialized with `captureMetrics: true` + Onyx.init({ + keys: ONYX_KEYS, + registerStorageEventListener: jest.fn(), + captureMetrics: true, + }); + + // THEN Onyx should have statistic related methods + expect(Onyx.getMetrics).toEqual(expect.any(Function)); + expect(Onyx.printMetrics).toEqual(expect.any(Function)); + expect(Onyx.resetMetrics).toEqual(expect.any(Function)); + }); + + it('Should not expose metrics methods when `captureMetrics` is false or not set', () => { + // WHEN Onyx is initialized without setting `captureMetrics` + Onyx.init({ + keys: ONYX_KEYS, + registerStorageEventListener: jest.fn(), + }); + + // THEN Onyx should not have statistic related methods + expect(Onyx.getMetrics).not.toBeDefined(); + expect(Onyx.printMetrics).not.toBeDefined(); + expect(Onyx.resetMetrics).not.toBeDefined(); + + // WHEN Onyx is initialized with `captureMetrics: false` + Onyx.init({ + keys: ONYX_KEYS, + registerStorageEventListener: jest.fn(), + captureMetrics: false, + }); + + // THEN Onyx should not have statistic related methods + expect(Onyx.getMetrics).not.toBeDefined(); + expect(Onyx.printMetrics).not.toBeDefined(); + expect(Onyx.resetMetrics).not.toBeDefined(); + }); + + it('Should decorate exposed methods', () => { + // GIVEN Onyx is initialized with `captureMetrics: true` + Onyx.init({ + keys: ONYX_KEYS, + registerStorageEventListener: jest.fn(), + captureMetrics: true, + }); + + // WHEN calling decorated methods through Onyx[methodName] + const methods = ['set', 'multiSet', 'clear', 'merge', 'mergeCollection']; + methods.forEach(name => Onyx[name]('mockKey', {mockKey: {mockValue: 'mockValue'}})); + + return waitForPromisesToResolve() + .then(() => { + // THEN metrics should have captured data for each method + const summaries = Onyx.getMetrics().summaries; + + methods.forEach((name) => { + expect(summaries[`Onyx:${name}`].total).toBeGreaterThan(0); + }); + }); + }); + }); +}); diff --git a/tests/unit/onyxTest.js b/tests/unit/onyxTest.js index 4ee1a8149..1740a1747 100644 --- a/tests/unit/onyxTest.js +++ b/tests/unit/onyxTest.js @@ -264,80 +264,4 @@ describe('Onyx', () => { expect(error.message).toEqual(`Provided collection does not have all its data belonging to the same parent. CollectionKey: ${ONYX_KEYS.COLLECTION.TEST_KEY}, DataKey: not_my_test`); } }); - - describe('captureMetrics', () => { - // makes require calls to always return a "fresh" (undecorated) instance - beforeEach(() => jest.resetModules()); - - it('Should expose metrics methods when `captureMetrics` is true', () => { - // Run in isolation so the top import is unaffected - const IsolatedOnyx = require('../../index').default; - - // WHEN Onyx is initialized with `captureMetrics: true` - IsolatedOnyx.init({ - keys: ONYX_KEYS, - registerStorageEventListener: jest.fn(), - captureMetrics: true, - }); - - // THEN Onyx should have statistic related methods - expect(IsolatedOnyx.getMetrics).toEqual(expect.any(Function)); - expect(IsolatedOnyx.printMetrics).toEqual(expect.any(Function)); - expect(IsolatedOnyx.resetMetrics).toEqual(expect.any(Function)); - }); - - it('Should not expose metrics methods when `captureMetrics` is false or not set', () => { - // Run in isolation so the top import is unaffected - const IsolatedOnyx = require('../../index').default; - - // WHEN Onyx is initialized without setting `captureMetrics` - IsolatedOnyx.init({ - keys: ONYX_KEYS, - registerStorageEventListener: jest.fn(), - }); - - // THEN Onyx should not have statistic related methods - expect(IsolatedOnyx.getMetrics).not.toBeDefined(); - expect(IsolatedOnyx.printMetrics).not.toBeDefined(); - expect(IsolatedOnyx.resetMetrics).not.toBeDefined(); - - // WHEN Onyx is initialized with `captureMetrics: false` - IsolatedOnyx.init({ - keys: ONYX_KEYS, - registerStorageEventListener: jest.fn(), - captureMetrics: false, - }); - - // THEN Onyx should not have statistic related methods - expect(IsolatedOnyx.getMetrics).not.toBeDefined(); - expect(IsolatedOnyx.printMetrics).not.toBeDefined(); - expect(IsolatedOnyx.resetMetrics).not.toBeDefined(); - }); - - it('Should decorate exposed methods', () => { - // Run in isolation so the top import is unaffected - const IsolatedOnyx = require('../../index').default; - - // GIVEN Onyx is initialized with `captureMetrics: true` - IsolatedOnyx.init({ - keys: ONYX_KEYS, - registerStorageEventListener: jest.fn(), - captureMetrics: true, - }); - - // WHEN calling decorated methods through Onyx[methodName] - const methods = ['set', 'multiSet', 'clear', 'merge', 'mergeCollection']; - methods.forEach(name => IsolatedOnyx[name]('mockKey', {mockKey: {mockValue: 'mockValue'}})); - - return waitForPromisesToResolve() - .then(() => { - // THEN metrics should have captured data for each method - const summaries = IsolatedOnyx.getMetrics().summaries; - - methods.forEach((name) => { - expect(summaries[`Onyx:${name}`].total).toBeGreaterThan(0); - }); - }); - }); - }); }); diff --git a/tests/unit/withOnyxTest.js b/tests/unit/withOnyxTest.js index 440786474..79d1351ec 100644 --- a/tests/unit/withOnyxTest.js +++ b/tests/unit/withOnyxTest.js @@ -25,7 +25,7 @@ describe('withOnyx', () => { it('should render with the test data when using withOnyx', () => { let result; - Onyx.set(ONYX_KEYS.TEST_KEY, 'test1') + return Onyx.set(ONYX_KEYS.TEST_KEY, 'test1') .then(() => { const TestComponentWithOnyx = withOnyx({ text: { @@ -38,12 +38,12 @@ describe('withOnyx', () => { }) .then(() => { const textComponent = result.getByText('test1'); - expect(textComponent).toBeTruthy(); - expect(result).toHaveBeenCalledTimes(999); + expect(textComponent).not.toBeNull(); }); }); it('should update withOnyx subscriber multiple times when merge is used', () => { + // Todo: ViewWithCollections does not expect a `text` prop const TestComponentWithOnyx = withOnyx({ text: { key: ONYX_KEYS.COLLECTION.TEST_KEY, @@ -62,6 +62,7 @@ describe('withOnyx', () => { }); it('should update withOnyx subscriber just once when mergeCollection is used', () => { + // Todo: ViewWithCollections does not expect a `text` prop const TestComponentWithOnyx = withOnyx({ text: { key: ONYX_KEYS.COLLECTION.TEST_KEY, @@ -78,6 +79,7 @@ describe('withOnyx', () => { }); it('should update withOnyx subscribing to individual key if mergeCollection is used', () => { + // Todo: ViewWithCollections does not expect a `text` prop const collectionItemID = 1; const TestComponentWithOnyx = withOnyx({ text: { @@ -95,6 +97,7 @@ describe('withOnyx', () => { }); it('should update withOnyx subscribing to individual key with merged value if mergeCollection is used', () => { + // Todo: ViewWithCollections does not expect a `text` prop const collectionItemID = 4; const TestComponentWithOnyx = withOnyx({ text: {