diff --git a/.eslintrc.js b/.eslintrc.js index f821efb7..7bf9593a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -38,6 +38,7 @@ module.exports = { project: './tsconfig.json', }, rules: { + '@typescript-eslint/prefer-for-of': 'off', 'rulesdir/prefer-underscore-method': 'off', 'react/jsx-props-no-spreading': 'off', 'react/require-default-props': 'off', diff --git a/lib/DevTools.js b/lib/DevTools.js deleted file mode 100644 index 58f15a71..00000000 --- a/lib/DevTools.js +++ /dev/null @@ -1,71 +0,0 @@ -import _ from 'underscore'; - -const ERROR_LABEL = 'Onyx DevTools - Error: '; - -/* eslint-disable no-underscore-dangle */ -class DevTools { - constructor() { - this.remoteDev = this.connectViaExtension(); - this.state = {}; - this.defaultState = {}; - } - - connectViaExtension(options) { - try { - if ((options && options.remote) || typeof window === 'undefined' || !window.__REDUX_DEVTOOLS_EXTENSION__) { - return; - } - return window.__REDUX_DEVTOOLS_EXTENSION__.connect(options); - } catch (e) { - console.error(ERROR_LABEL, e); - } - } - - /** - * Registers an action that updated the current state of the storage - * - * @param {string} type - name of the action - * @param {any} payload - data written to the storage - * @param {object} stateChanges - partial state that got updated after the changes - */ - registerAction(type, payload = undefined, stateChanges = {}) { - try { - if (!this.remoteDev) { - return; - } - const newState = { - ...this.state, - ...stateChanges, - }; - this.remoteDev.send({type, payload}, newState); - this.state = newState; - } catch (e) { - console.error(ERROR_LABEL, e); - } - } - - initState(initialState = {}) { - try { - if (!this.remoteDev) { - return; - } - this.remoteDev.init(initialState); - this.state = initialState; - this.defaultState = initialState; - } catch (e) { - console.error(ERROR_LABEL, e); - } - } - - /** - * This clears the internal state of the DevTools, preserving the keys included in `keysToPreserve` - * - * @param {string[]} keysToPreserve - */ - clearState(keysToPreserve = []) { - const newState = _.mapObject(this.state, (value, key) => (keysToPreserve.includes(key) ? value : this.defaultState[key])); - this.registerAction('CLEAR', undefined, newState); - } -} - -export default new DevTools(); diff --git a/lib/DevTools.ts b/lib/DevTools.ts new file mode 100644 index 00000000..76be784e --- /dev/null +++ b/lib/DevTools.ts @@ -0,0 +1,104 @@ +type DevtoolsOptions = { + maxAge?: number; + name?: string; + postTimelineUpdate?: () => void; + preAction?: () => void; + logTrace?: boolean; + remote?: boolean; +}; + +type DevtoolsSubscriber = (message: {type: string; payload: unknown; state: string}) => void; + +type DevtoolsConnection = { + send(data: Record, state: Record): void; + init(state: Record): void; + unsubscribe(): void; + subscribe(cb: DevtoolsSubscriber): () => void; +}; + +const ERROR_LABEL = 'Onyx DevTools - Error: '; + +type ReduxDevtools = { + connect(options?: DevtoolsOptions): DevtoolsConnection; +}; + +class DevTools { + private remoteDev?: DevtoolsConnection; + + private state: Record; + + private defaultState: Record; + + constructor() { + this.remoteDev = this.connectViaExtension(); + this.state = {}; + this.defaultState = {}; + } + + connectViaExtension(options?: DevtoolsOptions): DevtoolsConnection | undefined { + try { + // We don't want to augment the window type in a library code, so we use type assertion instead + // eslint-disable-next-line no-underscore-dangle, @typescript-eslint/no-explicit-any + const reduxDevtools: ReduxDevtools = (window as any).__REDUX_DEVTOOLS_EXTENSION__; + + if ((options && options.remote) || typeof window === 'undefined' || !reduxDevtools) { + return; + } + // eslint-disable-next-line no-underscore-dangle, @typescript-eslint/no-explicit-any + return reduxDevtools.connect(options); + } catch (e) { + console.error(ERROR_LABEL, e); + } + } + + /** + * Registers an action that updated the current state of the storage + * + * @param type - name of the action + * @param payload - data written to the storage + * @param stateChanges - partial state that got updated after the changes + */ + registerAction(type: string, payload: unknown, stateChanges: Record = {}) { + try { + if (!this.remoteDev) { + return; + } + const newState = { + ...this.state, + ...stateChanges, + }; + this.remoteDev.send({type, payload}, newState); + this.state = newState; + } catch (e) { + console.error(ERROR_LABEL, e); + } + } + + initState(initialState: Record = {}) { + try { + if (!this.remoteDev) { + return; + } + this.remoteDev.init(initialState); + this.state = initialState; + this.defaultState = initialState; + } catch (e) { + console.error(ERROR_LABEL, e); + } + } + + /** + * This clears the internal state of the DevTools, preserving the keys included in `keysToPreserve` + */ + public clearState(keysToPreserve: string[] = []): void { + const newState = Object.entries(this.state).reduce((obj: Record, [key, value]) => { + // eslint-disable-next-line no-param-reassign + obj[key] = keysToPreserve.includes(key) ? value : this.defaultState[key]; + return obj; + }, {}); + + this.registerAction('CLEAR', undefined, newState); + } +} + +export default new DevTools(); diff --git a/lib/MDTable.js b/lib/MDTable.js deleted file mode 100644 index 9a09132e..00000000 --- a/lib/MDTable.js +++ /dev/null @@ -1,66 +0,0 @@ -import AsciTable from 'ascii-table'; - -class MDTable extends AsciTable { - /** - * Create a CSV string from the table data - * @returns {string} - */ - toCSV() { - return [this.getTitle(), this.getHeading(), ...this.getRows()].join('\n'); - } - - /** - * Create a JSON string from the table data - * @returns {string} - */ - toJSON() { - return JSON.stringify(super.toJSON()); - } - - /** - * Create a MD string from the table data - * @returns {string} - */ - toString() { - // Ignore modifying the first |---| for titled tables - let idx = this.getTitle() ? -2 : -1; - const ascii = super.toString().replace(/-\|/g, () => { - /* we replace "----|" with "---:|" to align the data to the right in MD */ - idx++; - - if (idx < 0 || this.leftAlignedCols.includes(idx)) { - return '-|'; - } - - return ':|'; - }); - - // strip the top and the bottom row (----) to make an MD table - const md = ascii.split('\n').slice(1, -1).join('\n'); - return md; - } -} - -/** - * Table Factory helper - * @param {Object} options - * @param {string} [options.title] - optional title center above the table - * @param {string[]} options.heading - table column names - * @param {number[]} [options.leftAlignedCols=[]] - indexes of columns that should be left aligned - * Pass the columns that are non numeric here - the rest will be aligned to the right - * @param {Array} [options.rows] The table can be initialized with row. Rows can also be added by `addRow` - * @returns {MDTable} - */ -MDTable.factory = ({title, heading, leftAlignedCols = [], rows = []}) => { - const table = new MDTable({title, heading, rows}); - table.leftAlignedCols = leftAlignedCols; - - /* By default we want everything aligned to the right as most values are numbers - * we just override the columns that are not right aligned */ - heading.forEach((name, idx) => table.setAlign(idx, AsciTable.RIGHT)); - leftAlignedCols.forEach((idx) => table.setAlign(idx, AsciTable.LEFT)); - - return table; -}; - -export default MDTable; diff --git a/lib/Onyx.d.ts b/lib/Onyx.d.ts deleted file mode 100644 index 8bf30964..00000000 --- a/lib/Onyx.d.ts +++ /dev/null @@ -1,346 +0,0 @@ -import {Component} from 'react'; -import * as Logger from './Logger'; -import {CollectionKey, CollectionKeyBase, DeepRecord, KeyValueMapping, NullishDeep, OnyxCollection, OnyxEntry, OnyxKey, Selector} from './types'; - -/** - * Represents a mapping object where each `OnyxKey` maps to either a value of its corresponding type in `KeyValueMapping` or `null`. - * - * It's very similar to `KeyValueMapping` but this type accepts using `null` as well. - */ -type NullableKeyValueMapping = { - [TKey in OnyxKey]: OnyxEntry; -}; - -/** - * Represents the base options used in `Onyx.connect()` method. - */ -type BaseConnectOptions = { - statePropertyName?: string; - withOnyxInstance?: Component; - initWithStoredValues?: boolean; -}; - -type TryGetCachedValueMapping = { - selector?: Selector; - withOnyxInstance?: Component; -}; - -/** - * Represents the options used in `Onyx.connect()` method. - * The type is built from `BaseConnectOptions` and extended to handle key/callback related options. - * It includes two different forms, depending on whether we are waiting for a collection callback or not. - * - * If `waitForCollectionCallback` is `true`, it expects `key` to be a Onyx collection key and `callback` will be triggered with the whole collection - * and will pass `value` as an `OnyxCollection`. - * - * - * If `waitForCollectionCallback` is `false` or not specified, the `key` can be any Onyx key and `callback` will be triggered with updates of each collection item - * and will pass `value` as an `OnyxEntry`. - */ -type ConnectOptions = BaseConnectOptions & - ( - | { - key: TKey extends CollectionKeyBase ? TKey : never; - callback?: (value: OnyxCollection) => void; - waitForCollectionCallback: true; - } - | { - key: TKey; - callback?: (value: OnyxEntry, key: TKey) => void; - waitForCollectionCallback?: false; - } - ); - -/** - * Represents a mapping between Onyx collection keys and their respective values. - * - * It helps to enforce that a Onyx collection key should not be without suffix (e.g. should always be of the form `${TKey}${string}`), - * and to map each Onyx collection key with suffix to a value of type `TValue`. - * - * Also, the `TMap` type is inferred automatically in `mergeCollection()` method and represents - * the object of collection keys/values specified in the second parameter of the method. - */ -type Collection = { - [MapK in keyof TMap]: MapK extends `${TKey}${string}` - ? MapK extends `${TKey}` - ? never // forbids empty id - : TValue - : never; -}; - -/** - * Represents different kinds of updates that can be passed to `Onyx.update()` method. It is a discriminated union of - * different update methods (`SET`, `MERGE`, `MERGE_COLLECTION`), each with their own key and value structure. - */ -type OnyxUpdate = - | { - [TKey in OnyxKey]: - | { - onyxMethod: typeof METHOD.SET; - key: TKey; - value: OnyxEntry; - } - | { - onyxMethod: typeof METHOD.MERGE; - key: TKey; - value: OnyxEntry>; - }; - }[OnyxKey] - | { - [TKey in CollectionKeyBase]: { - onyxMethod: typeof METHOD.MERGE_COLLECTION; - key: TKey; - value: Record<`${TKey}${string}`, NullishDeep>; - }; - }[CollectionKeyBase]; - -/** - * Represents the options used in `Onyx.init()` method. - */ -type InitOptions = { - keys?: DeepRecord; - initialKeyStates?: Partial; - safeEvictionKeys?: OnyxKey[]; - maxCachedKeysCount?: number; - shouldSyncMultipleInstances?: boolean; - debugSetState?: boolean; -}; - -declare const METHOD: { - readonly SET: 'set'; - readonly MERGE: 'merge'; - readonly MERGE_COLLECTION: 'mergecollection'; - readonly MULTI_SET: 'multiset'; - readonly CLEAR: 'clear'; -}; - -/** - * Returns current key names stored in persisted storage - */ -declare function getAllKeys(): Promise>; - -/** - * Checks to see if the a subscriber's supplied key - * is associated with a collection of keys. - */ -declare function isCollectionKey(key: OnyxKey): key is CollectionKeyBase; - -declare function isCollectionMemberKey(collectionKey: TCollectionKey, key: string): key is `${TCollectionKey}${string}`; - -/** - * Splits a collection member key into the collection key part and the ID part. - * @param key - The collection member key to split. - * @returns A tuple where the first element is the collection part and the second element is the ID part. - */ -declare function splitCollectionMemberKey(key: TKey): [TKey extends `${infer Prefix}_${string}` ? `${Prefix}_` : never, string]; - -/** - * Checks to see if this key has been flagged as - * safe for removal. - */ -declare function isSafeEvictionKey(testKey: OnyxKey): boolean; - -/** - * Removes a key previously added to this list - * which will enable it to be deleted again. - */ -declare function removeFromEvictionBlockList(key: OnyxKey, connectionID: number): void; - -/** - * Keys added to this list can never be deleted. - */ -declare function addToEvictionBlockList(key: OnyxKey, connectionID: number): void; - -/** - * Subscribes a react component's state directly to a store key - * - * @example - * const connectionID = Onyx.connect({ - * key: ONYXKEYS.SESSION, - * callback: onSessionChange, - * }); - * - * @param mapping the mapping information to connect Onyx to the components state - * @param mapping.key ONYXKEY to subscribe to - * @param [mapping.statePropertyName] the name of the property in the state to connect the data to - * @param [mapping.withOnyxInstance] whose setState() method will be called with any changed data - * This is used by React components to connect to Onyx - * @param [mapping.callback] a method that will be called with changed data - * This is used by any non-React code to connect to Onyx - * @param [mapping.initWithStoredValues] If set to false, then no data will be prefilled into the - * component - * @param [mapping.waitForCollectionCallback] If set to true, it will return the entire collection to the callback as a single object - * @returns an ID to use when calling disconnect - */ -declare function connect(mapping: ConnectOptions): number; - -/** - * Remove the listener for a react component - * @example - * Onyx.disconnect(connectionID); - * - * @param connectionID unique id returned by call to Onyx.connect() - */ -declare function disconnect(connectionID: number, keyToRemoveFromEvictionBlocklist?: OnyxKey): void; - -/** - * Write a value to our store with the given key - * - * @param key ONYXKEY to set - * @param value value to store - */ -declare function set(key: TKey, value: OnyxEntry): Promise; - -/** - * Sets multiple keys and values - * - * @example Onyx.multiSet({'key1': 'a', 'key2': 'b'}); - * - * @param data object keyed by ONYXKEYS and the values to set - */ -declare function multiSet(data: Partial): Promise; - -/** - * Merge a new value into an existing value at a key. - * - * The types of values that can be merged are `Object` and `Array`. To set another type of value use `Onyx.set()`. Merge - * behavior uses lodash/merge under the hood for `Object` and simple concatenation for `Array`. However, it's important - * to note that if you have an array value property on an `Object` that the default behavior of lodash/merge is not to - * concatenate. See here: https://github.com/lodash/lodash/issues/2872 - * - * Calls to `Onyx.merge()` are batched so that any calls performed in a single tick will stack in a queue and get - * applied in the order they were called. Note: `Onyx.set()` calls do not work this way so use caution when mixing - * `Onyx.merge()` and `Onyx.set()`. - * - * @example - * Onyx.merge(ONYXKEYS.EMPLOYEE_LIST, ['Joe']); // -> ['Joe'] - * Onyx.merge(ONYXKEYS.EMPLOYEE_LIST, ['Jack']); // -> ['Joe', 'Jack'] - * Onyx.merge(ONYXKEYS.POLICY, {id: 1}); // -> {id: 1} - * Onyx.merge(ONYXKEYS.POLICY, {name: 'My Workspace'}); // -> {id: 1, name: 'My Workspace'} - * - * @param key ONYXKEYS key - * @param value Object or Array value to merge - */ -declare function merge(key: TKey, value: OnyxEntry>): Promise; - -/** - * Clear out all the data in the store - * - * Note that calling Onyx.clear() and then Onyx.set() on a key with a default - * key state may store an unexpected value in Storage. - * - * E.g. - * Onyx.clear(); - * Onyx.set(ONYXKEYS.DEFAULT_KEY, 'default'); - * Storage.getItem(ONYXKEYS.DEFAULT_KEY) - * .then((storedValue) => console.log(storedValue)); - * null is logged instead of the expected 'default' - * - * Onyx.set() might call Storage.setItem() before Onyx.clear() calls - * Storage.setItem(). Use Onyx.merge() instead if possible. Onyx.merge() calls - * Onyx.get(key) before calling Storage.setItem() via Onyx.set(). - * Storage.setItem() from Onyx.clear() will have already finished and the merged - * value will be saved to storage after the default value. - * - * @param keysToPreserve is a list of ONYXKEYS that should not be cleared with the rest of the data - */ -declare function clear(keysToPreserve?: OnyxKey[]): Promise; - -/** - * Merges a collection based on their keys - * - * Note that both `TKey` and `TMap` types are inferred automatically, `TKey` being the - * collection key specified in the first parameter and `TMap` being the object of - * collection keys/values specified in the second parameter. - * - * @example - * - * Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, { - * [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1, - * [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, - * }); - * - * @param collectionKey e.g. `ONYXKEYS.COLLECTION.REPORT` - * @param collection Object collection keyed by individual collection member keys and values - */ -declare function mergeCollection(collectionKey: TKey, collection: Collection>): Promise; - -/** - * Insert API responses and lifecycle data into Onyx - * - * @param data An array of update objects - * @returns resolves when all operations are complete - */ -declare function update(data: OnyxUpdate[]): Promise; - -/** - * Initialize the store with actions and listening for storage events - * - * @param [options={}] config object - * @param [options.keys={}] `ONYXKEYS` constants object - * @param [options.initialKeyStates={}] initial data to set when `init()` and `clear()` is called - * @param [options.safeEvictionKeys=[]] This is an array of keys - * (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 [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 [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) - * @param [options.debugSetState] Enables debugging setState() calls to connected components. - * @example - * Onyx.init({ - * keys: ONYXKEYS, - * initialKeyStates: { - * [ONYXKEYS.SESSION]: {loading: false}, - * }, - * }); - */ -declare function init(config?: InitOptions): void; - -/** - * @private - */ -declare function hasPendingMergeForKey(key: OnyxKey): boolean; - -/** - * When set these keys will not be persisted to storage - */ -declare function setMemoryOnlyKeys(keyList: OnyxKey[]): void; - -/** - * Tries to get a value from the cache. If the value is not present in cache it will return the default value or undefined. - * If the requested key is a collection, it will return an object with all the collection members. - */ -declare function tryGetCachedValue( - key: TKey, - mapping?: TryGetCachedValueMapping, -): TKey extends CollectionKeyBase ? OnyxCollection | undefined : OnyxEntry | undefined; - -declare const Onyx: { - connect: typeof connect; - disconnect: typeof disconnect; - set: typeof set; - multiSet: typeof multiSet; - merge: typeof merge; - mergeCollection: typeof mergeCollection; - hasPendingMergeForKey: typeof hasPendingMergeForKey; - update: typeof update; - clear: typeof clear; - getAllKeys: typeof getAllKeys; - init: typeof init; - registerLogger: typeof Logger.registerLogger; - addToEvictionBlockList: typeof addToEvictionBlockList; - removeFromEvictionBlockList: typeof removeFromEvictionBlockList; - isSafeEvictionKey: typeof isSafeEvictionKey; - METHOD: typeof METHOD; - setMemoryOnlyKeys: typeof setMemoryOnlyKeys; - tryGetCachedValue: typeof tryGetCachedValue; - isCollectionKey: typeof isCollectionKey; - isCollectionMemberKey: typeof isCollectionMemberKey; - splitCollectionMemberKey: typeof splitCollectionMemberKey; -}; - -export default Onyx; -export {ConnectOptions, OnyxUpdate}; diff --git a/lib/Onyx.js b/lib/Onyx.ts similarity index 67% rename from lib/Onyx.js rename to lib/Onyx.ts index 4a1f12c9..83ac2fdd 100644 --- a/lib/Onyx.js +++ b/lib/Onyx.ts @@ -1,6 +1,9 @@ /* eslint-disable no-continue */ +import type {Component} from 'react'; import {deepEqual} from 'fast-equals'; -import _ from 'underscore'; +import lodashClone from 'lodash/clone'; +import type {ValueOf} from 'type-fest'; +import type {QueryResult} from 'react-native-quick-sqlite'; import * as Logger from './Logger'; import cache from './OnyxCache'; import * as Str from './Str'; @@ -10,6 +13,74 @@ import Storage from './storage'; import utils from './utils'; import unstable_batchedUpdates from './batch'; import DevTools from './DevTools'; +import type {CollectionKey, CollectionKeyBase, DeepRecord, KeyValueMapping, NullishDeep, OnyxCollection, OnyxEntry, OnyxKey, OnyxValue, Selector, WithOnyxInstanceState} from './types'; + +/** + * Represents a mapping object where each `OnyxKey` maps to either a value of its corresponding type in `KeyValueMapping` or `null`. + * + * It's very similar to `KeyValueMapping` but this type accepts using `null` as well. + */ +type NullableKeyValueMapping = { + [TKey in OnyxKey]: OnyxEntry; +}; + +/** + * Represents a mapping between Onyx collection keys and their respective values. + * + * It helps to enforce that a Onyx collection key should not be without suffix (e.g. should always be of the form `${TKey}${string}`), + * and to map each Onyx collection key with suffix to a value of type `TValue`. + * + * Also, the `TMap` type is inferred automatically in `mergeCollection()` method and represents + * the object of collection keys/values specified in the second parameter of the method. + */ +type Collection = { + [MapK in keyof TMap]: MapK extends `${TKey}${string}` + ? MapK extends `${TKey}` + ? never // forbids empty id + : TValue + : never; +}; + +type WithOnyxInstance = Component>> & { + setStateProxy: (cb: (state: Record) => OnyxValue) => void; + setWithOnyxState: (statePropertyName: OnyxKey, value: OnyxValue) => void; +}; + +/** Represents the base options used in `Onyx.connect()` method. */ +type BaseConnectOptions = { + selector?: Selector; + withOnyxInstance?: WithOnyxInstance; + initWithStoredValues?: boolean; + canEvict?: boolean; +}; + +/** + * Represents the options used in `Onyx.connect()` method. + * The type is built from `BaseConnectOptions` and extended to handle key/callback related options. + * It includes two different forms, depending on whether we are waiting for a collection callback or not. + * + * If `waitForCollectionCallback` is `true`, it expects `key` to be a Onyx collection key and `callback` will be triggered with the whole collection + * and will pass `value` as an `OnyxCollection`. + * + * + * If `waitForCollectionCallback` is `false` or not specified, the `key` can be any Onyx key and `callback` will be triggered with updates of each collection item + * and will pass `value` as an `OnyxEntry`. + */ +type ConnectOptions = BaseConnectOptions & + ( + | { + key: TKey extends CollectionKey ? TKey : never; + callback?: (value: OnyxCollection) => void; + waitForCollectionCallback: true; + } + | { + key: TKey; + callback?: (value: OnyxEntry, key: TKey) => void; + waitForCollectionCallback?: false; + } + ); + +type Mapping = ConnectOptions & {connectionID: number; statePropertyName: string; displayName: string}; // Method constants const METHOD = { @@ -18,51 +89,55 @@ const METHOD = { MERGE_COLLECTION: 'mergecollection', MULTI_SET: 'multiset', CLEAR: 'clear', -}; +} as const; + +type OnyxMethod = ValueOf; // Key/value store of Onyx key and arrays of values to merge -const mergeQueue = {}; -const mergeQueuePromise = {}; +const mergeQueue: Record = {}; +const mergeQueuePromise: Record> = {}; // Keeps track of the last connectionID that was used so we can keep incrementing it let lastConnectionID = 0; // Holds a mapping of all the react components that want their state subscribed to a store key -const callbackToStateMapping = {}; +const callbackToStateMapping: Record> = {}; // Keeps a copy of the values of the onyx collection keys as a map for faster lookups -let onyxCollectionKeyMap = new Map(); +let onyxCollectionKeyMap = new Map(); // Holds a list of keys that have been directly subscribed to or recently modified from least to most recent -let recentlyAccessedKeys = []; +let recentlyAccessedKeys: OnyxKey[] = []; // Holds a list of keys that are safe to remove when we reach max storage. If a key does not match with // whatever appears in this list it will NEVER be a candidate for eviction. -let evictionAllowList = []; +let evictionAllowList: OnyxKey[] = []; // Holds a map of keys and connectionID arrays whose keys will never be automatically evicted as // long as we have at least one subscriber that returns false for the canEvict property. -const evictionBlocklist = {}; +const evictionBlocklist: Record = {}; // Optional user-provided key value states set when Onyx initializes or clears -let defaultKeyStates = {}; +let defaultKeyStates: Record = {}; // Connections can be made before `Onyx.init`. They would wait for this task before resolving const deferredInitTask = createDeferredTask(); -let batchUpdatesPromise = null; -let batchUpdatesQueue = []; +let batchUpdatesPromise: Promise | null = null; +let batchUpdatesQueue: Array<() => void> = []; /** * Sends an action to DevTools extension * - * @param {string} method - Onyx method from METHOD - * @param {string} key - Onyx key that was changed - * @param {any} value - contains the change that was made by the method - * @param {any} mergedValue - (optional) value that was written in the storage after a merge method was executed. + * @param method - Onyx method from METHOD + * @param key - Onyx key that was changed + * @param value - contains the change that was made by the method + * @param mergedValue - (optional) value that was written in the storage after a merge method was executed. */ -function sendActionToDevTools(method, key, value, mergedValue = undefined) { - DevTools.registerAction(utils.formatActionName(method, key), value, key ? {[key]: mergedValue || value} : value); +function sendActionToDevTools(method: OnyxMethod, key: undefined, value: Record, mergedValue?: OnyxValue): void; +function sendActionToDevTools(method: OnyxMethod, key: OnyxKey, value: OnyxValue, mergedValue?: OnyxValue): void; +function sendActionToDevTools(method: OnyxMethod, key: OnyxKey | undefined, value: Record | OnyxValue, mergedValue: OnyxValue = undefined): void { + DevTools.registerAction(utils.formatActionName(method, key), value, key ? {[key]: mergedValue || value} : (value as Record)); } /** @@ -70,9 +145,8 @@ function sendActionToDevTools(method, key, value, mergedValue = undefined) { * This happens for example in the Onyx.update function, where we process API responses that might contain a lot of * update operations. Instead of calling the subscribers for each update operation, we batch them together which will * cause react to schedule the updates at once instead of after each other. This is mainly a performance optimization. - * @returns {Promise} */ -function maybeFlushBatchUpdates() { +function maybeFlushBatchUpdates(): Promise { if (batchUpdatesPromise) { return batchUpdatesPromise; } @@ -98,49 +172,31 @@ function maybeFlushBatchUpdates() { return batchUpdatesPromise; } -function batchUpdates(updates) { +function batchUpdates(updates: () => void): Promise { batchUpdatesQueue.push(updates); return maybeFlushBatchUpdates(); } -/** - * Uses a selector function to return a simplified version of sourceData - * @param {Mixed} sourceData - * @param {Function} selector Function that takes sourceData and returns a simplified version of it - * @param {Object} [withOnyxInstanceState] - * @returns {Mixed} - */ -const getSubsetOfData = (sourceData, selector, withOnyxInstanceState) => selector(sourceData, withOnyxInstanceState); - /** * Takes a collection of items (eg. {testKey_1:{a:'a'}, testKey_2:{b:'b'}}) * and runs it through a reducer function to return a subset of the data according to a selector. * The resulting collection will only contain items that are returned by the selector. - * @param {Object} collection - * @param {String|Function} selector (see method docs for getSubsetOfData() for full details) - * @param {Object} [withOnyxInstanceState] - * @returns {Object} */ -const reduceCollectionWithSelector = (collection, selector, withOnyxInstanceState) => - _.reduce( - collection, - (finalCollection, item, key) => { - // eslint-disable-next-line no-param-reassign - finalCollection[key] = getSubsetOfData(item, selector, withOnyxInstanceState); - - return finalCollection; - }, - {}, - ); +function reduceCollectionWithSelector( + collection: Record, + selector: Selector | undefined, + withOnyxInstanceState: WithOnyxInstanceState | undefined, +): Record { + return Object.entries(collection ?? {}).reduce((finalCollection: Record, [key, item]) => { + // eslint-disable-next-line no-param-reassign + finalCollection[key] = selector?.(item, withOnyxInstanceState); + + return finalCollection; + }, {}); +} -/** - * Get some data from the store - * - * @private - * @param {string} key - * @returns {Promise<*>} - */ -function get(key) { +/** Get some data from the store */ +function get(key: OnyxKey): Promise { // When we already have the value in cache - resolve right away if (cache.hasCacheForKey(key)) { return Promise.resolve(cache.getValue(key)); @@ -164,12 +220,8 @@ function get(key) { return cache.captureTask(taskName, promise); } -/** - * Returns current key names stored in persisted storage - * @private - * @returns {Promise} - */ -function getAllKeys() { +/** Returns current key names stored in persisted storage */ +function getAllKeys(): Promise { // When we've already read stored keys, resolve right away const storedKeys = cache.getAllKeys(); if (storedKeys.length > 0) { @@ -180,7 +232,7 @@ function getAllKeys() { // When a value retrieving task for all keys is still running hook to it if (cache.hasPendingTask(taskName)) { - return cache.getTaskPromise(taskName); + return cache.getTaskPromise(taskName) as Promise; } // Otherwise retrieve the keys from storage and capture a promise to aid concurrent usages @@ -189,26 +241,17 @@ function getAllKeys() { return keys; }); - return cache.captureTask(taskName, promise); + return cache.captureTask(taskName, promise) as Promise; } /** - * Checks to see if the a subscriber's supplied key - * is associated with a collection of keys. - * - * @param {String} key - * @returns {Boolean} + * Checks to see if the a subscriber's supplied key is associated with a collection of keys. */ -function isCollectionKey(key) { +function isCollectionKey(key: OnyxKey): key is CollectionKeyBase { return onyxCollectionKeyMap.has(key); } -/** - * @param {String} collectionKey - * @param {String} key - * @returns {Boolean} - */ -function isCollectionMemberKey(collectionKey, key) { +function isCollectionMemberKey(collectionKey: TCollectionKey, key: string): key is `${TCollectionKey}${string}` { return Str.startsWith(key, collectionKey) && key.length > collectionKey.length; } @@ -230,38 +273,22 @@ function splitCollectionMemberKey(key) { /** * Checks to see if a provided key is the exact configured key of our connected subscriber * or if the provided key is a collection member key (in case our configured key is a "collection key") - * - * @private - * @param {String} configKey - * @param {String} key - * @return {Boolean} */ -function isKeyMatch(configKey, key) { +function isKeyMatch(configKey: OnyxKey, key: OnyxKey): boolean { return isCollectionKey(configKey) ? Str.startsWith(key, configKey) : configKey === key; } -/** - * Checks to see if this key has been flagged as - * safe for removal. - * - * @private - * @param {String} testKey - * @returns {Boolean} - */ -function isSafeEvictionKey(testKey) { - return _.some(evictionAllowList, (key) => isKeyMatch(key, testKey)); +/** Checks to see if this key has been flagged as safe for removal. */ +function isSafeEvictionKey(testKey: OnyxKey): boolean { + return evictionAllowList.some((key) => isKeyMatch(key, testKey)); } /** * Tries to get a value from the cache. If the value is not present in cache it will return the default value or undefined. * If the requested key is a collection, it will return an object with all the collection members. - * - * @param {String} key - * @param {Object} mapping - * @returns {Mixed} */ -function tryGetCachedValue(key, mapping = {}) { - let val = cache.getValue(key); +function tryGetCachedValue(key: TKey, mapping: Mapping): OnyxValue { + let val: OnyxValue | Record = cache.getValue(key); if (isCollectionKey(key)) { const allCacheKeys = cache.getAllKeys(); @@ -271,20 +298,17 @@ function tryGetCachedValue(key, mapping = {}) { if (allCacheKeys.length === 0) { return; } - const matchingKeys = _.filter(allCacheKeys, (k) => k.startsWith(key)); - const values = _.reduce( - matchingKeys, - (finalObject, matchedKey) => { - const cachedValue = cache.getValue(matchedKey); - if (cachedValue) { - // This is permissible because we're in the process of constructing the final object in a reduce function. - // eslint-disable-next-line no-param-reassign - finalObject[matchedKey] = cachedValue; - } - return finalObject; - }, - {}, - ); + + const matchingKeys = allCacheKeys.filter((k) => k.startsWith(key)); + const values = matchingKeys.reduce((finalObject: Record, matchedKey) => { + const cachedValue = cache.getValue(matchedKey); + if (cachedValue) { + // This is permissible because we're in the process of constructing the final object in a reduce function. + // eslint-disable-next-line no-param-reassign + finalObject[matchedKey] = cachedValue; + } + return finalObject; + }, {}); val = values; } @@ -292,33 +316,24 @@ function tryGetCachedValue(key, mapping = {}) { if (mapping.selector) { const state = mapping.withOnyxInstance ? mapping.withOnyxInstance.state : undefined; if (isCollectionKey(key)) { - return reduceCollectionWithSelector(val, mapping.selector, state); + return reduceCollectionWithSelector(val as Record, mapping.selector, state); } - return getSubsetOfData(val, mapping.selector, state); + return mapping.selector(val, state); } return val; } -/** - * Remove a key from the recently accessed key list. - * - * @private - * @param {String} key - */ -function removeLastAccessedKey(key) { - recentlyAccessedKeys = _.without(recentlyAccessedKeys, key); +/** Remove a key from the recently accessed key list. */ +function removeLastAccessedKey(key: OnyxKey): void { + recentlyAccessedKeys = recentlyAccessedKeys.filter((recentlyAccessedKey) => recentlyAccessedKey !== key); } /** - * Add a key to the list of recently accessed keys. The least - * recently accessed key should be at the head and the most - * recently accessed key at the tail. - * - * @private - * @param {String} key + * Add a key to the list of recently accessed keys. + * The least recently accessed key should be at the head and the most recently accessed key at the tail. */ -function addLastAccessedKey(key) { +function addLastAccessedKey(key: OnyxKey): void { // Only specific keys belong in this list since we cannot remove an entire collection. if (isCollectionKey(key) || !isSafeEvictionKey(key)) { return; @@ -329,30 +344,19 @@ function addLastAccessedKey(key) { } /** - * Removes a key previously added to this list - * which will enable it to be deleted again. - * - * @private - * @param {String} key - * @param {Number} connectionID + * Removes a key previously added to this list which will enable it to be deleted again. */ -function removeFromEvictionBlockList(key, connectionID) { - evictionBlocklist[key] = _.without(evictionBlocklist[key] || [], connectionID); +function removeFromEvictionBlockList(key: OnyxKey, connectionID: number): void { + evictionBlocklist[key] = evictionBlocklist[key]?.filter((evictionKey) => evictionKey !== connectionID); // Remove the key if there are no more subscribers - if (evictionBlocklist[key].length === 0) { + if (evictionBlocklist[key]?.length === 0) { delete evictionBlocklist[key]; } } -/** - * Keys added to this list can never be deleted. - * - * @private - * @param {String} key - * @param {Number} connectionID - */ -function addToEvictionBlockList(key, connectionID) { +/** Keys added to this list can never be deleted. */ +function addToEvictionBlockList(key: OnyxKey, connectionID: number): void { removeFromEvictionBlockList(key, connectionID); if (!evictionBlocklist[key]) { @@ -365,16 +369,12 @@ function addToEvictionBlockList(key, connectionID) { /** * Take all the keys that are safe to evict and add them to * the recently accessed list when initializing the app. This - * enables keys that have not recently been accessed to be - * removed. - * - * @private - * @returns {Promise} + * enables keys that have not recently been accessed to be removed. */ -function addAllSafeEvictionKeysToRecentlyAccessedList() { +function addAllSafeEvictionKeysToRecentlyAccessedList(): Promise { return getAllKeys().then((keys) => { - _.each(evictionAllowList, (safeEvictionKey) => { - _.each(keys, (key) => { + evictionAllowList.forEach((safeEvictionKey) => { + keys.forEach((key) => { if (!isKeyMatch(safeEvictionKey, key)) { return; } @@ -384,44 +384,27 @@ function addAllSafeEvictionKeysToRecentlyAccessedList() { }); } -/** - * @private - * @param {String} collectionKey - * @returns {Object} - */ -function getCachedCollection(collectionKey) { - const collectionMemberKeys = _.filter(cache.getAllKeys(), (storedKey) => isCollectionMemberKey(collectionKey, storedKey)); - - return _.reduce( - collectionMemberKeys, - (prev, curr) => { - const cachedValue = cache.getValue(curr); - if (!cachedValue) { - return prev; - } +function getCachedCollection(collectionKey: TKey): Record { + const collectionMemberKeys = cache.getAllKeys().filter((storedKey) => isCollectionMemberKey(collectionKey, storedKey)); - // eslint-disable-next-line no-param-reassign - prev[curr] = cachedValue; + return collectionMemberKeys.reduce((prev: Record, key) => { + const cachedValue = cache.getValue(key); + if (!cachedValue) { return prev; - }, - {}, - ); + } + + // eslint-disable-next-line no-param-reassign + prev[key] = cachedValue; + return prev; + }, {}); } -/** - * When a collection of keys change, search for any callbacks matching the collection key and trigger those callbacks - * - * @private - * @param {String} collectionKey - * @param {Object} partialCollection - a partial collection of grouped member keys - * @param {boolean} [notifyRegularSubscibers=true] - * @param {boolean} [notifyWithOnyxSubscibers=true] - */ -function keysChanged(collectionKey, partialCollection, notifyRegularSubscibers = true, notifyWithOnyxSubscibers = true) { +/** When a collection of keys change, search for any callbacks matching the collection key and trigger those callbacks */ +function keysChanged(collectionKey: TKey, partialCollection: OnyxCollection, notifyRegularSubscibers = true, notifyWithOnyxSubscibers = true) { // We are iterating over all subscribers similar to keyChanged(). However, we are looking for subscribers who are subscribing to either a collection key or - // individual collection key member for the collection that is being updated. It is important to note that the collection parameter cane be a PARTIAL collection + // individual collection key member for the collection that is being updated. It is important to note that the collection parameter can be a PARTIAL collection // and does not represent all of the combined keys and values for a collection key. It is just the "new" data that was merged in via mergeCollection(). - const stateMappingKeys = _.keys(callbackToStateMapping); + const stateMappingKeys = Object.keys(callbackToStateMapping); for (let i = 0; i < stateMappingKeys.length; i++) { const subscriber = callbackToStateMapping[stateMappingKeys[i]]; if (!subscriber) { @@ -448,7 +431,7 @@ function keysChanged(collectionKey, partialCollection, notifyRegularSubscibers = const cachedCollection = getCachedCollection(collectionKey); // Regular Onyx.connect() subscriber found. - if (_.isFunction(subscriber.callback)) { + if (typeof subscriber.callback === 'function') { if (!notifyRegularSubscibers) { continue; } @@ -463,7 +446,7 @@ function keysChanged(collectionKey, partialCollection, notifyRegularSubscibers = // If they are not using waitForCollectionCallback then we notify the subscriber with // the new merged data but only for any keys in the partial collection. - const dataKeys = _.keys(partialCollection); + const dataKeys = partialCollection && typeof partialCollection === 'object' ? Object.keys(partialCollection) : []; for (let j = 0; j < dataKeys.length; j++) { const dataKey = dataKeys[j]; subscriber.callback(cachedCollection[dataKey], dataKey); @@ -474,7 +457,7 @@ function keysChanged(collectionKey, partialCollection, notifyRegularSubscibers = // And if the subscriber is specifically only tracking a particular collection member key then we will // notify them with the cached data for that key only. if (isSubscribedToCollectionMemberKey) { - subscriber.callback(cachedCollection[subscriber.key], subscriber.key); + subscriber.callback(cachedCollection[subscriber.key] as OnyxCollection, subscriber.key); continue; } @@ -495,7 +478,7 @@ function keysChanged(collectionKey, partialCollection, notifyRegularSubscibers = if (subscriber.selector) { subscriber.withOnyxInstance.setStateProxy((prevState) => { const previousData = prevState[subscriber.statePropertyName]; - const newData = reduceCollectionWithSelector(cachedCollection, subscriber.selector, subscriber.withOnyxInstance.state); + const newData = reduceCollectionWithSelector(cachedCollection, subscriber.selector, subscriber.withOnyxInstance?.state); if (!deepEqual(previousData, newData)) { return { @@ -508,8 +491,8 @@ function keysChanged(collectionKey, partialCollection, notifyRegularSubscibers = } subscriber.withOnyxInstance.setStateProxy((prevState) => { - const finalCollection = _.clone(prevState[subscriber.statePropertyName] || {}); - const dataKeys = _.keys(partialCollection); + const finalCollection = lodashClone(prevState[subscriber.statePropertyName] || {}) as Record; + const dataKeys = Object.keys(partialCollection ?? {}); for (let j = 0; j < dataKeys.length; j++) { const dataKey = dataKeys[j]; finalCollection[dataKey] = cachedCollection[dataKey]; @@ -527,8 +510,8 @@ function keysChanged(collectionKey, partialCollection, notifyRegularSubscibers = if (isSubscribedToCollectionMemberKey) { // However, we only want to update this subscriber if the partial data contains a change. // Otherwise, we would update them with a value they already have and trigger an unnecessary re-render. - const dataFromCollection = partialCollection[subscriber.key]; - if (_.isUndefined(dataFromCollection)) { + const dataFromCollection = partialCollection?.[subscriber.key]; + if (dataFromCollection === undefined) { continue; } @@ -538,7 +521,7 @@ function keysChanged(collectionKey, partialCollection, notifyRegularSubscibers = if (subscriber.selector) { subscriber.withOnyxInstance.setStateProxy((prevState) => { const prevData = prevState[subscriber.statePropertyName]; - const newData = getSubsetOfData(cachedCollection[subscriber.key], subscriber.selector, subscriber.withOnyxInstance.state); + const newData = subscriber.selector?.(cachedCollection[subscriber.key], subscriber.withOnyxInstance?.state); if (!deepEqual(prevData, newData)) { PerformanceUtils.logSetStateCall(subscriber, prevData, newData, 'keysChanged', collectionKey); return { @@ -579,17 +562,18 @@ function keysChanged(collectionKey, partialCollection, notifyRegularSubscibers = * @example * keyChanged(key, value, subscriber => subscriber.initWithStoredValues === false) * - * @private - * @param {String} key - * @param {*} data - * @param {*} prevData - * @param {Function} [canUpdateSubscriber] only subscribers that pass this truth test will be updated - * @param {boolean} [notifyRegularSubscibers=true] - * @param {boolean} [notifyWithOnyxSubscibers=true] + * @param [canUpdateSubscriber] only subscribers that pass this truth test will be updated */ -function keyChanged(key, data, prevData, canUpdateSubscriber = () => true, notifyRegularSubscibers = true, notifyWithOnyxSubscibers = true) { +function keyChanged( + key: OnyxKey, + data: OnyxValue, + prevData: OnyxValue, + canUpdateSubscriber = (_subscriber: Mapping) => true, + notifyRegularSubscibers = true, + notifyWithOnyxSubscibers = true, +) { // Add or remove this key from the recentlyAccessedKeys lists - if (!_.isNull(data)) { + if (data !== null) { addLastAccessedKey(key); } else { removeLastAccessedKey(key); @@ -598,7 +582,7 @@ function keyChanged(key, data, prevData, canUpdateSubscriber = () => true, notif // We are iterating over all subscribers to see if they are interested in the key that has just changed. If the subscriber's key is a collection key then we will // notify them if the key that changed is a collection member. Or if it is a regular key notify them when there is an exact match. Depending on whether the subscriber // was connected via withOnyx we will call setState() directly on the withOnyx instance. If it is a regular connection we will pass the data to the provided callback. - const stateMappingKeys = _.keys(callbackToStateMapping); + const stateMappingKeys = Object.keys(callbackToStateMapping); for (let i = 0; i < stateMappingKeys.length; i++) { const subscriber = callbackToStateMapping[stateMappingKeys[i]]; if (!subscriber || !isKeyMatch(subscriber.key, key) || !canUpdateSubscriber(subscriber)) { @@ -606,7 +590,7 @@ function keyChanged(key, data, prevData, canUpdateSubscriber = () => true, notif } // Subscriber is a regular call to connect() and provided a callback - if (_.isFunction(subscriber.callback)) { + if (typeof subscriber.callback === 'function') { if (!notifyRegularSubscibers) { continue; } @@ -616,8 +600,7 @@ function keyChanged(key, data, prevData, canUpdateSubscriber = () => true, notif subscriber.callback(cachedCollection); continue; } - - subscriber.callback(data, key); + subscriber.callback(data as Record, key); continue; } @@ -633,9 +616,9 @@ function keyChanged(key, data, prevData, canUpdateSubscriber = () => true, notif // returned by the selector and only when the selected data has changed. if (subscriber.selector) { subscriber.withOnyxInstance.setStateProxy((prevState) => { - const prevWithOnyxData = prevState[subscriber.statePropertyName]; + const prevWithOnyxData = prevState[subscriber.statePropertyName] as Record; const newWithOnyxData = { - [key]: getSubsetOfData(data, subscriber.selector, subscriber.withOnyxInstance.state), + [key]: subscriber.selector?.(data, subscriber.withOnyxInstance?.state), }; const prevDataWithNewData = { ...prevWithOnyxData, @@ -670,8 +653,8 @@ function keyChanged(key, data, prevData, canUpdateSubscriber = () => true, notif // returned by the selector and only if the selected data has changed. if (subscriber.selector) { subscriber.withOnyxInstance.setStateProxy(() => { - const previousValue = getSubsetOfData(prevData, subscriber.selector, subscriber.withOnyxInstance.state); - const newValue = getSubsetOfData(data, subscriber.selector, subscriber.withOnyxInstance.state); + const previousValue = subscriber.selector?.(prevData, subscriber.withOnyxInstance?.state); + const newValue = subscriber.selector?.(data, subscriber.withOnyxInstance?.state); if (!deepEqual(previousValue, newValue)) { return { @@ -711,18 +694,8 @@ function keyChanged(key, data, prevData, canUpdateSubscriber = () => true, notif * Sends the data obtained from the keys to the connection. It either: * - sets state on the withOnyxInstances * - triggers the callback function - * - * @private - * @param {Object} mapping - * @param {Object} [mapping.withOnyxInstance] - * @param {String} [mapping.statePropertyName] - * @param {Function} [mapping.callback] - * @param {String} [mapping.selector] - * @param {*|null} val - * @param {String|undefined} matchedKey - * @param {Boolean} isBatched */ -function sendDataToConnection(mapping, val, matchedKey, isBatched) { +function sendDataToConnection(mapping: Mapping, val: OnyxValue | Record, matchedKey: TKey | undefined, isBatched: boolean) { // If the mapping no longer exists then we should not send any data. // This means our subscriber disconnected or withOnyx wrapped component unmounted. if (!callbackToStateMapping[mapping.connectionID]) { @@ -736,16 +709,16 @@ function sendDataToConnection(mapping, val, matchedKey, isBatched) { // returned by the selector. if (mapping.selector) { if (isCollectionKey(mapping.key)) { - newData = reduceCollectionWithSelector(val, mapping.selector, mapping.withOnyxInstance.state); + newData = reduceCollectionWithSelector(val as Record, mapping.selector, mapping.withOnyxInstance.state); } else { - newData = getSubsetOfData(val, mapping.selector, mapping.withOnyxInstance.state); + newData = mapping.selector(val, mapping.withOnyxInstance.state); } } PerformanceUtils.logSetStateCall(mapping, null, newData, 'sendDataToConnection'); if (isBatched) { batchUpdates(() => { - mapping.withOnyxInstance.setWithOnyxState(mapping.statePropertyName, newData); + mapping.withOnyxInstance?.setWithOnyxState(mapping.statePropertyName, newData); }); } else { mapping.withOnyxInstance.setWithOnyxState(mapping.statePropertyName, newData); @@ -753,19 +726,16 @@ function sendDataToConnection(mapping, val, matchedKey, isBatched) { return; } - if (_.isFunction(mapping.callback)) { - mapping.callback(val, matchedKey); + if (typeof mapping.callback === 'function') { + mapping.callback(val as Record, matchedKey as TKey); } } /** * We check to see if this key is flagged as safe for eviction and add it to the recentlyAccessedKeys list so that when we * run out of storage the least recently accessed key can be removed. - * - * @private - * @param {Object} mapping */ -function addKeyToRecentlyAccessedIfNeeded(mapping) { +function addKeyToRecentlyAccessedIfNeeded(mapping: Mapping) { if (!isSafeEvictionKey(mapping.key)) { return; } @@ -775,7 +745,7 @@ function addKeyToRecentlyAccessedIfNeeded(mapping) { if (mapping.withOnyxInstance && !isCollectionKey(mapping.key)) { // All React components subscribing to a key flagged as a safe eviction key must implement the canEvict property. - if (_.isUndefined(mapping.canEvict)) { + if (mapping.canEvict === undefined) { throw new Error(`Cannot subscribe to safe eviction key '${mapping.key}' without providing a canEvict value.`); } @@ -785,21 +755,17 @@ function addKeyToRecentlyAccessedIfNeeded(mapping) { /** * Gets the data for a given an array of matching keys, combines them into an object, and sends the result back to the subscriber. - * - * @private - * @param {Array} matchingKeys - * @param {Object} mapping */ -function getCollectionDataAndSendAsObject(matchingKeys, mapping) { +function getCollectionDataAndSendAsObject(matchingKeys: CollectionKeyBase[], mapping: Mapping) { // Keys that are not in the cache - const missingKeys = []; + const missingKeys: OnyxKey[] = []; // Tasks that are pending - const pendingTasks = []; + const pendingTasks: Array> = []; // Keys for the tasks that are pending - const pendingKeys = []; + const pendingKeys: OnyxKey[] = []; // We are going to combine all the data from the matching keys into a single object - const data = {}; + const data: Record = {}; /** * We are going to iterate over all the matching keys and check if we have the data in the cache. @@ -837,7 +803,7 @@ function getCollectionDataAndSendAsObject(matchingKeys, mapping) { // We are going to get the missing keys using multiGet from the storage. .then(() => { if (missingKeys.length === 0) { - return Promise.resolve(); + return Promise.resolve(undefined); } return Storage.multiGet(missingKeys); }) @@ -848,7 +814,7 @@ function getCollectionDataAndSendAsObject(matchingKeys, mapping) { } // temp object is used to merge the missing data into the cache - const temp = {}; + const temp: Record = {}; values.forEach((value) => { data[value[0]] = value[1]; temp[value[0]] = value[1]; @@ -871,26 +837,21 @@ function getCollectionDataAndSendAsObject(matchingKeys, mapping) { * callback: onSessionChange, * }); * - * @param {Object} mapping the mapping information to connect Onyx to the components state - * @param {String} mapping.key ONYXKEY to subscribe to - * @param {String} [mapping.statePropertyName] the name of the property in the state to connect the data to - * @param {Object} [mapping.withOnyxInstance] whose setState() method will be called with any changed data + * @param mapping the mapping information to connect Onyx to the components state + * @param mapping.key ONYXKEY to subscribe to + * @param [mapping.statePropertyName] the name of the property in the state to connect the data to + * @param [mapping.withOnyxInstance] whose setState() method will be called with any changed data * This is used by React components to connect to Onyx - * @param {Function} [mapping.callback] a method that will be called with changed data + * @param [mapping.callback] a method that will be called with changed data * This is used by any non-React code to connect to Onyx - * @param {Boolean} [mapping.initWithStoredValues] If set to false, then no data will be prefilled into the + * @param [mapping.initWithStoredValues] If set to false, then no data will be prefilled into the * component - * @param {Boolean} [mapping.waitForCollectionCallback] If set to true, it will return the entire collection to the callback as a single object - * @param {Function} [mapping.selector] THIS PARAM IS ONLY USED WITH withOnyx(). If included, this will be used to subscribe to a subset of an Onyx key's data. - * The sourceData and withOnyx state are passed to the selector and should return the simplified data. Using this setting on `withOnyx` can have very positive - * performance benefits because the component will only re-render when the subset of data changes. Otherwise, any change of data on any property would normally - * cause the component to re-render (and that can be expensive from a performance standpoint). - * @param {String | Number | Boolean | Object} [mapping.initialValue] THIS PARAM IS ONLY USED WITH withOnyx(). - * If included, this will be passed to the component so that something can be rendered while data is being fetched from the DB. - * Note that it will not cause the component to have the loading prop set to true. | - * @returns {Number} an ID to use when calling disconnect + * @param [mapping.waitForCollectionCallback] If set to true, it will return the entire collection to the callback as a single object + * @returns an ID to use when calling disconnect */ -function connect(mapping) { +function connect(options: ConnectOptions): number { + const mapping = options as unknown as Mapping; + const connectionID = lastConnectionID++; callbackToStateMapping[connectionID] = mapping; callbackToStateMapping[connectionID].connectionID = connectionID; @@ -915,7 +876,7 @@ function connect(mapping) { // We search all the keys in storage to see if any are a "match" for the subscriber we are connecting so that we // can send data back to the subscriber. Note that multiple keys can match as a subscriber could either be // subscribed to a "collection key" or a single key. - const matchingKeys = _.filter(keys, (key) => isKeyMatch(mapping.key, key)); + const matchingKeys = keys.filter((key) => isKeyMatch(mapping.key, key)); // If the key being connected to does not exist we initialize the value with null. For subscribers that connected // directly via connect() they will simply get a null value sent to them without any information about which key matched @@ -935,7 +896,7 @@ function connect(mapping) { // When using a callback subscriber we will either trigger the provided callback for each key we find or combine all values // into an object and just make a single call. The latter behavior is enabled by providing a waitForCollectionCallback key // combined with a subscription to a collection key. - if (_.isFunction(mapping.callback)) { + if (typeof mapping.callback === 'function') { if (isCollectionKey(mapping.key)) { if (mapping.waitForCollectionCallback) { getCollectionDataAndSendAsObject(matchingKeys, mapping); @@ -944,7 +905,7 @@ function connect(mapping) { // We did not opt into using waitForCollectionCallback mode so the callback is called for every matching key. for (let i = 0; i < matchingKeys.length; i++) { - get(matchingKeys[i]).then((val) => sendDataToConnection(mapping, val, matchingKeys[i], true)); + get(matchingKeys[i]).then((val) => sendDataToConnection(mapping, val, matchingKeys[i] as TKey, true)); } return; } @@ -980,10 +941,9 @@ function connect(mapping) { * @example * Onyx.disconnect(connectionID); * - * @param {Number} connectionID unique id returned by call to Onyx.connect() - * @param {String} [keyToRemoveFromEvictionBlocklist] + * @param connectionID unique id returned by call to Onyx.connect() */ -function disconnect(connectionID, keyToRemoveFromEvictionBlocklist) { +function disconnect(connectionID: number, keyToRemoveFromEvictionBlocklist?: OnyxKey): void { if (!callbackToStateMapping[connectionID]) { return; } @@ -1002,14 +962,13 @@ function disconnect(connectionID, keyToRemoveFromEvictionBlocklist) { * * @example * scheduleSubscriberUpdate(key, value, subscriber => subscriber.initWithStoredValues === false) - * - * @param {String} key - * @param {*} value - * @param {*} prevValue - * @param {Function} [canUpdateSubscriber] only subscribers that pass this truth test will be updated - * @returns {Promise} */ -function scheduleSubscriberUpdate(key, value, prevValue, canUpdateSubscriber = () => true) { +function scheduleSubscriberUpdate( + key: TKey, + value: KeyValueMapping[TKey], + prevValue: KeyValueMapping[TKey], + canUpdateSubscriber = (_subscriber: Mapping) => true, +): Promise<[void, void]> { const promise = Promise.resolve().then(() => keyChanged(key, value, prevValue, canUpdateSubscriber, true, false)); batchUpdates(() => keyChanged(key, value, prevValue, canUpdateSubscriber, false, true)); return Promise.all([maybeFlushBatchUpdates(), promise]); @@ -1019,12 +978,8 @@ function scheduleSubscriberUpdate(key, value, prevValue, canUpdateSubscriber = ( * This method is similar to notifySubscribersOnNextTick but it is built for working specifically with collections * so that keysChanged() is triggered for the collection and not keyChanged(). If this was not done, then the * subscriber callbacks receive the data in a different format than they normally expect and it breaks code. - * - * @param {String} key - * @param {*} value - * @returns {Promise} */ -function scheduleNotifyCollectionSubscribers(key, value) { +function scheduleNotifyCollectionSubscribers(key: OnyxKey, value: OnyxCollection) { const promise = Promise.resolve().then(() => keysChanged(key, value, true, false)); batchUpdates(() => keysChanged(key, value, false, true)); return Promise.all([maybeFlushBatchUpdates(), promise]); @@ -1032,23 +987,15 @@ function scheduleNotifyCollectionSubscribers(key, value) { /** * Remove a key from Onyx and update the subscribers - * - * @private - * @param {String} key - * @return {Promise} */ -function remove(key) { +function remove(key: TKey): Promise { const prevValue = cache.getValue(key, false); cache.drop(key); scheduleSubscriberUpdate(key, null, prevValue); - return Storage.removeItem(key); + return Storage.removeItem(key) as Promise; } -/** - * @private - * @returns {Promise} - */ -function reportStorageQuota() { +function reportStorageQuota(): Promise { return Storage.getDatabaseSize() .then(({bytesUsed, bytesRemaining}) => { Logger.logInfo(`Storage Quota Check -- bytesUsed: ${bytesUsed} bytesRemaining: ${bytesRemaining}`); @@ -1062,14 +1009,8 @@ function reportStorageQuota() { * If we fail to set or merge we must handle this by * evicting some data from Onyx and then retrying to do * whatever it is we attempted to do. - * - * @private - * @param {Error} error - * @param {Function} onyxMethod - * @param {...any} args - * @return {Promise} */ -function evictStorageAndRetry(error, onyxMethod, ...args) { +function evictStorageAndRetry(error: Error, onyxMethod: TMethod, ...args: Parameters) { Logger.logInfo(`Failed to save to storage. Error: ${error}. onyxMethod: ${onyxMethod.name}`); if (error && Str.startsWith(error.message, "Failed to execute 'put' on 'IDBObjectStore'")) { @@ -1078,7 +1019,7 @@ function evictStorageAndRetry(error, onyxMethod, ...args) { } // Find the first key that we can remove that has no subscribers in our blocklist - const keyForRemoval = _.find(recentlyAccessedKeys, (key) => !evictionBlocklist[key]); + const keyForRemoval = recentlyAccessedKeys.find((key) => !evictionBlocklist[key]); if (!keyForRemoval) { // If we have no acceptable keys to remove then we are possibly trying to save mission critical data. If this is the case, // then we should stop retrying as there is not much the user can do to fix this. Instead of getting them stuck in an infinite loop we @@ -1090,22 +1031,15 @@ function evictStorageAndRetry(error, onyxMethod, ...args) { // Remove the least recently viewed key that is not currently being accessed and retry. Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying.`); reportStorageQuota(); + + // @ts-expect-error No overload matches this call. return remove(keyForRemoval).then(() => onyxMethod(...args)); } -/** - * Notifys subscribers and writes current value to cache - * - * @param {String} key - * @param {*} value - * @param {String} method - * @param {Boolean} hasChanged - * @param {Boolean} wasRemoved - * @returns {Promise} - */ -function broadcastUpdate(key, value, method, hasChanged, wasRemoved = false) { +/** Notifies subscribers and writes current value to cache */ +function broadcastUpdate(key: TKey, value: KeyValueMapping[TKey], method: string, hasChanged: boolean, wasRemoved = false) { // Logging properties only since values could be sensitive things we don't want to log - Logger.logInfo(`${method}() called for key: ${key}${_.isObject(value) ? ` properties: ${_.keys(value).join(',')}` : ''}`); + Logger.logInfo(`${method}() called for key: ${key}${value && typeof value === 'object' ? ` properties: ${Object.keys(value).join(',')}` : ''}`); const prevValue = cache.getValue(key, false); // Update subscribers if the cached value has changed, or when the subscriber specifically requires @@ -1119,23 +1053,17 @@ function broadcastUpdate(key, value, method, hasChanged, wasRemoved = false) { return scheduleSubscriberUpdate(key, value, prevValue, (subscriber) => hasChanged || subscriber.initWithStoredValues === false); } -/** - * @param {String} key - * @returns {Boolean} - */ -function hasPendingMergeForKey(key) { - return Boolean(mergeQueue[key]); +function hasPendingMergeForKey(key: OnyxKey): boolean { + return !!mergeQueue[key]; } /** * Removes a key from storage if the value is null. * Otherwise removes all nested null values in objects and returns the object - * @param {String} key - * @param {Mixed} value - * @returns {Mixed} The value without null values and a boolean "wasRemoved", which indicates if the key got removed completely + * @returns The value without null values and a boolean "wasRemoved", which indicates if the key got removed completely */ -function removeNullValues(key, value) { - if (_.isNull(value)) { +function removeNullValues(key: OnyxKey, value: OnyxValue | Record | null) { + if (value === null) { remove(key); return {value, wasRemoved: true}; } @@ -1143,18 +1071,17 @@ function removeNullValues(key, value) { // We can remove all null values in an object by merging it with itself // utils.fastMerge recursively goes through the object and removes all null values // Passing two identical objects as source and target to fastMerge will not change it, but only remove the null values - return {value: utils.removeNestedNullValues(value), wasRemoved: false}; + return {value: utils.removeNestedNullValues(value as Record), wasRemoved: false}; } /** * Write a value to our store with the given key * - * @param {String} key ONYXKEY to set - * @param {*} value value to store - * - * @returns {Promise} + * @param key ONYXKEY to set + * @param value value to store */ -function set(key, value) { + +function set(key: OnyxKey, value: OnyxValue | Record): Promise { // If the value is null, we remove the key from storage const {value: valueAfterRemoving, wasRemoved} = removeNullValues(key, value); @@ -1184,14 +1111,13 @@ function set(key, value) { * 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 and removes key-value pairs that are being set to null - * @private - * @param {Record} data - * @return {Array} an array of key - value pairs <[key, value]> + * + * @return an array of key - value pairs <[key, value]> */ -function prepareKeyValuePairsForStorage(data) { - const keyValuePairs = []; +function prepareKeyValuePairsForStorage(data: Record): Array<[OnyxKey, OnyxValue]> { + const keyValuePairs: Array<[OnyxKey, OnyxValue]> = []; - _.forEach(data, (value, key) => { + Object.entries(data).forEach(([key, value]) => { const {value: valueAfterRemoving, wasRemoved} = removeNullValues(key, value); if (wasRemoved) return; @@ -1207,13 +1133,12 @@ function prepareKeyValuePairsForStorage(data) { * * @example Onyx.multiSet({'key1': 'a', 'key2': 'b'}); * - * @param {Object} data object keyed by ONYXKEYS and the values to set - * @returns {Promise} + * @param data object keyed by ONYXKEYS and the values to set */ -function multiSet(data) { +function multiSet(data: Partial): Promise> { const keyValuePairs = prepareKeyValuePairsForStorage(data); - const updatePromises = _.map(keyValuePairs, ([key, value]) => { + const updatePromises = keyValuePairs.map(([key, value]) => { const prevValue = cache.getValue(key, false); // Update cache and optimistically inform subscribers on the next tick @@ -1232,22 +1157,19 @@ function multiSet(data) { /** * Merges an array of changes with an existing value * - * @private - * @param {*} existingValue - * @param {Array<*>} changes Array of changes that should be applied to the existing value - * @param {Boolean} shouldRemoveNullObjectValues - * @returns {*} + * @param changes Array of changes that should be applied to the existing value */ -function applyMerge(existingValue, changes, shouldRemoveNullObjectValues) { - const lastChange = _.last(changes); +function applyMerge(existingValue: OnyxValue, changes: Array>, shouldRemoveNullObjectValues: boolean) { + const lastChange = changes?.at(-1); - if (_.isArray(lastChange)) { + if (Array.isArray(lastChange)) { return lastChange; } - if (_.some(changes, _.isObject)) { + if (changes.some((change) => typeof change === 'object')) { // Object values are then merged one after the other - return _.reduce(changes, (modifiedData, change) => utils.fastMerge(modifiedData, change, shouldRemoveNullObjectValues), existingValue || {}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return changes.reduce((modifiedData, change) => utils.fastMerge(modifiedData as any, change as any, shouldRemoveNullObjectValues), existingValue || {}); } // If we have anything else we can't merge it so we'll @@ -1270,15 +1192,11 @@ function applyMerge(existingValue, changes, shouldRemoveNullObjectValues) { * Onyx.merge(ONYXKEYS.EMPLOYEE_LIST, ['Jack']); // -> ['Joe', 'Jack'] * Onyx.merge(ONYXKEYS.POLICY, {id: 1}); // -> {id: 1} * Onyx.merge(ONYXKEYS.POLICY, {name: 'My Workspace'}); // -> {id: 1, name: 'My Workspace'} - * - * @param {String} key ONYXKEYS key - * @param {(Object|Array)} changes Object or Array value to merge - * @returns {Promise} */ -function merge(key, changes) { +function merge(key: TKey, changes: OnyxEntry>): Promise { // Top-level undefined values are ignored // Therefore we need to prevent adding them to the merge queue - if (_.isUndefined(changes)) { + if (changes === undefined) { return mergeQueue[key] ? mergeQueuePromise[key] : Promise.resolve(); } @@ -1292,7 +1210,9 @@ function merge(key, changes) { mergeQueuePromise[key] = get(key).then((existingValue) => { // Calls to Onyx.set after a merge will terminate the current merge process and clear the merge queue - if (mergeQueue[key] == null) return; + if (mergeQueue[key] == null) { + return undefined; + } try { // We first only merge the changes, so we can provide these to the native implementation (SQLite uses only delta changes in "JSON_PATCH" to merge) @@ -1301,7 +1221,7 @@ function merge(key, changes) { // The presence of a `null` in the merge queue instructs us to drop the existing value. // In this case, we can't simply merge the batched changes with the existing value, because then the null in the merge queue would have no effect - const shouldOverwriteExistingValue = _.includes(mergeQueue[key], null); + const shouldOverwriteExistingValue = mergeQueue[key].includes(null); // Clean up the write queue, so we don't apply these changes again delete mergeQueue[key]; @@ -1330,7 +1250,7 @@ function merge(key, changes) { // If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead. if (!hasChanged || wasRemoved) { - return updatePromise; + return updatePromise as Promise; } return Storage.mergeItem(key, batchedChanges, modifiedData).then(() => { @@ -1348,17 +1268,15 @@ function merge(key, changes) { /** * Merge user provided default key value pairs. - * @private - * @returns {Promise} */ -function initializeWithDefaultKeyStates() { - return Storage.multiGet(_.keys(defaultKeyStates)).then((pairs) => { - const existingDataAsObject = _.object(pairs); +function initializeWithDefaultKeyStates(): Promise { + return Storage.multiGet(Object.keys(defaultKeyStates)).then((pairs) => { + const existingDataAsObject = Object.fromEntries(pairs); const merged = utils.fastMerge(existingDataAsObject, defaultKeyStates); cache.merge(merged); - _.each(merged, (val, key) => keyChanged(key, val, existingDataAsObject)); + Object.entries(merged).forEach(([key, value]) => keyChanged(key, value, existingDataAsObject)); }); } @@ -1381,23 +1299,22 @@ function initializeWithDefaultKeyStates() { * Storage.setItem() from Onyx.clear() will have already finished and the merged * value will be saved to storage after the default value. * - * @param {Array} keysToPreserve is a list of ONYXKEYS that should not be cleared with the rest of the data - * @returns {Promise} + * @param keysToPreserve is a list of ONYXKEYS that should not be cleared with the rest of the data */ -function clear(keysToPreserve = []) { +function clear(keysToPreserve: OnyxKey[] = []): Promise> { return getAllKeys().then((keys) => { - const keysToBeClearedFromStorage = []; - const keyValuesToResetAsCollection = {}; - const keyValuesToResetIndividually = {}; + const keysToBeClearedFromStorage: OnyxKey[] = []; + const keyValuesToResetAsCollection: Record> = {}; + const keyValuesToResetIndividually: Record = {}; // 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) - _.each(keys, (key) => { - const isKeyToPreserve = _.contains(keysToPreserve, key); - const isDefaultKey = _.has(defaultKeyStates, key); + keys.forEach((key) => { + const isKeyToPreserve = keysToPreserve.includes(key); + const isDefaultKey = key in defaultKeyStates; // If the key is being removed or reset to default: // 1. Update it in the cache @@ -1405,7 +1322,7 @@ function clear(keysToPreserve = []) { // since collection key subscribers need to be updated differently if (!isKeyToPreserve) { const oldValue = cache.getValue(key); - const newValue = _.get(defaultKeyStates, key, null); + const newValue = defaultKeyStates[key] ?? null; if (newValue !== oldValue) { cache.set(key, newValue); const collectionKey = key.substring(0, key.indexOf('_') + 1); @@ -1413,7 +1330,7 @@ function clear(keysToPreserve = []) { if (!keyValuesToResetAsCollection[collectionKey]) { keyValuesToResetAsCollection[collectionKey] = {}; } - keyValuesToResetAsCollection[collectionKey][key] = newValue; + keyValuesToResetAsCollection[collectionKey]![key] = newValue; } else { keyValuesToResetIndividually[key] = newValue; } @@ -1428,20 +1345,28 @@ function clear(keysToPreserve = []) { keysToBeClearedFromStorage.push(key); }); - const updatePromises = []; + const updatePromises: Array> = []; // Notify the subscribers for each key/value group so they can receive the new values - _.each(keyValuesToResetIndividually, (value, key) => { + Object.entries(keyValuesToResetIndividually).forEach(([key, value]) => { updatePromises.push(scheduleSubscriberUpdate(key, value, cache.getValue(key, false))); }); - _.each(keyValuesToResetAsCollection, (value, key) => { + Object.entries(keyValuesToResetAsCollection).forEach(([key, value]) => { updatePromises.push(scheduleNotifyCollectionSubscribers(key, value)); }); - const defaultKeyValuePairs = _.pairs(_.omit(defaultKeyStates, keysToPreserve)); + const defaultKeyValuePairs = Object.entries( + Object.keys(defaultKeyStates) + .filter((key) => !keysToPreserve.includes(key)) + .reduce((obj: Record, key) => { + // eslint-disable-next-line no-param-reassign + obj[key] = defaultKeyStates[key]; + return obj; + }, {}), + ); // Remove only the items that we want cleared from storage, and reset others to default - _.each(keysToBeClearedFromStorage, (key) => cache.drop(key)); + keysToBeClearedFromStorage.forEach((key) => cache.drop(key)); return Storage.removeItems(keysToBeClearedFromStorage) .then(() => Storage.multiSet(defaultKeyValuePairs)) .then(() => { @@ -1461,19 +1386,19 @@ function clear(keysToPreserve = []) { * [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, * }); * - * @param {String} collectionKey e.g. `ONYXKEYS.COLLECTION.REPORT` - * @param {Object} collection Object collection keyed by individual collection member keys and values - * @returns {Promise} + * @param collectionKey e.g. `ONYXKEYS.COLLECTION.REPORT` + * @param collection Object collection keyed by individual collection member keys and values */ -function mergeCollection(collectionKey, collection) { - if (!_.isObject(collection) || _.isArray(collection) || _.isEmpty(collection)) { +function mergeCollection(collectionKey: TKey, collection: Collection>): Promise { + if (typeof collection !== 'object' || Array.isArray(collection) || utils.isEmptyObject(collection)) { Logger.logInfo('mergeCollection() called with invalid or empty value. Skipping this update.'); return Promise.resolve(); } + const mergedCollection: Record = collection; // Confirm all the collection keys belong to the same parent let hasCollectionKeyCheckFailed = false; - _.each(collection, (_data, dataKey) => { + Object.keys(mergedCollection).forEach((dataKey) => { if (isKeyMatch(collectionKey, dataKey)) { return; } @@ -1493,20 +1418,28 @@ function mergeCollection(collectionKey, collection) { return getAllKeys().then((persistedKeys) => { // Split to keys that exist in storage and keys that don't - const [existingKeys, newKeys] = _.chain(collection) - .pick((value, key) => { - if (_.isNull(value)) { - remove(key); - return false; - } - return true; - }) - .keys() - .partition((key) => persistedKeys.includes(key)) - .value(); - - const existingKeyCollection = _.pick(collection, existingKeys); - const newCollection = _.pick(collection, newKeys); + const keys = Object.keys(mergedCollection).filter((key) => { + if (mergedCollection[key] === null) { + remove(key); + return false; + } + return true; + }); + + const existingKeys = keys.filter((key) => persistedKeys.includes(key)); + const newKeys = keys.filter((key) => !persistedKeys.includes(key)); + + const existingKeyCollection = existingKeys.reduce((obj: Record, key) => { + // eslint-disable-next-line no-param-reassign + obj[key] = mergedCollection[key]; + return obj; + }, {}); + + const newCollection = newKeys.reduce((obj: Record, key) => { + // eslint-disable-next-line no-param-reassign + obj[key] = mergedCollection[key]; + return obj; + }, {}); const keyValuePairsForExistingCollection = prepareKeyValuePairsForStorage(existingKeyCollection); const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage(newCollection); @@ -1524,46 +1457,82 @@ function mergeCollection(collectionKey, collection) { // Prefill cache if necessary by calling get() on any existing keys and then merge original data to cache // and update all subscribers - const promiseUpdate = Promise.all(_.map(existingKeys, get)).then(() => { - cache.merge(collection); - return scheduleNotifyCollectionSubscribers(collectionKey, collection); + const promiseUpdate = Promise.all(existingKeys.map(get)).then(() => { + cache.merge(mergedCollection); + return scheduleNotifyCollectionSubscribers(collectionKey, mergedCollection); }); return Promise.all(promises) - .catch((error) => evictStorageAndRetry(error, mergeCollection, collection)) + .catch((error) => evictStorageAndRetry(error, mergeCollection, collectionKey, mergedCollection)) .then(() => { - sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, collection); + sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, mergedCollection); return promiseUpdate; }); }); } +/** + * Represents different kinds of updates that can be passed to `Onyx.update()` method. It is a discriminated union of + * different update methods (`SET`, `MERGE`, `MERGE_COLLECTION`), each with their own key and value structure. + */ +type OnyxUpdate = + | { + [TKey in OnyxKey]: + | { + onyxMethod: typeof METHOD.SET; + key: TKey; + value: OnyxEntry; + } + | { + onyxMethod: typeof METHOD.MERGE; + key: TKey; + value: OnyxEntry>; + } + | { + onyxMethod: typeof METHOD.MULTI_SET; + key: TKey; + value: Partial; + } + | { + onyxMethod: typeof METHOD.CLEAR; + key: TKey; + value?: undefined; + }; + }[OnyxKey] + | { + [TKey in CollectionKeyBase]: { + onyxMethod: typeof METHOD.MERGE_COLLECTION; + key: TKey; + value: Record<`${TKey}${string}`, NullishDeep>; + }; + }[CollectionKeyBase]; + /** * Insert API responses and lifecycle data into Onyx * - * @param {Array} data An array of objects with shape {onyxMethod: oneOf('set', 'merge', 'mergeCollection', 'multiSet', 'clear'), key: string, value: *} - * @returns {Promise} resolves when all operations are complete + * @param data An array of objects with shape {onyxMethod: oneOf('set', 'merge', 'mergeCollection', 'multiSet', 'clear'), key: string, value: *} + * @returns resolves when all operations are complete */ -function update(data) { +function update(data: OnyxUpdate[]): Promise { // First, validate the Onyx object is in the format we expect - _.each(data, ({onyxMethod, key, value}) => { - if (!_.contains([METHOD.CLEAR, METHOD.SET, METHOD.MERGE, METHOD.MERGE_COLLECTION, METHOD.MULTI_SET], onyxMethod)) { + data.forEach(({onyxMethod, key, value}) => { + if (![METHOD.CLEAR, METHOD.SET, METHOD.MERGE, METHOD.MERGE_COLLECTION, METHOD.MULTI_SET].includes(onyxMethod)) { throw new Error(`Invalid onyxMethod ${onyxMethod} in Onyx update.`); } if (onyxMethod === METHOD.MULTI_SET) { // For multiset, we just expect the value to be an object - if (!_.isObject(value) || _.isArray(value) || _.isFunction(value)) { + if (typeof value !== 'object' || Array.isArray(value) || typeof value === 'function') { throw new Error('Invalid value provided in Onyx multiSet. Onyx multiSet value must be of type object.'); } - } else if (onyxMethod !== METHOD.CLEAR && !_.isString(key)) { + } else if (onyxMethod !== METHOD.CLEAR && typeof key !== 'string') { throw new Error(`Invalid ${typeof key} key provided in Onyx update. Onyx key must be of type string.`); } }); - const promises = []; - let clearPromise = Promise.resolve(); + const promises: Array<() => Promise> = []; + let clearPromise: Promise = Promise.resolve(); - _.each(data, ({onyxMethod, key, value}) => { + data.forEach(({onyxMethod, key, value}) => { switch (onyxMethod) { case METHOD.SET: promises.push(() => set(key, value)); @@ -1572,7 +1541,8 @@ function update(data) { promises.push(() => merge(key, value)); break; case METHOD.MERGE_COLLECTION: - promises.push(() => mergeCollection(key, value)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- We validated that the value is a collection + promises.push(() => mergeCollection(key, value as any)); break; case METHOD.MULTI_SET: promises.push(() => multiSet(value)); @@ -1585,46 +1555,51 @@ function update(data) { } }); - return clearPromise.then(() => Promise.all(_.map(promises, (p) => p()))); + return clearPromise.then(() => Promise.all(promises.map((p) => p()))); } /** - * When set these keys will not be persisted to storage - * @param {string[]} keyList + * Represents the options used in `Onyx.init()` method. */ -function setMemoryOnlyKeys(keyList) { - Storage.setMemoryOnlyKeys(keyList); +type InitOptions = { + /** `ONYXKEYS` constants object */ + keys?: DeepRecord; - // When in memory only mode for certain keys we do not want to ever drop items from the cache as the user will have no way to recover them again via storage. - cache.setRecentKeysLimit(Infinity); -} + /** initial data to set when `init()` and `clear()` is called */ + initialKeyStates?: Partial; -/** - * Initialize the store with actions and listening for storage events - * - * @param {Object} [options={}] config object - * @param {Object} [options.keys={}] `ONYXKEYS` constants object - * @param {Object} [options.initialKeyStates={}] initial data to set when `init()` and `clear()` is called - * @param {String[]} [options.safeEvictionKeys=[]] This is an array of keys - * (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 {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) - * @param {Boolean} [options.debugSetState] Enables debugging setState() calls to connected components. - * @example - * Onyx.init({ - * keys: ONYXKEYS, - * initialKeyStates: { - * [ONYXKEYS.SESSION]: {loading: false}, - * }, - * }); - */ -function init({keys = {}, initialKeyStates = {}, safeEvictionKeys = [], maxCachedKeysCount = 1000, shouldSyncMultipleInstances = Boolean(global.localStorage), debugSetState = false} = {}) { + /** + * This is an array of keys (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. + */ + safeEvictionKeys?: OnyxKey[]; + + /** + * 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 + */ + maxCachedKeysCount?: number; + + /** + * 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) + */ + shouldSyncMultipleInstances?: boolean; + + /** Enables debugging setState() calls to connected components */ + debugSetState?: boolean; +}; + +/** Initialize the store with actions and listening for storage events */ +function init({ + keys = {}, + initialKeyStates = {}, + safeEvictionKeys = [], + maxCachedKeysCount = 1000, + shouldSyncMultipleInstances = Boolean(global.localStorage), + debugSetState = false, +}: InitOptions) { if (debugSetState) { PerformanceUtils.setShouldDebugSetState(true); } @@ -1635,15 +1610,11 @@ function init({keys = {}, initialKeyStates = {}, safeEvictionKeys = [], maxCache // We need the value of the collection keys later for checking if a // key is a collection. We store it in a map for faster lookup. - const collectionValues = _.values(keys.COLLECTION); - onyxCollectionKeyMap = _.reduce( - collectionValues, - (acc, val) => { - acc.set(val, true); - return acc; - }, - new Map(), - ); + const collectionValues = keys.COLLECTION ? Object.values(keys.COLLECTION) : []; + onyxCollectionKeyMap = collectionValues.reduce((acc, val) => { + acc.set(val, true); + return acc; + }, new Map()); // Set our default key states to use when initializing and clearing Onyx data defaultKeyStates = initialKeyStates; @@ -1681,12 +1652,12 @@ const Onyx = { removeFromEvictionBlockList, isSafeEvictionKey, METHOD, - setMemoryOnlyKeys, tryGetCachedValue, hasPendingMergeForKey, isCollectionKey, isCollectionMemberKey, splitCollectionMemberKey, -}; +} as const; export default Onyx; +export type {OnyxUpdate, Mapping, ConnectOptions}; diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index 77eb2b4b..29351168 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -1,9 +1,7 @@ import {deepEqual} from 'fast-equals'; import bindAll from 'lodash/bindAll'; -import type {Key, Value} from './storage/providers/types'; import utils from './utils'; - -type StorageMap = Record; +import type {OnyxKey, OnyxValue} from './types'; /** * In memory cache providing data by reference @@ -11,19 +9,19 @@ type StorageMap = Record; */ class OnyxCache { /** Cache of all the storage keys available in persistent storage */ - private storageKeys: Set; + storageKeys: Set; /** Unique list of keys maintained in access order (most recent at the end) */ - private recentKeys: Set; + private recentKeys: Set; /** A map of cached values */ - private storageMap: StorageMap; + private storageMap: Record; /** * Captured pending tasks for already running storage methods * Using a map yields better performance on operations such a delete */ - private pendingPromises: Map>; + private pendingPromises: Map>; /** Maximum size of the keys store din cache */ private maxRecentKeysSize = 0; @@ -54,7 +52,7 @@ class OnyxCache { } /** Get all the storage keys */ - getAllKeys(): Key[] { + getAllKeys(): OnyxKey[] { return Array.from(this.storageKeys); } @@ -62,7 +60,7 @@ class OnyxCache { * Get a cached value from storage * @param [shouldReindexCache] – This is an LRU cache, and by default accessing a value will make it become last in line to be evicted. This flag can be used to skip that and just access the value directly without side-effects. */ - getValue(key: Key, shouldReindexCache = true): Value { + getValue(key: OnyxKey, shouldReindexCache = true): OnyxValue { if (shouldReindexCache) { this.addToAccessedKeys(key); } @@ -70,14 +68,14 @@ class OnyxCache { } /** Check whether cache has data for the given key */ - hasCacheForKey(key: Key): boolean { + hasCacheForKey(key: OnyxKey): boolean { return this.storageMap[key] !== undefined; } /** Saves a key in the storage keys list * Serves to keep the result of `getAllKeys` up to date */ - addKey(key: Key): void { + addKey(key: OnyxKey): void { this.storageKeys.add(key); } @@ -85,7 +83,7 @@ class OnyxCache { * Set's a key value in cache * Adds the key to the storage keys list as well */ - set(key: Key, value: Value): Value { + set(key: OnyxKey, value: OnyxValue): OnyxValue { this.addKey(key); this.addToAccessedKeys(key); this.storageMap[key] = value; @@ -94,7 +92,7 @@ class OnyxCache { } /** Forget the cached value for the given key */ - drop(key: Key): void { + drop(key: OnyxKey): void { delete this.storageMap[key]; this.storageKeys.delete(key); this.recentKeys.delete(key); @@ -104,7 +102,7 @@ class OnyxCache { * Deep merge data to cache, any non existing keys will be created * @param data - a map of (cache) key - values */ - merge(data: StorageMap): void { + merge(data: Record): void { if (typeof data !== 'object' || Array.isArray(data)) { throw new Error('data passed to cache.merge() must be an Object of onyx key/value pairs'); } @@ -146,8 +144,8 @@ class OnyxCache { * provided from this function * @param taskName - unique name given for the task */ - getTaskPromise(taskName: string): Promise | undefined { - return this.pendingPromises.get(taskName); + getTaskPromise(taskName: string): Promise { + return this.pendingPromises.get(taskName) as Promise; } /** @@ -155,7 +153,7 @@ class OnyxCache { * hook up to the promise if it's still pending * @param taskName - unique name for the task */ - captureTask(taskName: string, promise: Promise): Promise { + captureTask(taskName: string, promise: Promise): Promise { const returnPromise = promise.finally(() => { this.pendingPromises.delete(taskName); }); @@ -166,7 +164,7 @@ class OnyxCache { } /** Adds a key to the top of the recently accessed keys */ - private addToAccessedKeys(key: Key): void { + addToAccessedKeys(key: OnyxKey): void { this.recentKeys.delete(key); this.recentKeys.add(key); } @@ -198,7 +196,7 @@ class OnyxCache { } /** Check if the value has changed */ - hasValueChanged(key: Key, value: Value): boolean { + hasValueChanged(key: OnyxKey, value: OnyxValue): boolean { return !deepEqual(this.storageMap[key], value); } } diff --git a/lib/PerformanceUtils.ts b/lib/PerformanceUtils.ts index b378eb28..832aaa2a 100644 --- a/lib/PerformanceUtils.ts +++ b/lib/PerformanceUtils.ts @@ -1,5 +1,7 @@ import lodashTransform from 'lodash/transform'; import {deepEqual} from 'fast-equals'; +import type {Mapping} from './Onyx'; +import type {OnyxKey} from './types'; type UnknownObject = Record; @@ -10,11 +12,6 @@ type LogParams = { newValue?: unknown; }; -type Mapping = Record & { - key: string; - displayName: string; -}; - let debugSetState = false; function setShouldDebugSetState(debug: boolean) { @@ -44,7 +41,7 @@ function diffObject( /** * Provide insights into why a setState() call occurred by diffing the before and after values. */ -function logSetStateCall(mapping: Mapping, previousValue: unknown, newValue: unknown, caller: string, keyThatChanged: string) { +function logSetStateCall(mapping: Mapping, previousValue: unknown, newValue: unknown, caller: string, keyThatChanged?: string) { if (!debugSetState) { return; } diff --git a/lib/index.d.ts b/lib/index.d.ts deleted file mode 100644 index e69e736e..00000000 --- a/lib/index.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -import Onyx, {OnyxUpdate, ConnectOptions} from './Onyx'; -import {CustomTypeOptions, OnyxCollection, OnyxEntry, NullishDeep, KeyValueMapping, OnyxKey, Selector, WithOnyxInstanceState, OnyxValue} from './types'; -import withOnyx from './withOnyx'; -import useOnyx, {UseOnyxResult, FetchStatus} from './useOnyx'; - -export default Onyx; -export { - CustomTypeOptions, - OnyxCollection, - OnyxEntry, - OnyxUpdate, - withOnyx, - ConnectOptions, - NullishDeep, - KeyValueMapping, - OnyxKey, - Selector, - WithOnyxInstanceState, - useOnyx, - UseOnyxResult, - OnyxValue, - FetchStatus, -}; diff --git a/lib/index.js b/lib/index.js deleted file mode 100644 index b50e88d2..00000000 --- a/lib/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import Onyx from './Onyx'; -import withOnyx from './withOnyx'; -import useOnyx from './useOnyx'; - -export default Onyx; -export {withOnyx, useOnyx}; diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 00000000..b16537f5 --- /dev/null +++ b/lib/index.ts @@ -0,0 +1,9 @@ +import Onyx from './Onyx'; +import type {OnyxUpdate, ConnectOptions} from './Onyx'; +import type {CustomTypeOptions, OnyxCollection, OnyxEntry, NullishDeep, KeyValueMapping, OnyxKey, Selector, WithOnyxInstanceState} from './types'; +import useOnyx from './useOnyx'; +import withOnyx from './withOnyx'; + +export default Onyx; +export {withOnyx, useOnyx}; +export type {CustomTypeOptions, OnyxCollection, OnyxEntry, OnyxUpdate, ConnectOptions, NullishDeep, KeyValueMapping, OnyxKey, Selector, WithOnyxInstanceState}; diff --git a/lib/storage/__mocks__/index.ts b/lib/storage/__mocks__/index.ts index 3e6f156b..81425418 100644 --- a/lib/storage/__mocks__/index.ts +++ b/lib/storage/__mocks__/index.ts @@ -1,8 +1,9 @@ +import type {OnyxKey, OnyxValue} from '../../types'; import utils from '../../utils'; -import type {Key, KeyValuePairList, Value} from '../providers/types'; +import type {KeyValuePairList} from '../providers/types'; import type StorageProvider from '../providers/types'; -let storageMapInternal: Record = {}; +let storageMapInternal: Record = {}; const set = jest.fn((key, value) => { storageMapInternal[key] = value; @@ -35,7 +36,7 @@ const idbKeyvalMock: StorageProvider = { pairs.forEach(([key, value]) => { const existingValue = storageMapInternal[key]; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const newValue = utils.fastMerge(existingValue as any, value); + const newValue = utils.fastMerge(existingValue as any, value as any); set(key, newValue); }); @@ -65,8 +66,6 @@ const idbKeyvalMock: StorageProvider = { getDatabaseSize() { return Promise.resolve({bytesRemaining: 0, bytesUsed: 99999}); }, - // eslint-disable-next-line @typescript-eslint/no-empty-function - setMemoryOnlyKeys() {}, }; const idbKeyvalMockSpy = { @@ -86,7 +85,6 @@ const idbKeyvalMockSpy = { storageMapInternal = data; }), getDatabaseSize: jest.fn(idbKeyvalMock.getDatabaseSize), - setMemoryOnlyKeys: jest.fn(idbKeyvalMock.setMemoryOnlyKeys), }; export default idbKeyvalMockSpy; diff --git a/lib/storage/providers/IDBKeyVal.ts b/lib/storage/providers/IDBKeyVal.ts index bc429bc2..fa3562e7 100644 --- a/lib/storage/providers/IDBKeyVal.ts +++ b/lib/storage/providers/IDBKeyVal.ts @@ -2,7 +2,7 @@ import type {UseStore} from 'idb-keyval'; import {set, keys, getMany, setMany, get, clear, del, delMany, createStore, promisifyRequest} from 'idb-keyval'; import utils from '../../utils'; import type StorageProvider from './types'; -import type {Value} from './types'; +import type {OnyxValue} from '../../types'; // We don't want to initialize the store while the JS bundle loads as idb-keyval will try to use global.indexedDB // which might not be available in certain environments that load the bundle (e.g. electron main process). @@ -21,13 +21,13 @@ const provider: StorageProvider = { getCustomStore()('readwrite', (store) => { // Note: we are using the manual store transaction here, to fit the read and update // of the items in one transaction to achieve best performance. - const getValues = Promise.all(pairs.map(([key]) => promisifyRequest(store.get(key)))); + const getValues = Promise.all(pairs.map(([key]) => promisifyRequest(store.get(key)))); return getValues.then((values) => { const upsertMany = pairs.map(([key, value], index) => { const prev = values[index]; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const newValue = utils.fastMerge(prev as any, value); + const newValue = utils.fastMerge(prev as any, value as any); return promisifyRequest(store.put(newValue, key)); }); return Promise.all(upsertMany); @@ -39,8 +39,6 @@ const provider: StorageProvider = { }, multiSet: (pairs) => setMany(pairs, getCustomStore()), clear: () => clear(getCustomStore()), - // eslint-disable-next-line @typescript-eslint/no-empty-function - setMemoryOnlyKeys: () => {}, getAllKeys: () => keys(getCustomStore()), getItem: (key) => get(key, getCustomStore()) diff --git a/lib/storage/providers/SQLiteStorage.ts b/lib/storage/providers/SQLiteStorage.ts index 6011277b..36eb3622 100644 --- a/lib/storage/providers/SQLiteStorage.ts +++ b/lib/storage/providers/SQLiteStorage.ts @@ -93,9 +93,6 @@ const provider: StorageProvider = { }); }, - // eslint-disable-next-line @typescript-eslint/no-empty-function - setMemoryOnlyKeys: () => {}, - // eslint-disable-next-line @typescript-eslint/no-empty-function keepInstancesSync: () => {}, }; diff --git a/lib/storage/providers/types.ts b/lib/storage/providers/types.ts index 3f811372..e4723036 100644 --- a/lib/storage/providers/types.ts +++ b/lib/storage/providers/types.ts @@ -1,18 +1,17 @@ import type {BatchQueryResult, QueryResult} from 'react-native-quick-sqlite'; +import type {OnyxKey, OnyxValue} from '../../types'; -type Key = string; -type Value = IDBValidKey; -type KeyValuePair = [Key, Value]; -type KeyList = Key[]; +type KeyValuePair = [OnyxKey, OnyxValue]; +type KeyList = OnyxKey[]; type KeyValuePairList = KeyValuePair[]; -type OnStorageKeyChanged = (key: Key, value: Value | null) => void; +type OnStorageKeyChanged = (key: OnyxKey, value: OnyxValue | null) => void; type StorageProvider = { /** * Gets the value of a given key or return `null` if it's not available in storage */ - getItem: (key: Key) => Promise; + getItem: (key: OnyxKey) => Promise; /** * Get multiple key-value pairs for the given array of keys in a batch @@ -22,7 +21,7 @@ type StorageProvider = { /** * Sets the value for a given key. The only requirement is that the value should be serializable to JSON string */ - setItem: (key: Key, value: Value) => Promise; + setItem: (key: OnyxKey, value: OnyxValue) => Promise; /** * Stores multiple key-value pairs in a batch @@ -39,7 +38,7 @@ type StorageProvider = { * @param changes - the delta for a specific key * @param modifiedData - the pre-merged data from `Onyx.applyMerge` */ - mergeItem: (key: Key, changes: Value, modifiedData: Value) => Promise; + mergeItem: (key: OnyxKey, changes: OnyxValue, modifiedData: OnyxValue) => Promise; /** * Returns all keys available in storage @@ -49,7 +48,7 @@ type StorageProvider = { /** * Removes given key and its value from storage */ - removeItem: (key: Key) => Promise; + removeItem: (key: OnyxKey) => Promise; /** * Removes given keys and their values from storage @@ -61,11 +60,6 @@ type StorageProvider = { */ clear: () => Promise; - /** - * Sets memory only keys - */ - setMemoryOnlyKeys: () => void; - /** * Gets the total bytes of the database file */ @@ -78,4 +72,4 @@ type StorageProvider = { }; export default StorageProvider; -export type {Value, Key, KeyList, KeyValuePairList}; +export type {KeyList, KeyValuePair, KeyValuePairList, OnStorageKeyChanged}; diff --git a/lib/types.d.ts b/lib/types.ts similarity index 96% rename from lib/types.d.ts rename to lib/types.ts index a210c349..433e526f 100644 --- a/lib/types.d.ts +++ b/lib/types.ts @@ -1,5 +1,5 @@ -import {Merge} from 'type-fest'; -import {BuiltIns} from 'type-fest/source/internal'; +import type {Merge} from 'type-fest'; +import type {BuiltIns} from 'type-fest/source/internal'; /** * Represents a deeply nested record. It maps keys to values, @@ -76,6 +76,7 @@ type TypeOptions = Merge< * } * ``` */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface interface CustomTypeOptions {} /** @@ -193,6 +194,7 @@ type ExtractOnyxCollectionValue = TOnyxCollection extends NonNu type NonTransformableTypes = | BuiltIns + // eslint-disable-next-line @typescript-eslint/no-explicit-any | ((...args: any[]) => unknown) | Map | Set @@ -234,7 +236,8 @@ type NullishObjectDeep = { */ type WithOnyxInstanceState = (TOnyxProps & {loading: boolean}) | undefined; -export { +export type { + OnyxValue, CollectionKey, CollectionKeyBase, CustomTypeOptions,