diff --git a/API-INTERNAL.md b/API-INTERNAL.md index db3e45f5..bb1c647b 100644 --- a/API-INTERNAL.md +++ b/API-INTERNAL.md @@ -11,12 +11,15 @@
getMergeQueuePromise()

Getter - returns the merge queue promise.

-
getCallbackToStateMapping()
-

Getter - returns the callback to state mapping.

-
getDefaultKeyStates()

Getter - returns the default key states.

+
getDeferredInitTask()
+

Getter - returns the deffered init task.

+
+
getEvictionBlocklist()
+

Getter - returns the eviction block list.

+
initStoreValues(keys, initialKeyStates, safeEvictionKeys)

Sets the initial values for the Onyx store

@@ -34,11 +37,20 @@ The resulting collection will only contain items that are returned by the select
get()

Get some data from the store

+
storeKeyBySubscriptions(subscriptionID, key)
+

Stores a subscription ID associated with a given key.

+
+
deleteKeyBySubscriptions(subscriptionID)
+

Deletes a subscription ID associated with its corresponding key.

+
getAllKeys()

Returns current key names stored in persisted storage

+
getCollectionKeys()
+

Returns set of all registered collection keys

+
isCollectionKey()
-

Checks to see if the a subscriber's supplied key +

Checks to see if the subscriber's supplied key is associated with a collection of keys.

splitCollectionMemberKey(key)
@@ -51,6 +63,15 @@ or if the provided key is a collection member key (in case our configured key is
isSafeEvictionKey()

Checks to see if this key has been flagged as safe for removal.

+
getCollectionKey(key)string
+

It extracts the non-numeric collection identifier of a given key.

+

For example:

+ +
tryGetCachedValue()

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.

@@ -63,13 +84,6 @@ If the requested key is a collection, it will return an object with all the coll recently accessed key should be at the head and the most recently accessed key at the tail.

-
removeFromEvictionBlockList()
-

Removes a key previously added to this list -which will enable it to be deleted again.

-
-
addToEvictionBlockList()
-

Keys added to this list can never be deleted.

-
addAllSafeEvictionKeysToRecentlyAccessedList()

Take all the keys that are safe to evict and add them to the recently accessed list when initializing the app. This @@ -129,6 +143,18 @@ to an array of key-value pairs in the above format and removes key-value pairs t

initializeWithDefaultKeyStates()

Merge user provided default key value pairs.

+
isValidNonEmptyCollectionForMerge()
+

Validate the collection is not empty and has a correct type before applying mergeCollection()

+
+
doAllCollectionItemsBelongToSameParent()
+

Verify if all the collection keys belong to the same parent

+
+
subscribeToKey(connectOptions)
+

Subscribes to an Onyx key and listens to its changes.

+
+
unsubscribeFromKey(subscriptionID)
+

Disconnects and removes the listener from the Onyx key.

+
@@ -142,18 +168,24 @@ Getter - returns the merge queue. ## getMergeQueuePromise() Getter - returns the merge queue promise. -**Kind**: global function - - -## getCallbackToStateMapping() -Getter - returns the callback to state mapping. - **Kind**: global function ## getDefaultKeyStates() Getter - returns the default key states. +**Kind**: global function + + +## getDeferredInitTask() +Getter - returns the deffered init task. + +**Kind**: global function + + +## getEvictionBlocklist() +Getter - returns the eviction block list. + **Kind**: global function @@ -191,16 +223,45 @@ The resulting collection will only contain items that are returned by the select Get some data from the store **Kind**: global function + + +## storeKeyBySubscriptions(subscriptionID, key) +Stores a subscription ID associated with a given key. + +**Kind**: global function + +| Param | Description | +| --- | --- | +| subscriptionID | A subscription ID of the subscriber. | +| key | A key that the subscriber is subscribed to. | + + + +## deleteKeyBySubscriptions(subscriptionID) +Deletes a subscription ID associated with its corresponding key. + +**Kind**: global function + +| Param | Description | +| --- | --- | +| subscriptionID | The subscription ID to be deleted. | + ## getAllKeys() Returns current key names stored in persisted storage +**Kind**: global function + + +## getCollectionKeys() +Returns set of all registered collection keys + **Kind**: global function ## isCollectionKey() -Checks to see if the a subscriber's supplied key +Checks to see if the subscriber's supplied key is associated with a collection of keys. **Kind**: global function @@ -229,6 +290,23 @@ or if the provided key is a collection member key (in case our configured key is Checks to see if this key has been flagged as safe for removal. **Kind**: global function + + +## getCollectionKey(key) ⇒ string +It extracts the non-numeric collection identifier of a given key. + +For example: +- `getCollectionKey("report_123")` would return "report_" +- `getCollectionKey("report")` would return "report" +- `getCollectionKey("report_")` would return "report_" + +**Kind**: global function +**Returns**: string - The pure key without any numeric + +| Param | Type | Description | +| --- | --- | --- | +| key | OnyxKey | The key to process. | + ## tryGetCachedValue() @@ -249,19 +327,6 @@ 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. -**Kind**: global function - - -## removeFromEvictionBlockList() -Removes a key previously added to this list -which will enable it to be deleted again. - -**Kind**: global function - - -## addToEvictionBlockList() -Keys added to this list can never be deleted. - **Kind**: global function @@ -399,3 +464,38 @@ Merges an array of changes with an existing value Merge user provided default key value pairs. **Kind**: global function + + +## isValidNonEmptyCollectionForMerge() +Validate the collection is not empty and has a correct type before applying mergeCollection() + +**Kind**: global function + + +## doAllCollectionItemsBelongToSameParent() +Verify if all the collection keys belong to the same parent + +**Kind**: global function + + +## subscribeToKey(connectOptions) ⇒ +Subscribes to an Onyx key and listens to its changes. + +**Kind**: global function +**Returns**: The subscription ID to use when calling `OnyxUtils.unsubscribeFromKey()`. + +| Param | Description | +| --- | --- | +| connectOptions | The options object that will define the behavior of the connection. | + + + +## unsubscribeFromKey(subscriptionID) +Disconnects and removes the listener from the Onyx key. + +**Kind**: global function + +| Param | Description | +| --- | --- | +| subscriptionID | Subscription ID returned by calling `OnyxUtils.subscribeToKey()`. | + diff --git a/API.md b/API.md index 59e76ce7..a6287849 100644 --- a/API.md +++ b/API.md @@ -8,11 +8,11 @@
init()

Initialize the store with actions and listening for storage events

-
connect(mapping)
-

Subscribes a react component's state directly to a store key

+
connect(connectOptions)
+

Connects to an Onyx key given the options passed and listens to its changes.

-
disconnect(connectionID)
-

Remove the listener for a react component

+
disconnect(connection)
+

Disconnects and removes the listener from the Onyx key.

set(key, value)

Write a value to our store with the given key

@@ -60,45 +60,49 @@ Initialize the store with actions and listening for storage events **Kind**: global function -## connect(mapping) ⇒ -Subscribes a react component's state directly to a store key +## connect(connectOptions) ⇒ +Connects to an Onyx key given the options passed and listens to its changes. **Kind**: global function -**Returns**: an ID to use when calling disconnect +**Returns**: The connection object to use when calling `Onyx.disconnect()`. | Param | Description | | --- | --- | -| mapping | the mapping information to connect Onyx to the components state | -| mapping.key | ONYXKEY to subscribe to | -| [mapping.statePropertyName] | the name of the property in the state to connect the data to | -| [mapping.withOnyxInstance] | whose setState() method will be called with any changed data This is used by React components to connect to Onyx | -| [mapping.callback] | a method that will be called with changed data This is used by any non-React code to connect to Onyx | -| [mapping.initWithStoredValues] | If set to false, then no data will be prefilled into the component | -| [mapping.waitForCollectionCallback] | If set to true, it will return the entire collection to the callback as a single object | -| [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). | -| [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. | +| connectOptions | The options object that will define the behavior of the connection. | +| connectOptions.key | The Onyx key to subscribe to. | +| connectOptions.callback | A function that will be called when the Onyx data we are subscribed changes. | +| connectOptions.waitForCollectionCallback | If set to `true`, it will return the entire collection to the callback as a single object. | +| connectOptions.withOnyxInstance | The `withOnyx` class instance to be internally passed. **Only used inside `withOnyx()` HOC.** | +| connectOptions.statePropertyName | The name of the component's prop that is connected to the Onyx key. **Only used inside `withOnyx()` HOC.** | +| connectOptions.displayName | The component's display name. **Only used inside `withOnyx()` HOC.** | +| connectOptions.selector | This will be used to subscribe to a subset of an Onyx key's data. **Only used inside `useOnyx()` hook or `withOnyx()` HOC.** Using this setting on `useOnyx()` or `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). | **Example** -```js -const connectionID = Onyx.connect({ +```ts +const connection = Onyx.connect({ key: ONYXKEYS.SESSION, callback: onSessionChange, }); ``` -## disconnect(connectionID) -Remove the listener for a react component +## disconnect(connection) +Disconnects and removes the listener from the Onyx key. **Kind**: global function | Param | Description | | --- | --- | -| connectionID | unique id returned by call to Onyx.connect() | +| connection | Connection object returned by calling `Onyx.connect()`. | **Example** -```js -Onyx.disconnect(connectionID); +```ts +const connection = Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: onSessionChange, +}); + +Onyx.disconnect(connection); ``` diff --git a/lib/Onyx.ts b/lib/Onyx.ts index f8c70e28..88be4a2f 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -3,7 +3,6 @@ import _ from 'underscore'; import lodashPick from 'lodash/pick'; import * as Logger from './Logger'; import cache from './OnyxCache'; -import createDeferredTask from './createDeferredTask'; import * as PerformanceUtils from './PerformanceUtils'; import Storage from './storage'; import utils from './utils'; @@ -30,12 +29,8 @@ import type { } from './types'; import OnyxUtils from './OnyxUtils'; import logMessages from './logMessages'; - -// Keeps track of the last connectionID that was used so we can keep incrementing it -let lastConnectionID = 0; - -// Connections can be made before `Onyx.init`. They would wait for this task before resolving -const deferredInitTask = createDeferredTask(); +import type {Connection} from './OnyxConnectionManager'; +import connectionManager from './OnyxConnectionManager'; /** Initialize the store with actions and listening for storage events */ function init({ @@ -67,157 +62,54 @@ function init({ OnyxUtils.initStoreValues(keys, initialKeyStates, safeEvictionKeys); // Initialize all of our keys with data provided then give green light to any pending connections - Promise.all([OnyxUtils.addAllSafeEvictionKeysToRecentlyAccessedList(), OnyxUtils.initializeWithDefaultKeyStates()]).then(deferredInitTask.resolve); + Promise.all([OnyxUtils.addAllSafeEvictionKeysToRecentlyAccessedList(), OnyxUtils.initializeWithDefaultKeyStates()]).then(OnyxUtils.getDeferredInitTask().resolve); } /** - * Subscribes a react component's state directly to a store key + * Connects to an Onyx key given the options passed and listens to its changes. * * @example - * const connectionID = Onyx.connect({ + * ```ts + * const connection = 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. Default is true. - * @param [mapping.waitForCollectionCallback] If set to true, it will return the entire collection to the callback as a single object - * @param [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 [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 an ID to use when calling disconnect + * @param connectOptions The options object that will define the behavior of the connection. + * @param connectOptions.key The Onyx key to subscribe to. + * @param connectOptions.callback A function that will be called when the Onyx data we are subscribed changes. + * @param connectOptions.waitForCollectionCallback If set to `true`, it will return the entire collection to the callback as a single object. + * @param connectOptions.withOnyxInstance The `withOnyx` class instance to be internally passed. **Only used inside `withOnyx()` HOC.** + * @param connectOptions.statePropertyName The name of the component's prop that is connected to the Onyx key. **Only used inside `withOnyx()` HOC.** + * @param connectOptions.displayName The component's display name. **Only used inside `withOnyx()` HOC.** + * @param connectOptions.selector This will be used to subscribe to a subset of an Onyx key's data. **Only used inside `useOnyx()` hook or `withOnyx()` HOC.** + * Using this setting on `useOnyx()` or `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). + * @returns The connection object to use when calling `Onyx.disconnect()`. */ -function connect(connectOptions: ConnectOptions): number { - const mapping = connectOptions as Mapping; - const connectionID = lastConnectionID++; - const callbackToStateMapping = OnyxUtils.getCallbackToStateMapping(); - callbackToStateMapping[connectionID] = mapping as Mapping; - callbackToStateMapping[connectionID].connectionID = connectionID; - - // When keyChanged is called, a key is passed and the method looks through all the Subscribers in callbackToStateMapping for the matching key to get the connectionID - // to avoid having to loop through all the Subscribers all the time (even when just one connection belongs to one key), - // We create a mapping from key to lists of connectionIDs to access the specific list of connectionIDs. - OnyxUtils.storeKeyByConnections(mapping.key, callbackToStateMapping[connectionID].connectionID); - - if (mapping.initWithStoredValues === false) { - return connectionID; - } - - // Commit connection only after init passes - deferredInitTask.promise - .then(() => OnyxUtils.addKeyToRecentlyAccessedIfNeeded(mapping)) - .then(() => { - // Performance improvement - // If the mapping is connected to an onyx key that is not a collection - // we can skip the call to getAllKeys() and return an array with a single item - if (Boolean(mapping.key) && typeof mapping.key === 'string' && !mapping.key.endsWith('_') && cache.getAllKeys().has(mapping.key)) { - return new Set([mapping.key]); - } - return OnyxUtils.getAllKeys(); - }) - .then((keys) => { - // 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: string[] = []; - keys.forEach((key) => { - if (!OnyxUtils.isKeyMatch(mapping.key, key)) { - return; - } - matchingKeys.push(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 - // since there are none matched. In withOnyx() we wait for all connected keys to return a value before rendering the child - // component. This null value will be filtered out so that the connected component can utilize defaultProps. - if (matchingKeys.length === 0) { - if (mapping.key && !OnyxUtils.isCollectionKey(mapping.key)) { - cache.addNullishStorageKey(mapping.key); - } - - // Here we cannot use batching because the nullish value is expected to be set immediately for default props - // or they will be undefined. - OnyxUtils.sendDataToConnection(mapping, null, undefined, false); - return; - } - - // 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 (typeof mapping.callback === 'function') { - if (OnyxUtils.isCollectionKey(mapping.key)) { - if (mapping.waitForCollectionCallback) { - OnyxUtils.getCollectionDataAndSendAsObject(matchingKeys, mapping); - return; - } - - // We did not opt into using waitForCollectionCallback mode so the callback is called for every matching key. - OnyxUtils.multiGet(matchingKeys).then((values) => { - values.forEach((val, key) => { - OnyxUtils.sendDataToConnection(mapping, val as OnyxValue, key as TKey, true); - }); - }); - return; - } - - // If we are not subscribed to a collection key then there's only a single key to send an update for. - OnyxUtils.get(mapping.key).then((val) => OnyxUtils.sendDataToConnection(mapping, val as OnyxValue, mapping.key, true)); - return; - } - - // If we have a withOnyxInstance that means a React component has subscribed via the withOnyx() HOC and we need to - // group collection key member data into an object. - if ('withOnyxInstance' in mapping && mapping.withOnyxInstance) { - if (OnyxUtils.isCollectionKey(mapping.key)) { - OnyxUtils.getCollectionDataAndSendAsObject(matchingKeys, mapping); - return; - } - - // If the subscriber is not using a collection key then we just send a single value back to the subscriber - OnyxUtils.get(mapping.key).then((val) => OnyxUtils.sendDataToConnection(mapping, val as OnyxValue, mapping.key, true)); - return; - } - - console.error('Warning: Onyx.connect() was found without a callback or withOnyxInstance'); - }); - - // The connectionID is returned back to the caller so that it can be used to clean up the connection when it's no longer needed - // by calling Onyx.disconnect(connectionID). - return connectionID; +function connect(connectOptions: ConnectOptions): Connection { + return connectionManager.connect(connectOptions); } /** - * Remove the listener for a react component + * Disconnects and removes the listener from the Onyx key. + * * @example - * Onyx.disconnect(connectionID); + * ```ts + * const connection = Onyx.connect({ + * key: ONYXKEYS.SESSION, + * callback: onSessionChange, + * }); * - * @param connectionID unique id returned by call to Onyx.connect() + * Onyx.disconnect(connection); + * ``` + * + * @param connection Connection object returned by calling `Onyx.connect()`. */ -function disconnect(connectionID: number, keyToRemoveFromEvictionBlocklist?: OnyxKey): void { - const callbackToStateMapping = OnyxUtils.getCallbackToStateMapping(); - if (!callbackToStateMapping[connectionID]) { - return; - } - - // Remove this key from the eviction block list as we are no longer - // subscribing to it and it should be safe to delete again - if (keyToRemoveFromEvictionBlocklist) { - OnyxUtils.removeFromEvictionBlockList(keyToRemoveFromEvictionBlocklist, connectionID); - } - - OnyxUtils.deleteKeyByConnections(lastConnectionID); - delete callbackToStateMapping[connectionID]; +function disconnect(connection: Connection): void { + connectionManager.disconnect(connection); } /** diff --git a/lib/OnyxConnectionManager.ts b/lib/OnyxConnectionManager.ts new file mode 100644 index 00000000..f2564c58 --- /dev/null +++ b/lib/OnyxConnectionManager.ts @@ -0,0 +1,268 @@ +import bindAll from 'lodash/bindAll'; +import * as Logger from './Logger'; +import type {ConnectOptions} from './Onyx'; +import OnyxUtils from './OnyxUtils'; +import * as Str from './Str'; +import type {DefaultConnectCallback, DefaultConnectOptions, OnyxKey, OnyxValue} from './types'; +import utils from './utils'; + +type ConnectCallback = DefaultConnectCallback; + +/** + * Represents the connection's metadata that contains the necessary properties + * to handle that connection. + */ +type ConnectionMetadata = { + /** + * The subscription ID returned by `OnyxUtils.subscribeToKey()` that is associated to this connection. + */ + subscriptionID: number; + + /** + * The Onyx key associated to this connection. + */ + onyxKey: OnyxKey; + + /** + * Whether the first connection's callback was fired or not. + */ + isConnectionMade: boolean; + + /** + * A map of the subscriber's callbacks associated to this connection. + */ + callbacks: Map; + + /** + * The last callback value returned by `OnyxUtils.subscribeToKey()`'s callback. + */ + cachedCallbackValue?: OnyxValue; + + /** + * The last callback key returned by `OnyxUtils.subscribeToKey()`'s callback. + */ + cachedCallbackKey?: OnyxKey; +}; + +/** + * Represents the connection object returned by `Onyx.connect()`. + */ +type Connection = { + /** + * The ID used to identify this particular connection. + */ + id: string; + + /** + * The ID of the subscriber's callback that is associated to this connection. + */ + callbackID: string; +}; + +/** + * Manages Onyx connections of `Onyx.connect()`, `useOnyx()` and `withOnyx()` subscribers. + */ +class OnyxConnectionManager { + /** + * A map where the key is the connection ID generated inside `connect()` and the value is the metadata of that connection. + */ + private connectionsMap: Map; + + /** + * Stores the last generated callback ID which will be incremented when making a new connection. + */ + private lastCallbackID: number; + + constructor() { + this.connectionsMap = new Map(); + this.lastCallbackID = 0; + + // Binds all public methods to prevent problems with `this`. + bindAll(this, 'generateConnectionID', 'fireCallbacks', 'connect', 'disconnect', 'disconnectAll', 'addToEvictionBlockList', 'removeFromEvictionBlockList'); + } + + /** + * Generates a connection ID based on the `connectOptions` object passed to the function. + * + * The properties used to generate the ID are handpicked for performance reasons and + * according to their purpose and effect they produce in the Onyx connection. + */ + private generateConnectionID(connectOptions: ConnectOptions): string { + let suffix = ''; + + // We will generate a unique ID in any of the following situations: + // - `connectOptions.reuseConnection` is `false`. That means the subscriber explicitly wants the connection to not be reused. + // - `connectOptions.initWithStoredValues` is `false`. This flag changes the subscription flow when set to `false`, so the connection can't be reused. + // - `withOnyxInstance` is defined inside `connectOptions`. That means the subscriber is a `withOnyx` HOC and therefore doesn't support connection reuse. + if (connectOptions.reuseConnection === false || connectOptions.initWithStoredValues === false || utils.hasWithOnyxInstance(connectOptions)) { + suffix += `,uniqueID=${Str.guid()}`; + } + + return `onyxKey=${connectOptions.key},initWithStoredValues=${connectOptions.initWithStoredValues ?? true},waitForCollectionCallback=${ + connectOptions.waitForCollectionCallback ?? false + }${suffix}`; + } + + /** + * Fires all the subscribers callbacks associated with that connection ID. + */ + private fireCallbacks(connectionID: string): void { + const connection = this.connectionsMap.get(connectionID); + + connection?.callbacks.forEach((callback) => { + callback(connection.cachedCallbackValue, connection.cachedCallbackKey as OnyxKey); + }); + } + + /** + * Connects to an Onyx key given the options passed and listens to its changes. + * + * @param connectOptions The options object that will define the behavior of the connection. + * @returns The connection object to use when calling `disconnect()`. + */ + connect(connectOptions: ConnectOptions): Connection { + const connectionID = this.generateConnectionID(connectOptions); + let connectionMetadata = this.connectionsMap.get(connectionID); + let subscriptionID: number | undefined; + + const callbackID = String(this.lastCallbackID++); + + // If there is no connection yet for that connection ID, we create a new one. + if (!connectionMetadata) { + let callback: ConnectCallback | undefined; + + // If the subscriber is a `withOnyx` HOC we don't define `callback` as the HOC will use + // its own logic to handle the data. + if (!utils.hasWithOnyxInstance(connectOptions)) { + callback = (value, key) => { + const createdConnection = this.connectionsMap.get(connectionID); + if (createdConnection) { + // We signal that the first connection was made and now any new subscribers + // can fire their callbacks immediately with the cached value when connecting. + createdConnection.isConnectionMade = true; + createdConnection.cachedCallbackValue = value; + createdConnection.cachedCallbackKey = key; + + this.fireCallbacks(connectionID); + } + }; + } + + subscriptionID = OnyxUtils.subscribeToKey({ + ...(connectOptions as DefaultConnectOptions), + callback, + }); + + connectionMetadata = { + subscriptionID, + onyxKey: connectOptions.key, + isConnectionMade: false, + callbacks: new Map(), + }; + + this.connectionsMap.set(connectionID, connectionMetadata); + } + + // We add the subscriber's callback to the list of callbacks associated with this connection. + if (connectOptions.callback) { + connectionMetadata.callbacks.set(callbackID, connectOptions.callback as ConnectCallback); + } + + // If the first connection is already made we want any new subscribers to receive the cached callback value immediately. + if (connectionMetadata.isConnectionMade) { + // Defer the callback execution to the next tick of the event loop. + // This ensures that the current execution flow completes and the result connection object is available when the callback fires. + Promise.resolve().then(() => { + (connectOptions as DefaultConnectOptions).callback?.(connectionMetadata.cachedCallbackValue, connectionMetadata.cachedCallbackKey as OnyxKey); + }); + } + + return {id: connectionID, callbackID}; + } + + /** + * Disconnects and removes the listener from the Onyx key. + * + * @param connection Connection object returned by calling `connect()`. + */ + disconnect(connection: Connection): void { + if (!connection) { + Logger.logInfo(`[ConnectionManager] Attempted to disconnect passing an undefined connection object.`); + return; + } + + const connectionMetadata = this.connectionsMap.get(connection.id); + if (!connectionMetadata) { + Logger.logInfo(`[ConnectionManager] Attempted to disconnect but no connection was found.`); + return; + } + + // Removes the callback from the connection's callbacks map. + connectionMetadata.callbacks.delete(connection.callbackID); + + // If the connection's callbacks map is empty we can safely unsubscribe from the Onyx key. + if (connectionMetadata.callbacks.size === 0) { + OnyxUtils.unsubscribeFromKey(connectionMetadata.subscriptionID); + this.removeFromEvictionBlockList(connection); + + this.connectionsMap.delete(connection.id); + } + } + + /** + * Disconnect all subscribers from Onyx. + */ + disconnectAll(): void { + this.connectionsMap.forEach((connectionMetadata, connectionID) => { + OnyxUtils.unsubscribeFromKey(connectionMetadata.subscriptionID); + connectionMetadata.callbacks.forEach((_, callbackID) => { + this.removeFromEvictionBlockList({id: connectionID, callbackID}); + }); + }); + + this.connectionsMap.clear(); + } + + /** + * Adds the connection to the eviction block list. Connections added to this list can never be evicted. + * */ + addToEvictionBlockList(connection: Connection): void { + const connectionMetadata = this.connectionsMap.get(connection.id); + if (!connectionMetadata) { + return; + } + + const evictionBlocklist = OnyxUtils.getEvictionBlocklist(); + if (!evictionBlocklist[connectionMetadata.onyxKey]) { + evictionBlocklist[connectionMetadata.onyxKey] = []; + } + + evictionBlocklist[connectionMetadata.onyxKey]?.push(`${connection.id}_${connection.callbackID}`); + } + + /** + * Removes a connection previously added to this list + * which will enable it to be evicted again. + */ + removeFromEvictionBlockList(connection: Connection): void { + const connectionMetadata = this.connectionsMap.get(connection.id); + if (!connectionMetadata) { + return; + } + + const evictionBlocklist = OnyxUtils.getEvictionBlocklist(); + evictionBlocklist[connectionMetadata.onyxKey] = + evictionBlocklist[connectionMetadata.onyxKey]?.filter((evictionKey) => evictionKey !== `${connection.id}_${connection.callbackID}`) ?? []; + + // Remove the key if there are no more subscribers. + if (evictionBlocklist[connectionMetadata.onyxKey]?.length === 0) { + delete evictionBlocklist[connectionMetadata.onyxKey]; + } + } +} + +const connectionManager = new OnyxConnectionManager(); + +export default connectionManager; + +export type {Connection}; diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 5672beb3..57c718b7 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -14,6 +14,7 @@ import Storage from './storage'; import type { CollectionKey, CollectionKeyBase, + ConnectOptions, DeepRecord, DefaultConnectCallback, DefaultConnectOptions, @@ -26,10 +27,11 @@ import type { OnyxMergeCollectionInput, OnyxValue, Selector, - WithOnyxConnectOptions, } from './types'; import utils from './utils'; import type {WithOnyxState} from './withOnyx/types'; +import type {DeferredTask} from './createDeferredTask'; +import createDeferredTask from './createDeferredTask'; // Method constants const METHOD = { @@ -52,8 +54,8 @@ const callbackToStateMapping: Record> = {}; // Keeps a copy of the values of the onyx collection keys as a map for faster lookups let onyxCollectionKeySet = new Set(); -// Holds a mapping of the connected key to the connectionID for faster lookups -const onyxKeyToConnectionIDs = new Map(); +// Holds a mapping of the connected key to the subscriptionID for faster lookups +const onyxKeyToSubscriptionIDs = new Map(); // Holds a list of keys that have been directly subscribed to or recently modified from least to most recent let recentlyAccessedKeys: OnyxKey[] = []; @@ -62,9 +64,9 @@ let recentlyAccessedKeys: OnyxKey[] = []; // whatever appears in this list it will NEVER be a candidate for eviction. let evictionAllowList: OnyxKey[] = []; -// Holds a map of keys and connectionID arrays whose keys will never be automatically evicted as +// Holds a map of keys and connection 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: Record = {}; +const evictionBlocklist: Record = {}; // Optional user-provided key value states set when Onyx initializes or clears let defaultKeyStates: Record> = {}; @@ -77,6 +79,12 @@ const lastConnectionCallbackData = new Map>(); let snapshotKey: OnyxKey | null = null; +// Keeps track of the last subscriptionID that was used so we can keep incrementing it +let lastSubscriptionID = 0; + +// Connections can be made before `Onyx.init`. They would wait for this task before resolving +const deferredInitTask = createDeferredTask(); + function getSnapshotKey(): OnyxKey | null { return snapshotKey; } @@ -96,17 +104,24 @@ function getMergeQueuePromise(): Record> { } /** - * Getter - returns the callback to state mapping. + * Getter - returns the default key states. */ -function getCallbackToStateMapping(): Record> { - return callbackToStateMapping; +function getDefaultKeyStates(): Record> { + return defaultKeyStates; } /** - * Getter - returns the default key states. + * Getter - returns the deffered init task. */ -function getDefaultKeyStates(): Record> { - return defaultKeyStates; +function getDeferredInitTask(): DeferredTask { + return deferredInitTask; +} + +/** + * Getter - returns the eviction block list. + */ +function getEvictionBlocklist(): Record { + return evictionBlocklist; } /** @@ -327,32 +342,32 @@ function multiGet(keys: CollectionKeyBase[]): Promise id !== connectionID); - onyxKeyToConnectionIDs.set(subscriber.key, updatedConnectionIDs); + if (subscriber && onyxKeyToSubscriptionIDs.has(subscriber.key)) { + const updatedSubscriptionsIDs = onyxKeyToSubscriptionIDs.get(subscriber.key).filter((id: number) => id !== subscriptionID); + onyxKeyToSubscriptionIDs.set(subscriber.key, updatedSubscriptionsIDs); } - lastConnectionCallbackData.delete(connectionID); + lastConnectionCallbackData.delete(subscriptionID); } /** Returns current key names stored in persisted storage */ @@ -455,7 +470,7 @@ function getCollectionKey(key: OnyxKey): string { * 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. */ -function tryGetCachedValue(key: TKey, mapping?: Partial>): OnyxValue { +function tryGetCachedValue(key: TKey, mapping?: Partial>): OnyxValue { let val = cache.get(key); if (isCollectionKey(key)) { @@ -511,30 +526,6 @@ function addLastAccessedKey(key: OnyxKey): void { recentlyAccessedKeys.push(key); } -/** - * Removes a key previously added to this list - * which will enable it to be deleted again. - */ -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) { - delete evictionBlocklist[key]; - } -} - -/** Keys added to this list can never be deleted. */ -function addToEvictionBlockList(key: OnyxKey, connectionID: number): void { - removeFromEvictionBlockList(key, connectionID); - - if (!evictionBlocklist[key]) { - evictionBlocklist[key] = []; - } - - evictionBlocklist[key].push(connectionID); -} - /** * Take all the keys that are safe to evict and add them to * the recently accessed list when initializing the app. This @@ -633,7 +624,7 @@ function keysChanged( // send the whole cached collection. if (isSubscribedToCollectionKey) { if (subscriber.waitForCollectionCallback) { - subscriber.callback(cachedCollection); + subscriber.callback(cachedCollection, subscriber.key); continue; } @@ -668,7 +659,7 @@ function keysChanged( } // React component subscriber found. - if ('withOnyxInstance' in subscriber && subscriber.withOnyxInstance) { + if (utils.hasWithOnyxInstance(subscriber)) { if (!notifyWithOnyxSubscibers) { continue; } @@ -800,13 +791,13 @@ function keyChanged( // Given the amount of times this function is called we need to make sure we are not iterating over all subscribers every time. On the other hand, we don't need to // do the same in keysChanged, because we only call that function when a collection key changes, and it doesn't happen that often. // For performance reason, we look for the given key and later if don't find it we look for the collection key, instead of checking if it is a collection key first. - let stateMappingKeys = onyxKeyToConnectionIDs.get(key) ?? []; + let stateMappingKeys = onyxKeyToSubscriptionIDs.get(key) ?? []; const collectionKey = getCollectionKey(key); const plainCollectionKey = collectionKey.lastIndexOf('_') !== -1 ? collectionKey : undefined; if (plainCollectionKey) { // Getting the collection key from the specific key because only collection keys were stored in the mapping. - stateMappingKeys = [...stateMappingKeys, ...(onyxKeyToConnectionIDs.get(plainCollectionKey) ?? [])]; + stateMappingKeys = [...stateMappingKeys, ...(onyxKeyToSubscriptionIDs.get(plainCollectionKey) ?? [])]; if (stateMappingKeys.length === 0) { return; } @@ -825,7 +816,7 @@ function keyChanged( if (!notifyConnectSubscribers) { continue; } - if (lastConnectionCallbackData.has(subscriber.connectionID) && lastConnectionCallbackData.get(subscriber.connectionID) === value) { + if (lastConnectionCallbackData.has(subscriber.subscriptionID) && lastConnectionCallbackData.get(subscriber.subscriptionID) === value) { continue; } @@ -838,19 +829,19 @@ function keyChanged( } cachedCollection[key] = value; - subscriber.callback(cachedCollection); + subscriber.callback(cachedCollection, subscriber.key); continue; } const subscriberCallback = subscriber.callback as DefaultConnectCallback; subscriberCallback(value, key); - lastConnectionCallbackData.set(subscriber.connectionID, value); + lastConnectionCallbackData.set(subscriber.subscriptionID, value); continue; } // Subscriber connected via withOnyx() HOC - if ('withOnyxInstance' in subscriber && subscriber.withOnyxInstance) { + if (utils.hasWithOnyxInstance(subscriber)) { if (!notifyWithOnyxSubscribers) { continue; } @@ -952,11 +943,11 @@ function keyChanged( function sendDataToConnection(mapping: Mapping, value: OnyxValue | null, matchedKey: TKey | undefined, isBatched: boolean): void { // 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]) { + if (!callbackToStateMapping[mapping.subscriptionID]) { return; } - if ('withOnyxInstance' in mapping && mapping.withOnyxInstance) { + if (utils.hasWithOnyxInstance(mapping)) { let newData: OnyxValue = value; // If the mapping has a selector, then the component's state must only be updated with the data @@ -985,11 +976,11 @@ function sendDataToConnection(mapping: Mapping, valu // withOnyx will internally replace null values with undefined and never pass null values to wrapped components. // For regular callbacks, we never want to pass null values, but always just undefined if a value is not set in cache or storage. const valueToPass = value === null ? undefined : value; - const lastValue = lastConnectionCallbackData.get(mapping.connectionID); - lastConnectionCallbackData.get(mapping.connectionID); + const lastValue = lastConnectionCallbackData.get(mapping.subscriptionID); + lastConnectionCallbackData.get(mapping.subscriptionID); // If the value has not changed we do not need to trigger the callback - if (lastConnectionCallbackData.has(mapping.connectionID) && valueToPass === lastValue) { + if (lastConnectionCallbackData.has(mapping.subscriptionID) && valueToPass === lastValue) { return; } @@ -1008,7 +999,7 @@ function addKeyToRecentlyAccessedIfNeeded(mapping: Mapping // Try to free some cache whenever we connect to a safe eviction key cache.removeLeastRecentlyUsedKeys(); - if ('withOnyxInstance' in mapping && mapping.withOnyxInstance && !isCollectionKey(mapping.key)) { + if (utils.hasWithOnyxInstance(mapping) && !isCollectionKey(mapping.key)) { // All React components subscribing to a key flagged as a safe eviction key must implement the canEvict property. if (mapping.canEvict === undefined) { throw new Error(`Cannot subscribe to safe eviction key '${mapping.key}' without providing a canEvict value.`); @@ -1236,7 +1227,7 @@ function isValidNonEmptyCollectionForMerge function doAllCollectionItemsBelongToSameParent(collectionKey: TKey, collectionKeys: string[]): boolean { let hasCollectionKeyCheckFailed = false; collectionKeys.forEach((dataKey) => { - if (OnyxUtils.isKeyMatch(collectionKey, dataKey)) { + if (isKeyMatch(collectionKey, dataKey)) { return; } @@ -1251,12 +1242,130 @@ function doAllCollectionItemsBelongToSameParent( return !hasCollectionKeyCheckFailed; } +/** + * Subscribes to an Onyx key and listens to its changes. + * + * @param connectOptions The options object that will define the behavior of the connection. + * @returns The subscription ID to use when calling `OnyxUtils.unsubscribeFromKey()`. + */ +function subscribeToKey(connectOptions: ConnectOptions): number { + const mapping = connectOptions as Mapping; + const subscriptionID = lastSubscriptionID++; + callbackToStateMapping[subscriptionID] = mapping as Mapping; + callbackToStateMapping[subscriptionID].subscriptionID = subscriptionID; + + // When keyChanged is called, a key is passed and the method looks through all the Subscribers in callbackToStateMapping for the matching key to get the subscriptionID + // to avoid having to loop through all the Subscribers all the time (even when just one connection belongs to one key), + // We create a mapping from key to lists of subscriptionIDs to access the specific list of subscriptionIDs. + storeKeyBySubscriptions(mapping.key, callbackToStateMapping[subscriptionID].subscriptionID); + + if (mapping.initWithStoredValues === false) { + return subscriptionID; + } + + // Commit connection only after init passes + deferredInitTask.promise + .then(() => addKeyToRecentlyAccessedIfNeeded(mapping)) + .then(() => { + // Performance improvement + // If the mapping is connected to an onyx key that is not a collection + // we can skip the call to getAllKeys() and return an array with a single item + if (Boolean(mapping.key) && typeof mapping.key === 'string' && !mapping.key.endsWith('_') && cache.getAllKeys().has(mapping.key)) { + return new Set([mapping.key]); + } + return getAllKeys(); + }) + .then((keys) => { + // 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: string[] = []; + keys.forEach((key) => { + if (!isKeyMatch(mapping.key, key)) { + return; + } + matchingKeys.push(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 + // since there are none matched. In withOnyx() we wait for all connected keys to return a value before rendering the child + // component. This null value will be filtered out so that the connected component can utilize defaultProps. + if (matchingKeys.length === 0) { + if (mapping.key && !isCollectionKey(mapping.key)) { + cache.addNullishStorageKey(mapping.key); + } + + // Here we cannot use batching because the nullish value is expected to be set immediately for default props + // or they will be undefined. + sendDataToConnection(mapping, null, undefined, false); + return; + } + + // 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 (typeof mapping.callback === 'function') { + if (isCollectionKey(mapping.key)) { + if (mapping.waitForCollectionCallback) { + getCollectionDataAndSendAsObject(matchingKeys, mapping); + return; + } + + // We did not opt into using waitForCollectionCallback mode so the callback is called for every matching key. + multiGet(matchingKeys).then((values) => { + values.forEach((val, key) => { + sendDataToConnection(mapping, val as OnyxValue, key as TKey, true); + }); + }); + return; + } + + // If we are not subscribed to a collection key then there's only a single key to send an update for. + get(mapping.key).then((val) => sendDataToConnection(mapping, val as OnyxValue, mapping.key, true)); + return; + } + + // If we have a withOnyxInstance that means a React component has subscribed via the withOnyx() HOC and we need to + // group collection key member data into an object. + if (utils.hasWithOnyxInstance(mapping)) { + if (isCollectionKey(mapping.key)) { + getCollectionDataAndSendAsObject(matchingKeys, mapping); + return; + } + + // If the subscriber is not using a collection key then we just send a single value back to the subscriber + get(mapping.key).then((val) => sendDataToConnection(mapping, val as OnyxValue, mapping.key, true)); + return; + } + + console.error('Warning: Onyx.connect() was found without a callback or withOnyxInstance'); + }); + + // The subscriptionID is returned back to the caller so that it can be used to clean up the connection when it's no longer needed + // by calling OnyxUtils.unsubscribeFromKey(subscriptionID). + return subscriptionID; +} + +/** + * Disconnects and removes the listener from the Onyx key. + * + * @param subscriptionID Subscription ID returned by calling `OnyxUtils.subscribeToKey()`. + */ +function unsubscribeFromKey(subscriptionID: number): void { + if (!callbackToStateMapping[subscriptionID]) { + return; + } + + deleteKeyBySubscriptions(lastSubscriptionID); + delete callbackToStateMapping[subscriptionID]; +} + const OnyxUtils = { METHOD, getMergeQueue, getMergeQueuePromise, - getCallbackToStateMapping, getDefaultKeyStates, + getDeferredInitTask, initStoreValues, sendActionToDevTools, maybeFlushBatchUpdates, @@ -1272,14 +1381,11 @@ const OnyxUtils = { tryGetCachedValue, removeLastAccessedKey, addLastAccessedKey, - removeFromEvictionBlockList, - addToEvictionBlockList, addAllSafeEvictionKeysToRecentlyAccessedList, getCachedCollection, keysChanged, keyChanged, sendDataToConnection, - addKeyToRecentlyAccessedIfNeeded, getCollectionKey, getCollectionDataAndSendAsObject, scheduleSubscriberUpdate, @@ -1293,12 +1399,13 @@ const OnyxUtils = { prepareKeyValuePairsForStorage, applyMerge, initializeWithDefaultKeyStates, - storeKeyByConnections, - deleteKeyByConnections, getSnapshotKey, multiGet, isValidNonEmptyCollectionForMerge, doAllCollectionItemsBelongToSameParent, + subscribeToKey, + unsubscribeFromKey, + getEvictionBlocklist, }; export default OnyxUtils; diff --git a/lib/Str.ts b/lib/Str.ts index bb1429b4..7940b384 100644 --- a/lib/Str.ts +++ b/lib/Str.ts @@ -21,4 +21,16 @@ function result unknown, TArgs extends unknow return typeof parameter === 'function' ? (parameter(...args) as ReturnType) : parameter; } -export {startsWith, result}; +/** + * A simple GUID generator taken from https://stackoverflow.com/a/32760401/9114791 + */ +function guid(): string { + function s4() { + return Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); + } + return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`; +} + +export {guid, result, startsWith}; diff --git a/lib/createDeferredTask.ts b/lib/createDeferredTask.ts index 3d37c91f..e697e1a7 100644 --- a/lib/createDeferredTask.ts +++ b/lib/createDeferredTask.ts @@ -17,3 +17,5 @@ export default function createDeferredTask(): DeferredTask { return deferred; } + +export type {DeferredTask}; diff --git a/lib/index.ts b/lib/index.ts index 04ec3fb9..cde7a907 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -18,6 +18,7 @@ import type { OnyxMergeCollectionInput, } from './types'; import type {FetchStatus, ResultMetadata, UseOnyxResult} from './useOnyx'; +import type {Connection} from './OnyxConnectionManager'; import useOnyx from './useOnyx'; import withOnyx from './withOnyx'; import type {WithOnyxState} from './withOnyx/types'; @@ -46,4 +47,5 @@ export type { Selector, UseOnyxResult, WithOnyxState, + Connection, }; diff --git a/lib/types.ts b/lib/types.ts index e6a6c0ec..3a6696ce 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -261,35 +261,47 @@ type Collection = { }; /** Represents the base options used in `Onyx.connect()` method. */ +// NOTE: Any changes to this type like adding or removing options must be accounted in OnyxConnectionManager's `generateConnectionID()` method! type BaseConnectOptions = { + /** If set to `false`, then the initial data will be only sent to the callback function if it changes. */ initWithStoredValues?: boolean; -}; -/** Represents additional options used inside withOnyx HOC */ -type WithOnyxConnectOptions = { - withOnyxInstance: WithOnyxInstance; - statePropertyName: string; - displayName: string; - initWithStoredValues?: boolean; - selector?: Selector; - canEvict?: boolean; + /** + * If set to `false`, the connection won't be reused between other subscribers that are listening to the same Onyx key + * with the same connect configurations. + */ + reuseConnection?: boolean; }; +/** Represents the callback function used in `Onyx.connect()` method with a regular key. */ type DefaultConnectCallback = (value: OnyxEntry, key: TKey) => void; -type CollectionConnectCallback = (value: NonUndefined>) => void; +/** Represents the callback function used in `Onyx.connect()` method with a collection key. */ +type CollectionConnectCallback = (value: NonUndefined>, key: TKey) => void; -/** Represents the callback function used in `Onyx.connect()` method with a regular key. */ -type DefaultConnectOptions = { +/** Represents the options used in `Onyx.connect()` method with a regular key. */ +// NOTE: Any changes to this type like adding or removing options must be accounted in OnyxConnectionManager's `generateConnectionID()` method! +type DefaultConnectOptions = BaseConnectOptions & { + /** The Onyx key to subscribe to. */ key: TKey; + + /** A function that will be called when the Onyx data we are subscribed changes. */ callback?: DefaultConnectCallback; + + /** If set to `true`, it will return the entire collection to the callback as a single object. */ waitForCollectionCallback?: false; }; -/** Represents the callback function used in `Onyx.connect()` method with a collection key. */ -type CollectionConnectOptions = { +/** Represents the options used in `Onyx.connect()` method with a collection key. */ +// NOTE: Any changes to this type like adding or removing options must be accounted in OnyxConnectionManager's `generateConnectionID()` method! +type CollectionConnectOptions = BaseConnectOptions & { + /** The Onyx key to subscribe to. */ key: TKey extends CollectionKeyBase ? TKey : never; + + /** A function that will be called when the Onyx data we are subscribed changes. */ callback?: CollectionConnectCallback; + + /** If set to `true`, it will return the entire collection to the callback as a single object. */ waitForCollectionCallback: true; }; @@ -303,13 +315,36 @@ type CollectionConnectOptions = { * * 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`. - * - * The type is also extended with `BaseConnectOptions` and `WithOnyxConnectOptions` to include additional options, depending on the context where it's used. */ -type ConnectOptions = (CollectionConnectOptions | DefaultConnectOptions) & (BaseConnectOptions | WithOnyxConnectOptions); +// NOTE: Any changes to this type like adding or removing options must be accounted in OnyxConnectionManager's `generateConnectionID()` method! +type ConnectOptions = DefaultConnectOptions | CollectionConnectOptions; + +/** Represents additional `Onyx.connect()` options used inside `withOnyx()` HOC. */ +// NOTE: Any changes to this type like adding or removing options must be accounted in OnyxConnectionManager's `generateConnectionID()` method! +type WithOnyxConnectOptions = ConnectOptions & { + /** The `withOnyx` class instance to be internally passed. */ + withOnyxInstance: WithOnyxInstance; + + /** The name of the component's prop that is connected to the Onyx key. */ + statePropertyName: string; + + /** The component's display name. */ + displayName: string; + + /** + * This will be used to subscribe to a subset of an Onyx key's 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). + */ + selector?: Selector; + + /** Determines if this key in this subscription is safe to be evicted. */ + canEvict?: boolean; +}; -type Mapping = ConnectOptions & { - connectionID: number; +type Mapping = WithOnyxConnectOptions & { + subscriptionID: number; }; /** diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 406968ba..3abea163 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -1,8 +1,9 @@ import {deepEqual, shallowEqual} from 'fast-equals'; import {useCallback, useEffect, useRef, useSyncExternalStore} from 'react'; import type {IsEqual} from 'type-fest'; -import Onyx from './Onyx'; import OnyxCache from './OnyxCache'; +import type {Connection} from './OnyxConnectionManager'; +import connectionManager from './OnyxConnectionManager'; import OnyxUtils from './OnyxUtils'; import type {CollectionKeyBase, OnyxCollection, OnyxEntry, OnyxKey, OnyxValue, Selector} from './types'; import useLiveRef from './useLiveRef'; @@ -15,12 +16,12 @@ type BaseUseOnyxOptions = { canEvict?: boolean; /** - * If set to false, then no data will be prefilled into the component. + * If set to `false`, then no data will be prefilled into the component. */ initWithStoredValues?: boolean; /** - * If set to true, data will be retrieved from cache during the first render even if there is a pending merge for the key. + * If set to `true`, data will be retrieved from cache during the first render even if there is a pending merge for the key. */ allowStaleData?: boolean; }; @@ -69,7 +70,7 @@ function useOnyx>( options?: BaseUseOnyxOptions & UseOnyxInitialValueOption>, ): UseOnyxResult; function useOnyx>(key: TKey, options?: UseOnyxOptions): UseOnyxResult { - const connectionIDRef = useRef(null); + const connectionRef = useRef(null); const previousKey = usePrevious(key); // Used to stabilize the selector reference and avoid unnecessary calls to `getSnapshot()`. @@ -127,6 +128,23 @@ function useOnyx>(key: TKey ); }, [previousKey, key]); + // Mimics withOnyx's checkEvictableKeys() behavior. + const checkEvictableKey = useCallback(() => { + if (options?.canEvict === undefined || !connectionRef.current) { + return; + } + + if (!OnyxUtils.isSafeEvictionKey(key)) { + throw new Error(`canEvict can't be used on key '${key}'. This key must explicitly be flagged as safe for removal by adding it to Onyx.init({safeEvictionKeys: []}).`); + } + + if (options.canEvict) { + connectionManager.removeFromEvictionBlockList(connectionRef.current); + } else { + connectionManager.addToEvictionBlockList(connectionRef.current); + } + }, [key, options?.canEvict]); + const getSnapshot = useCallback(() => { // We get the value from cache while the first connection to Onyx is being made, // so we can return any cached value right away. After the connection is made, we only @@ -189,7 +207,7 @@ function useOnyx>(key: TKey const subscribe = useCallback( (onStoreChange: () => void) => { - connectionIDRef.current = Onyx.connect({ + connectionRef.current = connectionManager.connect({ key, callback: () => { // Signals that the first connection was made, so some logics in `getSnapshot()` @@ -206,34 +224,23 @@ function useOnyx>(key: TKey waitForCollectionCallback: OnyxUtils.isCollectionKey(key) as true, }); + checkEvictableKey(); + return () => { - if (!connectionIDRef.current) { + if (!connectionRef.current) { return; } - Onyx.disconnect(connectionIDRef.current); + connectionManager.disconnect(connectionRef.current); isFirstConnectionRef.current = false; }; }, - [key, options?.initWithStoredValues], + [key, options?.initWithStoredValues, checkEvictableKey], ); - // Mimics withOnyx's checkEvictableKeys() behavior. useEffect(() => { - if (options?.canEvict === undefined || !connectionIDRef.current) { - return; - } - - if (!OnyxUtils.isSafeEvictionKey(key)) { - throw new Error(`canEvict can't be used on key '${key}'. This key must explicitly be flagged as safe for removal by adding it to Onyx.init({safeEvictionKeys: []}).`); - } - - if (options.canEvict) { - OnyxUtils.removeFromEvictionBlockList(key, connectionIDRef.current); - } else { - OnyxUtils.addToEvictionBlockList(key, connectionIDRef.current); - } - }, [key, options?.canEvict]); + checkEvictableKey(); + }, [checkEvictableKey]); const result = useSyncExternalStore>(subscribe, getSnapshot); diff --git a/lib/utils.ts b/lib/utils.ts index ad496722..cdd04a54 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/prefer-for-of */ -import type {OnyxInput, OnyxKey} from './types'; +import type {ConnectOptions, OnyxInput, OnyxKey} from './types'; type EmptyObject = Record; type EmptyValue = EmptyObject | null | undefined; @@ -196,4 +196,11 @@ function omit(obj: Record, condition: string | string[] return filterObject(obj, condition, false); } -export default {isEmptyObject, fastMerge, formatActionName, removeNestedNullValues, checkCompatibilityWithExistingValue, pick, omit}; +/** + * Whether the connect options has the `withOnyxInstance` property defined, that is, it's used by the `withOnyx()` HOC. + */ +function hasWithOnyxInstance(mapping: ConnectOptions) { + return 'withOnyxInstance' in mapping && mapping.withOnyxInstance; +} + +export default {isEmptyObject, fastMerge, formatActionName, removeNestedNullValues, checkCompatibilityWithExistingValue, pick, omit, hasWithOnyxInstance}; diff --git a/lib/withOnyx/index.tsx b/lib/withOnyx/index.tsx index fdc5bed5..bd821a45 100644 --- a/lib/withOnyx/index.tsx +++ b/lib/withOnyx/index.tsx @@ -4,13 +4,14 @@ * will automatically change to reflect the new data. */ import React from 'react'; -import Onyx from '../Onyx'; import OnyxUtils from '../OnyxUtils'; import * as Str from '../Str'; -import type {GenericFunction, OnyxKey, WithOnyxConnectOptions} from '../types'; +import type {GenericFunction, Mapping, OnyxKey, WithOnyxConnectOptions} from '../types'; import utils from '../utils'; import type {MapOnyxToState, WithOnyxInstance, WithOnyxMapping, WithOnyxProps, WithOnyxState} from './types'; import cache from '../OnyxCache'; +import type {Connection} from '../OnyxConnectionManager'; +import connectionManager from '../OnyxConnectionManager'; // This is a list of keys that can exist on a `mapping`, but are not directly related to loading data from Onyx. When the keys of a mapping are looped over to check // if a key has changed, it's a good idea to skip looking at these properties since they would have unexpected results. @@ -67,7 +68,7 @@ export default function ( shouldDelayUpdates: boolean; - activeConnectionIDs: Record; + activeConnections: Record; tempState: WithOnyxState | undefined; @@ -78,13 +79,13 @@ export default function ( this.setWithOnyxState = this.setWithOnyxState.bind(this); this.flushPendingSetStates = this.flushPendingSetStates.bind(this); - // This stores all the Onyx connection IDs to be used when the component unmounts so everything can be - // disconnected. It is a key value store with the format {[mapping.key]: connectionID}. - this.activeConnectionIDs = {}; + // This stores all the Onyx connections to be used when the component unmounts so everything can be + // disconnected. It is a key value store with the format {[mapping.key]: connection metadata object}. + this.activeConnections = {}; const cachedState = mapOnyxToStateEntries(mapOnyxToState).reduce>((resultObj, [propName, mapping]) => { const key = Str.result(mapping.key as GenericFunction, props); - let value = OnyxUtils.tryGetCachedValue(key, mapping as Partial>); + let value = OnyxUtils.tryGetCachedValue(key, mapping as Mapping); const hasCacheForKey = cache.hasCacheForKey(key); if (!hasCacheForKey && !value && mapping.initialValue) { @@ -163,8 +164,8 @@ export default function ( const previousKey = isFirstTimeUpdatingAfterLoading ? mapping.previousKey : Str.result(mapping.key as GenericFunction, {...prevProps, ...prevOnyxDataFromState}); const newKey = Str.result(mapping.key as GenericFunction, {...this.props, ...onyxDataFromState}); if (previousKey !== newKey) { - Onyx.disconnect(this.activeConnectionIDs[previousKey], previousKey); - delete this.activeConnectionIDs[previousKey]; + connectionManager.disconnect(this.activeConnections[previousKey]); + delete this.activeConnections[previousKey]; this.connectMappingToOnyx(mapping, propName, newKey); } }); @@ -176,7 +177,7 @@ export default function ( // Disconnect everything from Onyx mapOnyxToStateEntries(mapOnyxToState).forEach(([, mapping]) => { const key = Str.result(mapping.key as GenericFunction, {...this.props, ...getOnyxDataFromState(this.state, mapOnyxToState)}); - Onyx.disconnect(this.activeConnectionIDs[key], key); + connectionManager.disconnect(this.activeConnections[key]); }); } @@ -293,9 +294,9 @@ export default function ( } if (canEvict) { - OnyxUtils.removeFromEvictionBlockList(key, mapping.connectionID); + connectionManager.removeFromEvictionBlockList(this.activeConnections[key]); } else { - OnyxUtils.addToEvictionBlockList(key, mapping.connectionID); + connectionManager.addToEvictionBlockList(this.activeConnections[key]); } }); } @@ -319,13 +320,13 @@ export default function ( onyxMapping.previousKey = key; // eslint-disable-next-line rulesdir/prefer-onyx-connect-in-libs - this.activeConnectionIDs[key] = Onyx.connect({ + this.activeConnections[key] = connectionManager.connect({ ...mapping, key, statePropertyName: statePropertyName as string, withOnyxInstance: this as unknown as WithOnyxInstance, displayName, - }); + } as WithOnyxConnectOptions); } flushPendingSetStates() { diff --git a/lib/withOnyx/types.ts b/lib/withOnyx/types.ts index 4b8b85ec..f536fec3 100644 --- a/lib/withOnyx/types.ts +++ b/lib/withOnyx/types.ts @@ -109,7 +109,7 @@ type Mapping = Mapping & { - connectionID: number; + subscriptionID: number; previousKey?: OnyxKey; }; diff --git a/tests/unit/OnyxConnectionManagerTest.ts b/tests/unit/OnyxConnectionManagerTest.ts new file mode 100644 index 00000000..a9f77a3c --- /dev/null +++ b/tests/unit/OnyxConnectionManagerTest.ts @@ -0,0 +1,394 @@ +// eslint-disable-next-line max-classes-per-file +import {act} from '@testing-library/react-native'; +import Onyx from '../../lib'; +import connectionManager from '../../lib/OnyxConnectionManager'; +import StorageMock from '../../lib/storage'; +import type {OnyxKey, WithOnyxConnectOptions} from '../../lib/types'; +import type {WithOnyxInstance} from '../../lib/withOnyx/types'; +import type GenericCollection from '../utils/GenericCollection'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; +import OnyxUtils from '../../lib/OnyxUtils'; + +// We need access to `connectionsMap` and `generateConnectionID` during the tests but the properties are private, +// so this workaround allows us to have access to them. +// eslint-disable-next-line dot-notation +const connectionsMap = connectionManager['connectionsMap']; +// eslint-disable-next-line dot-notation +const generateConnectionID = connectionManager['generateConnectionID']; + +function generateEmptyWithOnyxInstance() { + return new (class { + // eslint-disable-next-line @typescript-eslint/no-empty-function + setStateProxy() {} + + // eslint-disable-next-line @typescript-eslint/no-empty-function + setWithOnyxState() {} + })() as unknown as WithOnyxInstance; +} + +const ONYXKEYS = { + TEST_KEY: 'test', + TEST_KEY_2: 'test2', + COLLECTION: { + TEST_KEY: 'test_', + TEST_KEY_2: 'test2_', + }, +}; + +Onyx.init({ + keys: ONYXKEYS, +}); + +beforeEach(() => Onyx.clear()); + +describe('OnyxConnectionManager', () => { + // Always use a "fresh" instance + beforeEach(() => { + connectionManager.disconnectAll(); + }); + + describe('generateConnectionID', () => { + it('should generate a stable connection ID', async () => { + const connectionID = generateConnectionID({key: ONYXKEYS.TEST_KEY}); + expect(connectionID).toEqual(`onyxKey=${ONYXKEYS.TEST_KEY},initWithStoredValues=true,waitForCollectionCallback=false`); + }); + + it("should generate a stable connection ID regardless of the order which the option's properties were passed", async () => { + const connectionID = generateConnectionID({key: ONYXKEYS.TEST_KEY, waitForCollectionCallback: true, initWithStoredValues: true}); + expect(connectionID).toEqual(`onyxKey=${ONYXKEYS.TEST_KEY},initWithStoredValues=true,waitForCollectionCallback=true`); + }); + + it('should generate unique connection IDs if certain options are passed', async () => { + const connectionID1 = generateConnectionID({key: ONYXKEYS.TEST_KEY, reuseConnection: false}); + const connectionID2 = generateConnectionID({key: ONYXKEYS.TEST_KEY, reuseConnection: false}); + expect(connectionID1.startsWith(`onyxKey=${ONYXKEYS.TEST_KEY},initWithStoredValues=true,waitForCollectionCallback=false,uniqueID=`)).toBeTruthy(); + expect(connectionID2.startsWith(`onyxKey=${ONYXKEYS.TEST_KEY},initWithStoredValues=true,waitForCollectionCallback=false,uniqueID=`)).toBeTruthy(); + expect(connectionID1).not.toEqual(connectionID2); + + const connectionID3 = generateConnectionID({key: ONYXKEYS.TEST_KEY, initWithStoredValues: false}); + expect(connectionID3.startsWith(`onyxKey=${ONYXKEYS.TEST_KEY},initWithStoredValues=false,waitForCollectionCallback=false,uniqueID=`)).toBeTruthy(); + + const connectionID4 = generateConnectionID({ + key: ONYXKEYS.TEST_KEY, + displayName: 'Component1', + statePropertyName: 'prop1', + withOnyxInstance: generateEmptyWithOnyxInstance(), + } as WithOnyxConnectOptions); + expect(connectionID4.startsWith(`onyxKey=${ONYXKEYS.TEST_KEY},initWithStoredValues=true,waitForCollectionCallback=false,uniqueID=`)).toBeTruthy(); + }); + }); + + describe('connect / disconnect', () => { + it('should connect to a key and fire the callback with its value', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + + const callback1 = jest.fn(); + const connection = connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback1}); + + expect(connectionsMap.has(connection.id)).toBeTruthy(); + + await act(async () => waitForPromisesToResolve()); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); + + connectionManager.disconnect(connection); + + expect(connectionsMap.size).toEqual(0); + }); + + it('should connect two times to the same key and fire both callbacks with its value', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + + const callback1 = jest.fn(); + const connection1 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback1}); + + const callback2 = jest.fn(); + const connection2 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback2}); + + expect(connection1.id).toEqual(connection2.id); + expect(connectionsMap.size).toEqual(1); + expect(connectionsMap.has(connection1.id)).toBeTruthy(); + + await act(async () => waitForPromisesToResolve()); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); + + connectionManager.disconnect(connection1); + connectionManager.disconnect(connection2); + + expect(connectionsMap.size).toEqual(0); + }); + + it('should connect two times to the same key but with different options, and fire the callbacks differently', async () => { + const obj1 = {id: 'entry1_id', name: 'entry1_name'}; + const obj2 = {id: 'entry2_id', name: 'entry2_name'}; + const collection: GenericCollection = { + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]: obj1, + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`]: obj2, + }; + await StorageMock.multiSet([ + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`, obj1], + [`${ONYXKEYS.COLLECTION.TEST_KEY}entry2`, obj2], + ]); + + const callback1 = jest.fn(); + const connection1 = connectionManager.connect({key: ONYXKEYS.COLLECTION.TEST_KEY, callback: callback1}); + + const callback2 = jest.fn(); + const connection2 = connectionManager.connect({key: ONYXKEYS.COLLECTION.TEST_KEY, callback: callback2, waitForCollectionCallback: true}); + + expect(connection1.id).not.toEqual(connection2.id); + expect(connectionsMap.size).toEqual(2); + expect(connectionsMap.has(connection1.id)).toBeTruthy(); + expect(connectionsMap.has(connection2.id)).toBeTruthy(); + + await act(async () => waitForPromisesToResolve()); + + expect(callback1).toHaveBeenCalledTimes(2); + expect(callback1).toHaveBeenNthCalledWith(1, obj1, `${ONYXKEYS.COLLECTION.TEST_KEY}entry1`); + expect(callback1).toHaveBeenNthCalledWith(2, obj2, `${ONYXKEYS.COLLECTION.TEST_KEY}entry2`); + + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledWith(collection, undefined); + + connectionManager.disconnect(connection1); + connectionManager.disconnect(connection2); + + expect(connectionsMap.size).toEqual(0); + }); + + it('should connect to a key, connect some times more after first connection is made, and fire all subsequent callbacks immediately with its value', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + + const callback1 = jest.fn(); + connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback1}); + + await act(async () => waitForPromisesToResolve()); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); + + const callback2 = jest.fn(); + connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback2}); + + const callback3 = jest.fn(); + connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback3}); + + const callback4 = jest.fn(); + connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback4}); + + await act(async () => waitForPromisesToResolve()); + + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); + expect(callback3).toHaveBeenCalledTimes(1); + expect(callback3).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); + expect(callback4).toHaveBeenCalledTimes(1); + expect(callback4).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); + }); + + it('should have the connection object already defined when triggering the callback of the second connection to the same key', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + + const callback1 = jest.fn(); + connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback1}); + + await act(async () => waitForPromisesToResolve()); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); + + const callback2 = jest.fn(); + const connection2 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + callback: (...params) => { + callback2(...params); + connectionManager.disconnect(connection2); + }, + }); + + await act(async () => waitForPromisesToResolve()); + + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledWith('test', ONYXKEYS.TEST_KEY); + expect(connectionsMap.size).toEqual(1); + }); + + it('should create a separate connection to the same key when setting reuseConnection to false', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + + const callback1 = jest.fn(); + const connection1 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback1}); + + const callback2 = jest.fn(); + const connection2 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, reuseConnection: false, callback: callback2}); + + expect(connection1.id).not.toEqual(connection2.id); + expect(connectionsMap.size).toEqual(2); + expect(connectionsMap.has(connection1.id)).toBeTruthy(); + expect(connectionsMap.has(connection2.id)).toBeTruthy(); + }); + + it('should create a separate connection to the same key when setting initWithStoredValues to false', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + + const callback1 = jest.fn(); + const connection1 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, initWithStoredValues: false, callback: callback1}); + + await act(async () => waitForPromisesToResolve()); + + expect(callback1).not.toHaveBeenCalled(); + expect(connectionsMap.size).toEqual(1); + expect(connectionsMap.has(connection1.id)).toBeTruthy(); + + await Onyx.set(ONYXKEYS.TEST_KEY, 'test2'); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledWith('test2', ONYXKEYS.TEST_KEY); + + const callback2 = jest.fn(); + const connection2 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, initWithStoredValues: false, callback: callback2}); + + await act(async () => waitForPromisesToResolve()); + + expect(callback2).not.toHaveBeenCalled(); + expect(connectionsMap.size).toEqual(2); + expect(connectionsMap.has(connection2.id)).toBeTruthy(); + + await Onyx.set(ONYXKEYS.TEST_KEY, 'test3'); + + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledWith('test3', ONYXKEYS.TEST_KEY); + + connectionManager.disconnect(connection1); + connectionManager.disconnect(connection2); + + expect(connectionsMap.size).toEqual(0); + }); + + describe('withOnyx', () => { + it('should connect to a key two times with withOnyx and create two connections instead of one', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + + const connection1 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + displayName: 'Component1', + statePropertyName: 'prop1', + withOnyxInstance: generateEmptyWithOnyxInstance(), + } as WithOnyxConnectOptions); + + const connection2 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + displayName: 'Component2', + statePropertyName: 'prop2', + withOnyxInstance: generateEmptyWithOnyxInstance(), + } as WithOnyxConnectOptions); + + await act(async () => waitForPromisesToResolve()); + + expect(connection1.id).not.toEqual(connection2.id); + expect(connectionsMap.size).toEqual(2); + expect(connectionsMap.has(connection1.id)).toBeTruthy(); + expect(connectionsMap.has(connection2.id)).toBeTruthy(); + + connectionManager.disconnect(connection1); + connectionManager.disconnect(connection2); + + expect(connectionsMap.size).toEqual(0); + }); + + it('should connect to a key directly, connect again with withOnyx but create another connection instead of reusing the first one', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + + const callback1 = jest.fn(); + const connection1 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback1}); + + await act(async () => waitForPromisesToResolve()); + + const connection2 = connectionManager.connect({ + key: ONYXKEYS.TEST_KEY, + displayName: 'Component2', + statePropertyName: 'prop2', + withOnyxInstance: generateEmptyWithOnyxInstance(), + } as WithOnyxConnectOptions); + + expect(connection1.id).not.toEqual(connection2.id); + expect(connectionsMap.size).toEqual(2); + expect(connectionsMap.has(connection1.id)).toBeTruthy(); + expect(connectionsMap.has(connection2.id)).toBeTruthy(); + + connectionManager.disconnect(connection1); + connectionManager.disconnect(connection2); + + expect(connectionsMap.size).toEqual(0); + }); + }); + }); + + describe('disconnectAll', () => { + it('should disconnect all connections', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + await StorageMock.setItem(ONYXKEYS.TEST_KEY_2, 'test2'); + + const callback1 = jest.fn(); + const connection1 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback1}); + + const callback2 = jest.fn(); + const connection2 = connectionManager.connect({key: ONYXKEYS.TEST_KEY, callback: callback2}); + + const callback3 = jest.fn(); + const connection3 = connectionManager.connect({key: ONYXKEYS.TEST_KEY_2, callback: callback3}); + + expect(connection1.id).toEqual(connection2.id); + expect(connectionsMap.size).toEqual(2); + expect(connectionsMap.has(connection1.id)).toBeTruthy(); + expect(connectionsMap.has(connection3.id)).toBeTruthy(); + + await act(async () => waitForPromisesToResolve()); + + connectionManager.disconnectAll(); + + expect(connectionsMap.size).toEqual(0); + }); + }); + + describe('addToEvictionBlockList / removeFromEvictionBlockList', () => { + it('should add and remove connections from the eviction block list correctly', async () => { + const evictionBlocklist = OnyxUtils.getEvictionBlocklist(); + + connectionsMap.set('connectionID1', {subscriptionID: 0, onyxKey: ONYXKEYS.TEST_KEY, callbacks: new Map(), isConnectionMade: true}); + connectionsMap.get('connectionID1')?.callbacks.set('callbackID1', () => undefined); + connectionManager.addToEvictionBlockList({id: 'connectionID1', callbackID: 'callbackID1'}); + expect(evictionBlocklist[ONYXKEYS.TEST_KEY]).toEqual(['connectionID1_callbackID1']); + + connectionsMap.get('connectionID1')?.callbacks.set('callbackID2', () => undefined); + connectionManager.addToEvictionBlockList({id: 'connectionID1', callbackID: 'callbackID2'}); + expect(evictionBlocklist[ONYXKEYS.TEST_KEY]).toEqual(['connectionID1_callbackID1', 'connectionID1_callbackID2']); + + connectionsMap.set('connectionID2', {subscriptionID: 1, onyxKey: `${ONYXKEYS.COLLECTION.TEST_KEY}entry1`, callbacks: new Map(), isConnectionMade: true}); + connectionsMap.get('connectionID2')?.callbacks.set('callbackID3', () => undefined); + connectionManager.addToEvictionBlockList({id: 'connectionID2', callbackID: 'callbackID3'}); + expect(evictionBlocklist[`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]).toEqual(['connectionID2_callbackID3']); + + connectionManager.removeFromEvictionBlockList({id: 'connectionID2', callbackID: 'callbackID3'}); + expect(evictionBlocklist[`${ONYXKEYS.COLLECTION.TEST_KEY}entry1`]).toBeUndefined(); + + // inexistent callback ID, shouldn't do anything + connectionManager.removeFromEvictionBlockList({id: 'connectionID1', callbackID: 'callbackID1000'}); + expect(evictionBlocklist[ONYXKEYS.TEST_KEY]).toEqual(['connectionID1_callbackID1', 'connectionID1_callbackID2']); + + connectionManager.removeFromEvictionBlockList({id: 'connectionID1', callbackID: 'callbackID2'}); + expect(evictionBlocklist[ONYXKEYS.TEST_KEY]).toEqual(['connectionID1_callbackID1']); + + connectionManager.removeFromEvictionBlockList({id: 'connectionID1', callbackID: 'callbackID1'}); + expect(evictionBlocklist[ONYXKEYS.TEST_KEY]).toBeUndefined(); + + // inexistent connection ID, shouldn't do anything + expect(() => connectionManager.removeFromEvictionBlockList({id: 'connectionID0', callbackID: 'callbackID0'})).not.toThrow(); + }); + }); +}); diff --git a/tests/unit/onyxCacheTest.tsx b/tests/unit/onyxCacheTest.tsx index 9487b0b4..0759044c 100644 --- a/tests/unit/onyxCacheTest.tsx +++ b/tests/unit/onyxCacheTest.tsx @@ -13,6 +13,7 @@ import type OnyxInstance from '../../lib/Onyx'; import type withOnyxType from '../../lib/withOnyx'; import type {InitOptions} from '../../lib/types'; import generateRange from '../utils/generateRange'; +import type {Connection} from '../../lib/OnyxConnectionManager'; describe('Onyx', () => { describe('Cache Service', () => { @@ -528,7 +529,7 @@ describe('Onyx', () => { StorageMock.getItem.mockResolvedValue('"mockValue"'); const range = generateRange(0, 10); StorageMock.getAllKeys.mockResolvedValue(range.map((number) => `${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}${number}`)); - let connections: Array<{key: string; connectionId: number}> = []; + let connections: Array<{key: string; connection: Connection}> = []; // Given Onyx is configured with max 5 keys in cache return initOnyx({ @@ -540,14 +541,14 @@ describe('Onyx', () => { const key = `${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}${number}`; return { key, - connectionId: Onyx.connect({key, callback: jest.fn()}), + connection: Onyx.connect({key, callback: jest.fn()}), }; }); }) .then(waitForPromisesToResolve) .then(() => { // When a new connection for a safe eviction key happens - Onyx.connect({key: `${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}9`, callback: jest.fn()}); + Onyx.connect({key: `${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}10`, callback: jest.fn()}); }) .then(() => { // Then the most recent 5 keys should remain in cache diff --git a/tests/unit/onyxClearNativeStorageTest.ts b/tests/unit/onyxClearNativeStorageTest.ts index 28605adc..bac2fd06 100644 --- a/tests/unit/onyxClearNativeStorageTest.ts +++ b/tests/unit/onyxClearNativeStorageTest.ts @@ -2,6 +2,7 @@ import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; import StorageMock from '../../lib/storage'; import Onyx from '../../lib/Onyx'; import type OnyxCache from '../../lib/OnyxCache'; +import type {Connection} from '../../lib/OnyxConnectionManager'; const ONYX_KEYS = { DEFAULT_KEY: 'defaultKey', @@ -14,7 +15,7 @@ const MERGED_VALUE = 1; const DEFAULT_VALUE = 0; describe('Set data while storage is clearing', () => { - let connectionID: number; + let connection: Connection | undefined; let onyxValue: unknown; /** @type OnyxCache */ @@ -31,7 +32,7 @@ describe('Set data while storage is clearing', () => { [ONYX_KEYS.DEFAULT_KEY]: DEFAULT_VALUE, }, }); - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.DEFAULT_KEY, initWithStoredValues: false, callback: (val) => (onyxValue = val), @@ -40,7 +41,9 @@ describe('Set data while storage is clearing', () => { }); afterEach(() => { - Onyx.disconnect(connectionID); + if (connection) { + Onyx.disconnect(connection); + } return Onyx.clear(); }); diff --git a/tests/unit/onyxClearWebStorageTest.ts b/tests/unit/onyxClearWebStorageTest.ts index 6fb52fbc..d2894db6 100644 --- a/tests/unit/onyxClearWebStorageTest.ts +++ b/tests/unit/onyxClearWebStorageTest.ts @@ -3,6 +3,7 @@ import StorageMock from '../../lib/storage'; import Onyx from '../../lib/Onyx'; import type OnyxCache from '../../lib/OnyxCache'; import type GenericCollection from '../utils/GenericCollection'; +import type {Connection} from '../../lib/OnyxConnectionManager'; const ONYX_KEYS = { DEFAULT_KEY: 'defaultKey', @@ -16,7 +17,7 @@ const MERGED_VALUE = 'merged'; const DEFAULT_VALUE = 'default'; describe('Set data while storage is clearing', () => { - let connectionID: number; + let connection: Connection | undefined; let onyxValue: unknown; /** @type OnyxCache */ @@ -33,7 +34,7 @@ describe('Set data while storage is clearing', () => { [ONYX_KEYS.DEFAULT_KEY]: DEFAULT_VALUE, }, }); - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.DEFAULT_KEY, initWithStoredValues: false, callback: (val) => (onyxValue = val), @@ -42,7 +43,9 @@ describe('Set data while storage is clearing', () => { }); afterEach(() => { - Onyx.disconnect(connectionID); + if (connection) { + Onyx.disconnect(connection); + } return Onyx.clear(); }); @@ -129,7 +132,7 @@ describe('Set data while storage is clearing', () => { // Given a mocked callback function and a collection with four items in it const collectionCallback = jest.fn(); - const testConnectionID = Onyx.connect({ + const testConnection = Onyx.connect({ key: ONYX_KEYS.COLLECTION.TEST, waitForCollectionCallback: true, callback: collectionCallback, @@ -148,7 +151,7 @@ describe('Set data while storage is clearing', () => { // When onyx is cleared .then(() => Onyx.clear()) .then(() => { - Onyx.disconnect(testConnectionID); + Onyx.disconnect(testConnection); }) .then(() => { // Then the collection callback should only have been called three times: @@ -159,13 +162,17 @@ describe('Set data while storage is clearing', () => { // And it should be called with the expected parameters each time expect(collectionCallback).toHaveBeenNthCalledWith(1, undefined, undefined); - expect(collectionCallback).toHaveBeenNthCalledWith(2, { - test_1: 1, - test_2: 2, - test_3: 3, - test_4: 4, - }); - expect(collectionCallback).toHaveBeenLastCalledWith({}); + expect(collectionCallback).toHaveBeenNthCalledWith( + 2, + { + test_1: 1, + test_2: 2, + test_3: 3, + test_4: 4, + }, + ONYX_KEYS.COLLECTION.TEST, + ); + expect(collectionCallback).toHaveBeenLastCalledWith({}, ONYX_KEYS.COLLECTION.TEST); }) ); }); diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index cd53d53f..08e3600b 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -5,6 +5,7 @@ import OnyxUtils from '../../lib/OnyxUtils'; import type OnyxCache from '../../lib/OnyxCache'; import type {OnyxCollection, OnyxUpdate} from '../../lib/types'; import type GenericCollection from '../utils/GenericCollection'; +import type {Connection} from '../../lib/OnyxConnectionManager'; const ONYX_KEYS = { TEST_KEY: 'test', @@ -32,7 +33,7 @@ Onyx.init({ }); describe('Onyx', () => { - let connectionID: number; + let connection: Connection | undefined; /** @type OnyxCache */ let cache: typeof OnyxCache; @@ -43,7 +44,9 @@ describe('Onyx', () => { }); afterEach(() => { - Onyx.disconnect(connectionID); + if (connection) { + Onyx.disconnect(connection); + } return Onyx.clear(); }); @@ -74,7 +77,7 @@ describe('Onyx', () => { it('should set a simple key', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -91,7 +94,7 @@ describe('Onyx', () => { it('should not set the key if the value is incompatible (array vs object)', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -112,7 +115,7 @@ describe('Onyx', () => { it("shouldn't call a connection twice when setting a value", () => { const mockCallback = jest.fn(); - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, callback: mockCallback, // True is the default, just setting it here to be explicit @@ -127,7 +130,7 @@ describe('Onyx', () => { it('should merge an object with another object', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -148,7 +151,7 @@ describe('Onyx', () => { it('should not merge if the value is incompatible (array vs object)', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -168,7 +171,7 @@ describe('Onyx', () => { it('should notify subscribers when data has been cleared', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -177,7 +180,7 @@ describe('Onyx', () => { }); const mockCallback = jest.fn(); - const otherTestConnectionID = Onyx.connect({ + const otherTestConnection = Onyx.connect({ key: ONYX_KEYS.OTHER_TEST, callback: mockCallback, }); @@ -201,13 +204,13 @@ describe('Onyx', () => { // Expect that the connection to a key with a default value that wasn't changed is not called on clear expect(mockCallback).toHaveBeenCalledTimes(0); - return Onyx.disconnect(otherTestConnectionID); + return Onyx.disconnect(otherTestConnection); }); }); it('should notify key subscribers that use a underscore in their name', () => { const mockCallback = jest.fn(); - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.KEY_WITH_UNDERSCORE, callback: mockCallback, }); @@ -236,7 +239,7 @@ describe('Onyx', () => { it('should not notify subscribers after they have disconnected', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -247,7 +250,9 @@ describe('Onyx', () => { return Onyx.set(ONYX_KEYS.TEST_KEY, 'test') .then(() => { expect(testKeyValue).toBe('test'); - Onyx.disconnect(connectionID); + if (connection) { + Onyx.disconnect(connection); + } return Onyx.set(ONYX_KEYS.TEST_KEY, 'test updated'); }) .then(() => { @@ -258,7 +263,7 @@ describe('Onyx', () => { it('should merge arrays by replacing previous value with new value', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -279,7 +284,7 @@ describe('Onyx', () => { it('should merge 2 objects when it has no initial stored value for test key', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -295,7 +300,7 @@ describe('Onyx', () => { it('should merge 2 arrays when it has no initial stored value for test key', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -312,7 +317,7 @@ describe('Onyx', () => { it('should remove keys that are set to null when merging', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -373,7 +378,7 @@ describe('Onyx', () => { it('should ignore top-level and remove nested `undefined` values in Onyx.set', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -421,7 +426,7 @@ describe('Onyx', () => { it('should ignore top-level and remove nested `undefined` values in Onyx.multiSet', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -430,7 +435,7 @@ describe('Onyx', () => { }); let otherTestKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.OTHER_TEST, initWithStoredValues: false, callback: (value) => { @@ -480,7 +485,7 @@ describe('Onyx', () => { it('should ignore top-level and remove nested `undefined` values in Onyx.merge', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -527,7 +532,7 @@ describe('Onyx', () => { const holidayRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}holiday`; const workRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}work`; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.COLLECTION.TEST_KEY, initWithStoredValues: false, callback: (value) => (result = value), @@ -562,7 +567,7 @@ describe('Onyx', () => { it('should overwrite an array key nested inside an object', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -578,7 +583,7 @@ describe('Onyx', () => { it('should overwrite an array key nested inside an object when using merge on a collection', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.COLLECTION.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -595,7 +600,7 @@ describe('Onyx', () => { it('should properly set and merge when using mergeCollection', () => { const valuesReceived: Record = {}; const mockCallback = jest.fn((data) => (valuesReceived[data.ID] = data.value)); - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.COLLECTION.TEST_KEY, initWithStoredValues: false, callback: mockCallback, @@ -659,7 +664,7 @@ describe('Onyx', () => { it('should skip the update when a key not belonging to collection key is present in mergeCollection', () => { const valuesReceived: Record = {}; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.COLLECTION.TEST_KEY, initWithStoredValues: false, callback: (data, key) => (valuesReceived[key] = data), @@ -672,7 +677,7 @@ describe('Onyx', () => { it('should return full object to callback when calling mergeCollection()', () => { const valuesReceived: Record = {}; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.COLLECTION.TEST_KEY, initWithStoredValues: false, callback: (data, key) => (valuesReceived[key] = data), @@ -716,7 +721,7 @@ describe('Onyx', () => { it('should use update data object to set/merge keys', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -725,7 +730,7 @@ describe('Onyx', () => { }); let otherTestKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.OTHER_TEST, initWithStoredValues: false, callback: (value) => { @@ -768,7 +773,7 @@ describe('Onyx', () => { it('should use update data object to merge a collection of keys', () => { const valuesReceived: Record = {}; const mockCallback = jest.fn((data) => (valuesReceived[data.ID] = data.value)); - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.COLLECTION.TEST_KEY, initWithStoredValues: false, callback: mockCallback, @@ -880,7 +885,7 @@ describe('Onyx', () => { it('should properly set all keys provided in a multiSet called via update', () => { const valuesReceived: Record = {}; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.COLLECTION.TEST_KEY, initWithStoredValues: false, callback: (data, key) => (valuesReceived[key] = data), @@ -973,7 +978,7 @@ describe('Onyx', () => { return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_CONNECT_COLLECTION, initialCollectionData as GenericCollection) .then(() => { // When we connect to that collection with waitForCollectionCallback = true - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.COLLECTION.TEST_CONNECT_COLLECTION, waitForCollectionCallback: true, callback: mockCallback, @@ -995,7 +1000,7 @@ describe('Onyx', () => { }; // Given an Onyx.connect call with waitForCollectionCallback=true - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.COLLECTION.TEST_POLICY, waitForCollectionCallback: true, callback: mockCallback, @@ -1012,7 +1017,7 @@ describe('Onyx', () => { expect(mockCallback).toHaveBeenNthCalledWith(1, undefined, undefined); // AND the value for the second call should be collectionUpdate since the collection was updated - expect(mockCallback).toHaveBeenNthCalledWith(2, collectionUpdate); + expect(mockCallback).toHaveBeenNthCalledWith(2, collectionUpdate, ONYX_KEYS.COLLECTION.TEST_POLICY); }) ); }); @@ -1025,7 +1030,7 @@ describe('Onyx', () => { }; // Given an Onyx.connect call with waitForCollectionCallback=false - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: `${ONYX_KEYS.COLLECTION.TEST_POLICY}${1}`, callback: mockCallback, }); @@ -1053,7 +1058,7 @@ describe('Onyx', () => { }; // Given an Onyx.connect call with waitForCollectionCallback=true - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.COLLECTION.TEST_POLICY, waitForCollectionCallback: true, callback: mockCallback, @@ -1067,7 +1072,7 @@ describe('Onyx', () => { expect(mockCallback).toHaveBeenCalledTimes(2); // AND the value for the second call should be collectionUpdate - expect(mockCallback).toHaveBeenLastCalledWith(collectionUpdate); + expect(mockCallback).toHaveBeenLastCalledWith(collectionUpdate, ONYX_KEYS.COLLECTION.TEST_POLICY); }) ); }); @@ -1088,7 +1093,7 @@ describe('Onyx', () => { }; // Given an Onyx.connect call with waitForCollectionCallback=true - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.COLLECTION.TEST_POLICY, waitForCollectionCallback: true, callback: mockCallback, @@ -1102,7 +1107,7 @@ describe('Onyx', () => { expect(mockCallback).toHaveBeenCalledTimes(2); // And the value for the second call should be collectionUpdate - expect(mockCallback).toHaveBeenNthCalledWith(2, collectionUpdate); + expect(mockCallback).toHaveBeenNthCalledWith(2, collectionUpdate, ONYX_KEYS.COLLECTION.TEST_POLICY); }) // When merge is called again with the same collection not modified @@ -1128,7 +1133,7 @@ describe('Onyx', () => { }; // Given an Onyx.connect call with waitForCollectionCallback=true - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.COLLECTION.TEST_POLICY, waitForCollectionCallback: true, callback: mockCallback, @@ -1143,7 +1148,7 @@ describe('Onyx', () => { expect(mockCallback).toHaveBeenCalledTimes(1); // And the value for the second call should be collectionUpdate - expect(mockCallback).toHaveBeenNthCalledWith(1, collectionUpdate); + expect(mockCallback).toHaveBeenNthCalledWith(1, collectionUpdate, ONYX_KEYS.COLLECTION.TEST_POLICY); }) // When merge is called again with the same collection not modified @@ -1163,16 +1168,16 @@ describe('Onyx', () => { }); it('should return a promise that completes when all update() operations are done', () => { - const connectionIDs: number[] = []; + const connections: Connection[] = []; const testCallback = jest.fn(); const otherTestCallback = jest.fn(); const collectionCallback = jest.fn(); const itemKey = `${ONYX_KEYS.COLLECTION.TEST_UPDATE}1`; - connectionIDs.push(Onyx.connect({key: ONYX_KEYS.TEST_KEY, callback: testCallback})); - connectionIDs.push(Onyx.connect({key: ONYX_KEYS.OTHER_TEST, callback: otherTestCallback})); - connectionIDs.push(Onyx.connect({key: ONYX_KEYS.COLLECTION.TEST_UPDATE, callback: collectionCallback, waitForCollectionCallback: true})); + connections.push(Onyx.connect({key: ONYX_KEYS.TEST_KEY, callback: testCallback})); + connections.push(Onyx.connect({key: ONYX_KEYS.OTHER_TEST, callback: otherTestCallback})); + connections.push(Onyx.connect({key: ONYX_KEYS.COLLECTION.TEST_UPDATE, callback: collectionCallback, waitForCollectionCallback: true})); return waitForPromisesToResolve().then(() => Onyx.update([ {onyxMethod: Onyx.METHOD.SET, key: ONYX_KEYS.TEST_KEY, value: 'taco'}, @@ -1181,7 +1186,7 @@ describe('Onyx', () => { ]).then(() => { expect(collectionCallback).toHaveBeenCalledTimes(2); expect(collectionCallback).toHaveBeenNthCalledWith(1, undefined, undefined); - expect(collectionCallback).toHaveBeenNthCalledWith(2, {[itemKey]: {a: 'a'}}); + expect(collectionCallback).toHaveBeenNthCalledWith(2, {[itemKey]: {a: 'a'}}, ONYX_KEYS.COLLECTION.TEST_UPDATE); expect(testCallback).toHaveBeenCalledTimes(2); expect(testCallback).toHaveBeenNthCalledWith(1, undefined, undefined); @@ -1191,7 +1196,7 @@ describe('Onyx', () => { // We set an initial value of 42 for ONYX_KEYS.OTHER_TEST in Onyx.init() expect(otherTestCallback).toHaveBeenNthCalledWith(1, 42, ONYX_KEYS.OTHER_TEST); expect(otherTestCallback).toHaveBeenNthCalledWith(2, 'pizza', ONYX_KEYS.OTHER_TEST); - connectionIDs.forEach((id) => Onyx.disconnect(id)); + connections.forEach((id) => Onyx.disconnect(id)); }), ); }); @@ -1199,7 +1204,7 @@ describe('Onyx', () => { it('should merge an object with a batch of objects and undefined', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -1230,7 +1235,7 @@ describe('Onyx', () => { it('should merge an object with null and overwrite the value', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -1257,7 +1262,7 @@ describe('Onyx', () => { it('should merge a key with null and allow subsequent updates', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -1283,7 +1288,7 @@ describe('Onyx', () => { it("should not set null values in Onyx.merge, when the key doesn't exist yet", () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -1309,7 +1314,7 @@ describe('Onyx', () => { it('should apply updates in order with Onyx.update', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -1345,7 +1350,7 @@ describe('Onyx', () => { const routineRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}routine`; const holidayRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}holiday`; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.COLLECTION.TEST_KEY, initWithStoredValues: false, callback: (value) => (result = value), @@ -1387,7 +1392,7 @@ describe('Onyx', () => { }); it('should not call a collection item subscriber if the value did not change', () => { - const connectionIDs: number[] = []; + const connections: Connection[] = []; const cat = `${ONYX_KEYS.COLLECTION.ANIMALS}cat`; const dog = `${ONYX_KEYS.COLLECTION.ANIMALS}dog`; @@ -1396,15 +1401,15 @@ describe('Onyx', () => { const catCallback = jest.fn(); const dogCallback = jest.fn(); - connectionIDs.push( + connections.push( Onyx.connect({ key: ONYX_KEYS.COLLECTION.ANIMALS, callback: collectionCallback, waitForCollectionCallback: true, }), ); - connectionIDs.push(Onyx.connect({key: cat, callback: catCallback})); - connectionIDs.push(Onyx.connect({key: dog, callback: dogCallback})); + connections.push(Onyx.connect({key: cat, callback: catCallback})); + connections.push(Onyx.connect({key: dog, callback: dogCallback})); const initialValue = {name: 'Fluffy'}; @@ -1420,7 +1425,7 @@ describe('Onyx', () => { }) .then(() => { expect(collectionCallback).toHaveBeenCalledTimes(3); - expect(collectionCallback).toHaveBeenCalledWith(collectionDiff); + expect(collectionCallback).toHaveBeenCalledWith(collectionDiff, ONYX_KEYS.COLLECTION.ANIMALS); // Cat hasn't changed from its original value, expect only the initial connect callback expect(catCallback).toHaveBeenCalledTimes(1); @@ -1428,7 +1433,7 @@ describe('Onyx', () => { // Dog was modified, expect the initial connect callback and the mergeCollection callback expect(dogCallback).toHaveBeenCalledTimes(2); - connectionIDs.map((id) => Onyx.disconnect(id)); + connections.map((id) => Onyx.disconnect(id)); }); }); @@ -1458,13 +1463,13 @@ describe('Onyx', () => { describe('update', () => { it('should squash all updates of collection-related keys into a single mergeCollection call', () => { - const connectionIDs: number[] = []; + const connections: Connection[] = []; const routineRoute = `${ONYX_KEYS.COLLECTION.ROUTES}routine`; const holidayRoute = `${ONYX_KEYS.COLLECTION.ROUTES}holiday`; const routesCollectionCallback = jest.fn(); - connectionIDs.push( + connections.push( Onyx.connect({ key: ONYX_KEYS.COLLECTION.ROUTES, callback: routesCollectionCallback, @@ -1530,32 +1535,36 @@ describe('Onyx', () => { }, }, ]).then(() => { - expect(routesCollectionCallback).toHaveBeenNthCalledWith(1, { - [holidayRoute]: { - waypoints: { - 0: 'Bed', - 1: 'Home', - 2: 'Beach', - 3: 'Restaurant', - 4: 'Home', + expect(routesCollectionCallback).toHaveBeenNthCalledWith( + 1, + { + [holidayRoute]: { + waypoints: { + 0: 'Bed', + 1: 'Home', + 2: 'Beach', + 3: 'Restaurant', + 4: 'Home', + }, }, - }, - [routineRoute]: { - waypoints: { - 0: 'Bed', - 1: 'Home', - 2: 'Work', - 3: 'Gym', + [routineRoute]: { + waypoints: { + 0: 'Bed', + 1: 'Home', + 2: 'Work', + 3: 'Gym', + }, }, }, - }); + ONYX_KEYS.COLLECTION.ROUTES, + ); - connectionIDs.map((id) => Onyx.disconnect(id)); + connections.map((id) => Onyx.disconnect(id)); }); }); it('should return a promise that completes when all update() operations are done', () => { - const connectionIDs: number[] = []; + const connections: Connection[] = []; const bob = `${ONYX_KEYS.COLLECTION.PEOPLE}bob`; const lisa = `${ONYX_KEYS.COLLECTION.PEOPLE}lisa`; @@ -1569,23 +1578,23 @@ describe('Onyx', () => { const animalsCollectionCallback = jest.fn(); const catCallback = jest.fn(); - connectionIDs.push(Onyx.connect({key: ONYX_KEYS.TEST_KEY, callback: testCallback})); - connectionIDs.push(Onyx.connect({key: ONYX_KEYS.OTHER_TEST, callback: otherTestCallback})); - connectionIDs.push( + connections.push(Onyx.connect({key: ONYX_KEYS.TEST_KEY, callback: testCallback})); + connections.push(Onyx.connect({key: ONYX_KEYS.OTHER_TEST, callback: otherTestCallback})); + connections.push( Onyx.connect({ key: ONYX_KEYS.COLLECTION.ANIMALS, callback: animalsCollectionCallback, waitForCollectionCallback: true, }), ); - connectionIDs.push( + connections.push( Onyx.connect({ key: ONYX_KEYS.COLLECTION.PEOPLE, callback: peopleCollectionCallback, waitForCollectionCallback: true, }), ); - connectionIDs.push(Onyx.connect({key: cat, callback: catCallback})); + connections.push(Onyx.connect({key: cat, callback: catCallback})); return Onyx.update([ {onyxMethod: Onyx.METHOD.MERGE, key: ONYX_KEYS.TEST_KEY, value: 'none'}, @@ -1612,29 +1621,41 @@ describe('Onyx', () => { expect(otherTestCallback).toHaveBeenNthCalledWith(1, {food: 'pizza', drink: 'water'}, ONYX_KEYS.OTHER_TEST); - expect(animalsCollectionCallback).toHaveBeenNthCalledWith(1, { - [cat]: {age: 3, sound: 'meow'}, - }); - expect(animalsCollectionCallback).toHaveBeenNthCalledWith(2, { - [cat]: {age: 3, sound: 'meow'}, - [dog]: {size: 'M', sound: 'woof'}, - }); + expect(animalsCollectionCallback).toHaveBeenNthCalledWith( + 1, + { + [cat]: {age: 3, sound: 'meow'}, + }, + ONYX_KEYS.COLLECTION.ANIMALS, + ); + expect(animalsCollectionCallback).toHaveBeenNthCalledWith( + 2, + { + [cat]: {age: 3, sound: 'meow'}, + [dog]: {size: 'M', sound: 'woof'}, + }, + ONYX_KEYS.COLLECTION.ANIMALS, + ); expect(catCallback).toHaveBeenNthCalledWith(1, {age: 3, sound: 'meow'}, cat); - expect(peopleCollectionCallback).toHaveBeenNthCalledWith(1, { - [bob]: {age: 25, car: 'sedan'}, - [lisa]: {age: 21, car: 'SUV'}, - }); + expect(peopleCollectionCallback).toHaveBeenNthCalledWith( + 1, + { + [bob]: {age: 25, car: 'sedan'}, + [lisa]: {age: 21, car: 'SUV'}, + }, + ONYX_KEYS.COLLECTION.PEOPLE, + ); - connectionIDs.map((id) => Onyx.disconnect(id)); + connections.map((id) => Onyx.disconnect(id)); }); }); it('should apply updates in the correct order with Onyx.update', () => { let testKeyValue: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => { @@ -1668,7 +1689,7 @@ describe('Onyx', () => { it('should remove a deeply nested null when merging an existing key', () => { let result: unknown; - connectionID = Onyx.connect({ + connection = Onyx.connect({ key: ONYX_KEYS.TEST_KEY, initWithStoredValues: false, callback: (value) => (result = value), diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index 256c7e11..19388f36 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -1,9 +1,10 @@ import {act, renderHook} from '@testing-library/react-native'; import type {OnyxEntry} from '../../lib'; import Onyx, {useOnyx} from '../../lib'; -import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; +import OnyxUtils from '../../lib/OnyxUtils'; import StorageMock from '../../lib/storage'; import type GenericCollection from '../utils/GenericCollection'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; const ONYXKEYS = { TEST_KEY: 'test', @@ -12,10 +13,13 @@ const ONYXKEYS = { TEST_KEY: 'test_', TEST_KEY_2: 'test2_', }, + EVICTABLE_TEST_KEY: 'evictable_test', + EVICTABLE_TEST_KEY2: 'evictable_test2', }; Onyx.init({ keys: ONYXKEYS, + safeEvictionKeys: [ONYXKEYS.EVICTABLE_TEST_KEY, ONYXKEYS.EVICTABLE_TEST_KEY2], }); beforeEach(() => Onyx.clear()); @@ -503,4 +507,120 @@ describe('useOnyx', () => { expect(result.current[1].status).toEqual('loaded'); }); }); + + describe('multiple usage', () => { + it('should connect to a key and load the value into cache, and return the value loaded in the next hook call', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + + const {result: result1} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY)); + + expect(result1.current[0]).toBeUndefined(); + expect(result1.current[1].status).toEqual('loading'); + + await act(async () => waitForPromisesToResolve()); + + expect(result1.current[0]).toEqual('test'); + expect(result1.current[1].status).toEqual('loaded'); + + const {result: result2} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY)); + + expect(result2.current[0]).toEqual('test'); + expect(result2.current[1].status).toEqual('loaded'); + }); + + it('should connect to a key two times while data is loading from the cache, and return the value loaded to both of them', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + + const {result: result1} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY)); + const {result: result2} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY)); + + expect(result1.current[0]).toBeUndefined(); + expect(result1.current[1].status).toEqual('loading'); + + expect(result2.current[0]).toBeUndefined(); + expect(result2.current[1].status).toEqual('loading'); + + await act(async () => waitForPromisesToResolve()); + + expect(result1.current[0]).toEqual('test'); + expect(result1.current[1].status).toEqual('loaded'); + + expect(result2.current[0]).toEqual('test'); + expect(result2.current[1].status).toEqual('loaded'); + }); + }); + + // This test suite must be the last one to avoid problems when running the other tests here. + describe('canEvict', () => { + const error = (key: string) => `canEvict can't be used on key '${key}'. This key must explicitly be flagged as safe for removal by adding it to Onyx.init({safeEvictionKeys: []}).`; + + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(jest.fn); + }); + + afterEach(() => { + (console.error as unknown as jest.SpyInstance>).mockRestore(); + }); + + it('should throw an error when trying to set the "canEvict" property for a non-evictable key', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'test'); + + try { + renderHook(() => useOnyx(ONYXKEYS.TEST_KEY, {canEvict: false})); + + await act(async () => waitForPromisesToResolve()); + + fail('Expected to throw an error.'); + } catch (e) { + expect((e as Error).message).toBe(error(ONYXKEYS.TEST_KEY)); + } + }); + + it('should add the connection to the blocklist when setting "canEvict" to false', async () => { + await StorageMock.setItem(ONYXKEYS.EVICTABLE_TEST_KEY, 'test'); + + renderHook(() => useOnyx(ONYXKEYS.EVICTABLE_TEST_KEY, {canEvict: false})); + + await act(async () => waitForPromisesToResolve()); + + const evictionBlocklist = OnyxUtils.getEvictionBlocklist(); + expect(evictionBlocklist[ONYXKEYS.EVICTABLE_TEST_KEY]).toHaveLength(1); + }); + + it('should handle removal/adding the connection to the blocklist properly when changing the evictable key to another', async () => { + await StorageMock.setItem(ONYXKEYS.EVICTABLE_TEST_KEY, 'test'); + + const {rerender} = renderHook((key: string) => useOnyx(key, {canEvict: false}), {initialProps: ONYXKEYS.EVICTABLE_TEST_KEY as string}); + + await act(async () => waitForPromisesToResolve()); + + const evictionBlocklist = OnyxUtils.getEvictionBlocklist(); + expect(evictionBlocklist[ONYXKEYS.EVICTABLE_TEST_KEY]).toHaveLength(1); + expect(evictionBlocklist[ONYXKEYS.EVICTABLE_TEST_KEY2]).toBeUndefined(); + + await act(async () => { + rerender(ONYXKEYS.EVICTABLE_TEST_KEY2); + }); + + expect(evictionBlocklist[ONYXKEYS.EVICTABLE_TEST_KEY]).toBeUndefined(); + expect(evictionBlocklist[ONYXKEYS.EVICTABLE_TEST_KEY2]).toHaveLength(1); + }); + + it('should remove the connection from the blocklist when setting "canEvict" to true', async () => { + await StorageMock.setItem(ONYXKEYS.EVICTABLE_TEST_KEY, 'test'); + + const {rerender} = renderHook((canEvict: boolean) => useOnyx(ONYXKEYS.EVICTABLE_TEST_KEY, {canEvict}), {initialProps: false as boolean}); + + await act(async () => waitForPromisesToResolve()); + + const evictionBlocklist = OnyxUtils.getEvictionBlocklist(); + expect(evictionBlocklist[ONYXKEYS.EVICTABLE_TEST_KEY]).toHaveLength(1); + + await act(async () => { + rerender(true); + }); + + expect(evictionBlocklist[ONYXKEYS.EVICTABLE_TEST_KEY]).toBeUndefined(); + }); + }); });