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:
+
+getCollectionKey("report_123")
would return "report_"
+getCollectionKey("report")
would return "report"
+getCollectionKey("report_")
would return "report_"
+
+
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