diff --git a/__mocks__/localforage-removeitems.js b/__mocks__/localforage-removeitems.js new file mode 100644 index 000000000..9dfbb0a3c --- /dev/null +++ b/__mocks__/localforage-removeitems.js @@ -0,0 +1,14 @@ +import _ from 'underscore'; + +function extendPrototype(localforage) { + const newLocalforage = localforage; + newLocalforage.removeItems = keys => new Promise((resolve) => { + _.each(keys, (key) => { + delete newLocalforage.storageMap[key]; + }); + resolve(); + }); +} + +// eslint-disable-next-line import/prefer-default-export +export {extendPrototype}; diff --git a/lib/Onyx.js b/lib/Onyx.js index 28051bf1d..266775443 100644 --- a/lib/Onyx.js +++ b/lib/Onyx.js @@ -1047,55 +1047,46 @@ function initializeWithDefaultKeyStates() { function clear(keysToPreserve = []) { return getAllKeys() .then((keys) => { - const keyValuesToReset = []; - const defaultKeys = _.keys(defaultKeyStates); - - // Get all the values for the keys that need to be preserved. These key/value pairs will be set - // in Onyx after the database is cleared(). - const keyValuesToPreserve = _.map(keysToPreserve, key => [key, cache.getValue(key)]); + const keysToBeClearedFromStorage = []; + const keyValuesToResetAsCollection = {}; + const keyValuesToResetIndividually = {}; // The only keys that should not be cleared are: // 1. Anything specifically passed in keysToPreserve (because some keys like language preferences, offline // status, or activeClients need to remain in Onyx even when signed out) // 2. Any keys with a default state (because they need to remain in Onyx as their default, and setting them // to null would cause unknown behavior) - const keysToClear = _.difference(keys, keysToPreserve, defaultKeys); - keyValuesToReset.push(..._.map(keysToClear, key => [key, null])); - - // Remove any keysToPreserve from the defaultKeyStates because if they are passed in it has been explicitly - // called out to preserve those values instead of resetting them back - // to the default. - const defaultKeyValuePairs = _.pairs(_.omit(defaultKeyStates, ...keysToPreserve)); - - // Add the default key value pairs to the keyValuesToReset so that they get set back to their default values - // when we clear Onyx - keyValuesToReset.push(...defaultKeyValuePairs); - - // We now have all the key/values that need to be reset, but we're not done yet! - // There will be two groups of key/values and they each need to be updated a little bit differently. - // Collection keys need to be notified differently than non collection keys - const keyValuesToResetAsCollection = {}; - const keyValuesToResetIndividually = {}; - - // Make sure that we also reset the cache values before clearing the values from storage. - // We do this before clearing Storage so that any call to clear() followed by merge() on a key with a - // default state results in the merged value getting saved, since the update from the merge() call would - // happen on the tick after the update from this clear() - _.each(keyValuesToReset, (keyValue) => { - const key = keyValue[0]; - const value = keyValue[1]; - cache.set(key, value); - - const collectionKey = key.substring(0, key.indexOf('_') + 1); - if (collectionKey) { - if (!keyValuesToResetAsCollection[collectionKey]) { - keyValuesToResetAsCollection[collectionKey] = {}; + _.each(keys, (key) => { + const isKeyToPreserve = _.contains(keysToPreserve, key); + const isDefaultKey = _.has(defaultKeyStates, key); + + // If the key is being removed or reset to default: + // 1. Update it in the cache + // 2. Figure out whether it is a collection key or not, + // since collection key subscribers need to be updated differently + if (!isKeyToPreserve) { + const oldValue = cache.getValue(key); + const newValue = _.get(defaultKeyStates, key, null); + if (newValue !== oldValue) { + cache.set(key, newValue); + const collectionKey = key.substring(0, key.indexOf('_') + 1); + if (collectionKey) { + if (!keyValuesToResetAsCollection[collectionKey]) { + keyValuesToResetAsCollection[collectionKey] = {}; + } + keyValuesToResetAsCollection[collectionKey][key] = newValue; + } else { + keyValuesToResetIndividually[key] = newValue; + } } - keyValuesToResetAsCollection[collectionKey][key] = value; + } + + if (isKeyToPreserve || isDefaultKey) { return; } - keyValuesToResetIndividually[key] = value; + // If it isn't preserved and doesn't have a default, we'll remove it + keysToBeClearedFromStorage.push(key); }); // Notify the subscribers for each key/value group so they can receive the new values @@ -1106,11 +1097,10 @@ function clear(keysToPreserve = []) { notifyCollectionSubscribersOnNextTick(key, value); }); - // Call clear() and make sure that the default key/values and the key/values from the parameter - // are preserved in storage. This makes sure to always leave storage in a state that contains - // all the default values and any additional values that we want to remain after the database is cleared. - return Storage.clear() - .then(() => Storage.multiSet([...defaultKeyValuePairs, ...keyValuesToPreserve])); + const defaultKeyValuePairs = _.pairs(_.omit(defaultKeyStates, keysToPreserve)); + + // Remove only the items that we want cleared from storage, and reset others to default + return Storage.removeItems(keysToBeClearedFromStorage).then(() => Storage.multiSet(defaultKeyValuePairs)); }); } diff --git a/lib/storage/WebStorage.js b/lib/storage/WebStorage.js index c027cb493..fb7866794 100644 --- a/lib/storage/WebStorage.js +++ b/lib/storage/WebStorage.js @@ -17,6 +17,12 @@ function raiseStorageSyncEvent(onyxKey) { global.localStorage.removeItem(SYNC_ONYX, onyxKey); } +function raiseStorageSyncManyKeysEvent(onyxKeys) { + _.each(onyxKeys, (onyxKey) => { + raiseStorageSyncEvent(onyxKey); + }); +} + const webStorage = { ...Storage, @@ -31,6 +37,9 @@ const webStorage = { this.removeItem = key => Storage.removeItem(key) .then(() => raiseStorageSyncEvent(key)); + this.removeItems = keys => Storage.removeItems(keys) + .then(() => raiseStorageSyncManyKeysEvent(keys)); + // 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 over every key and raise a storage sync // event for each one diff --git a/lib/storage/providers/AsyncStorage.js b/lib/storage/providers/AsyncStorage.js index 62c253c8a..a793ebbea 100644 --- a/lib/storage/providers/AsyncStorage.js +++ b/lib/storage/providers/AsyncStorage.js @@ -73,6 +73,13 @@ const provider = { */ removeItem: AsyncStorage.removeItem, + /** + * Remove given keys and their values from storage + * @param {Array} keys + * @returns {Promise} + */ + removeItems: keys => Promise.all(_.map(keys, key => AsyncStorage.removeItem(key))), + /** * Clear everything from storage * @returns {Promise} diff --git a/lib/storage/providers/LocalForage.js b/lib/storage/providers/LocalForage.js index e42c505fa..338d1abe9 100644 --- a/lib/storage/providers/LocalForage.js +++ b/lib/storage/providers/LocalForage.js @@ -6,9 +6,12 @@ import localforage from 'localforage'; import _ from 'underscore'; +import {extendPrototype} from 'localforage-removeitems'; import SyncQueue from '../../SyncQueue'; import fastMerge from '../../fastMerge'; +extendPrototype(localforage); + localforage.config({ name: 'OnyxDB', }); @@ -103,6 +106,16 @@ const provider = { */ removeItem: localforage.removeItem, + /** + * Remove given keys and their values from storage + * + * @param {Array} keys + * @returns {Promise} + */ + removeItems(keys) { + return localforage.removeItems(keys); + }, + /** * Sets the value for a given key. The only requirement is that the value should be serializable to JSON string * @param {String} key diff --git a/lib/storage/providers/SQLiteStorage.js b/lib/storage/providers/SQLiteStorage.js index 0e5db8b5d..2fd865fa2 100644 --- a/lib/storage/providers/SQLiteStorage.js +++ b/lib/storage/providers/SQLiteStorage.js @@ -111,6 +111,18 @@ const provider = { */ removeItem: key => db.executeAsync('DELETE FROM keyvaluepairs WHERE record_key = ?;', [key]), + /** + * Removes given keys and their values from storage + * + * @param {Array} keys + * @returns {Promise} + */ + removeItems: (keys) => { + const placeholders = _.map(keys, () => '?').join(','); + const query = `DELETE FROM keyvaluepairs WHERE record_key IN (${placeholders});`; + return db.executeAsync(query, keys); + }, + /** * Clears absolutely everything from storage * @returns {Promise} diff --git a/package-lock.json b/package-lock.json index 8130c62b8..26b868695 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "@react-native-async-storage/async-storage": "^1.17.11", "expensify-common": ">=1", "localforage": "^1.10.0", + "localforage-removeitems": "^1.4.0", "react": ">=18.1.0", "react-native-performance": "^4.0.0", "react-native-quick-sqlite": "^8.0.0-beta.2" @@ -64,6 +65,9 @@ "localforage": { "optional": true }, + "localforage-removeitems": { + "optional": true + }, "react-native-performance": { "optional": true }, @@ -8702,7 +8706,7 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=", - "dev": true + "devOptional": true }, "node_modules/import-fresh": { "version": "3.2.2", @@ -11175,7 +11179,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", "integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=", - "dev": true, + "devOptional": true, "dependencies": { "immediate": "~3.0.5" } @@ -11222,11 +11226,21 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", - "dev": true, + "devOptional": true, "dependencies": { "lie": "3.1.1" } }, + "node_modules/localforage-removeitems": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/localforage-removeitems/-/localforage-removeitems-1.4.0.tgz", + "integrity": "sha512-/rFJ/w/FAZbVeXa6ZVANociT2sWaUglp2rgYARXcd/2v4CJMRbzvdcfYrNEBzEVkBRQqxipsUfyDDrAy9YiqzA==", + "optional": true, + "peer": true, + "dependencies": { + "localforage": ">=1.4.0" + } + }, "node_modules/locate-path": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", @@ -23916,7 +23930,7 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=", - "dev": true + "devOptional": true }, "import-fresh": { "version": "3.2.2", @@ -25853,7 +25867,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", "integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=", - "dev": true, + "devOptional": true, "requires": { "immediate": "~3.0.5" } @@ -25894,11 +25908,21 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", - "dev": true, + "devOptional": true, "requires": { "lie": "3.1.1" } }, + "localforage-removeitems": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/localforage-removeitems/-/localforage-removeitems-1.4.0.tgz", + "integrity": "sha512-/rFJ/w/FAZbVeXa6ZVANociT2sWaUglp2rgYARXcd/2v4CJMRbzvdcfYrNEBzEVkBRQqxipsUfyDDrAy9YiqzA==", + "optional": true, + "peer": true, + "requires": { + "localforage": ">=1.4.0" + } + }, "locate-path": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", diff --git a/package.json b/package.json index 6b0480bfd..377dcad66 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "@react-native-async-storage/async-storage": "^1.17.11", "expensify-common": ">=1", "localforage": "^1.10.0", + "localforage-removeitems": "^1.4.0", "react": ">=18.1.0", "react-native-performance": "^4.0.0", "react-native-quick-sqlite": "^8.0.0-beta.2" @@ -95,6 +96,9 @@ }, "localforage": { "optional": true + }, + "localforage-removeitems": { + "optional": true } }, "engines": { diff --git a/tests/unit/onyxTest.js b/tests/unit/onyxTest.js index 50b86084b..09d46e52c 100644 --- a/tests/unit/onyxTest.js +++ b/tests/unit/onyxTest.js @@ -114,12 +114,11 @@ describe('Onyx', () => { .then(() => Onyx.set(ONYX_KEYS.TEST_KEY, 'test')) .then(() => { expect(testKeyValue).toBe('test'); - expect(otherTestValue).toBe(42); return Onyx.clear().then(waitForPromisesToResolve); }) .then(() => { expect(testKeyValue).toBeNull(); - expect(otherTestValue).toBe(42); + expect(otherTestValue).toBe(null); return Onyx.disconnect(otherTestConnectionID); }); });