react-native-onyx
Version:
State management for React Native
1,015 lines (1,014 loc) • 61.8 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
/* eslint-disable no-continue */
const fast_equals_1 = require("fast-equals");
const clone_1 = __importDefault(require("lodash/clone"));
const DevTools_1 = __importDefault(require("./DevTools"));
const Logger = __importStar(require("./Logger"));
const OnyxCache_1 = __importStar(require("./OnyxCache"));
const PerformanceUtils = __importStar(require("./PerformanceUtils"));
const Str = __importStar(require("./Str"));
const batch_1 = __importDefault(require("./batch"));
const storage_1 = __importDefault(require("./storage"));
const utils_1 = __importDefault(require("./utils"));
const createDeferredTask_1 = __importDefault(require("./createDeferredTask"));
const GlobalSettings = __importStar(require("./GlobalSettings"));
const metrics_1 = __importDefault(require("./metrics"));
// Method constants
const METHOD = {
SET: 'set',
MERGE: 'merge',
MERGE_COLLECTION: 'mergecollection',
SET_COLLECTION: 'setcollection',
MULTI_SET: 'multiset',
CLEAR: 'clear',
};
// Key/value store of Onyx key and arrays of values to merge
const mergeQueue = {};
const mergeQueuePromise = {};
// Holds a mapping of all the React components that want their state subscribed to a store key
const callbackToStateMapping = {};
// 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 subscriptionID for faster lookups
const onyxKeyToSubscriptionIDs = new Map();
// Optional user-provided key value states set when Onyx initializes or clears
let defaultKeyStates = {};
let batchUpdatesPromise = null;
let batchUpdatesQueue = [];
// Used for comparison with a new update to avoid invoking the Onyx.connect callback with the same data.
const lastConnectionCallbackData = new Map();
let snapshotKey = 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 = (0, createDeferredTask_1.default)();
// Holds a set of collection member IDs which updates will be ignored when using Onyx methods.
let skippableCollectionMemberIDs = new Set();
function getSnapshotKey() {
return snapshotKey;
}
/**
* Getter - returns the merge queue.
*/
function getMergeQueue() {
return mergeQueue;
}
/**
* Getter - returns the merge queue promise.
*/
function getMergeQueuePromise() {
return mergeQueuePromise;
}
/**
* Getter - returns the default key states.
*/
function getDefaultKeyStates() {
return defaultKeyStates;
}
/**
* Getter - returns the deffered init task.
*/
function getDeferredInitTask() {
return deferredInitTask;
}
/**
* Getter - returns the skippable collection member IDs.
*/
function getSkippableCollectionMemberIDs() {
return skippableCollectionMemberIDs;
}
/**
* Setter - sets the skippable collection member IDs.
*/
function setSkippableCollectionMemberIDs(ids) {
skippableCollectionMemberIDs = ids;
}
/**
* Sets the initial values for the Onyx store
*
* @param keys - `ONYXKEYS` constants object from Onyx.init()
* @param initialKeyStates - initial data to set when `init()` and `clear()` are called
* @param evictableKeys - This is an array of keys (individual or collection patterns) that when provided to Onyx are flagged as "safe" for removal.
*/
function initStoreValues(keys, initialKeyStates, evictableKeys) {
var _a;
// We need the value of the collection keys later for checking if a
// key is a collection. We store it in a map for faster lookup.
const collectionValues = Object.values((_a = keys.COLLECTION) !== null && _a !== void 0 ? _a : {});
onyxCollectionKeySet = collectionValues.reduce((acc, val) => {
acc.add(val);
return acc;
}, new Set());
// Set our default key states to use when initializing and clearing Onyx data
defaultKeyStates = initialKeyStates;
DevTools_1.default.initState(initialKeyStates);
// Let Onyx know about which keys are safe to evict
OnyxCache_1.default.setEvictionAllowList(evictableKeys);
if (typeof keys.COLLECTION === 'object' && typeof keys.COLLECTION.SNAPSHOT === 'string') {
snapshotKey = keys.COLLECTION.SNAPSHOT;
}
}
function sendActionToDevTools(method, key, value, mergedValue = undefined) {
DevTools_1.default.registerAction(utils_1.default.formatActionName(method, key), value, key ? { [key]: mergedValue || value } : value);
}
/**
* We are batching together onyx updates. This helps with use cases where we schedule onyx updates after each other.
* This happens for example in the Onyx.update function, where we process API responses that might contain a lot of
* update operations. Instead of calling the subscribers for each update operation, we batch them together which will
* cause react to schedule the updates at once instead of after each other. This is mainly a performance optimization.
*/
function maybeFlushBatchUpdates() {
if (batchUpdatesPromise) {
return batchUpdatesPromise;
}
batchUpdatesPromise = new Promise((resolve) => {
/* We use (setTimeout, 0) here which should be called once native module calls are flushed (usually at the end of the frame)
* We may investigate if (setTimeout, 1) (which in React Native is equal to requestAnimationFrame) works even better
* then the batch will be flushed on next frame.
*/
setTimeout(() => {
const updatesCopy = batchUpdatesQueue;
batchUpdatesQueue = [];
batchUpdatesPromise = null;
(0, batch_1.default)(() => {
updatesCopy.forEach((applyUpdates) => {
applyUpdates();
});
});
resolve();
}, 0);
});
return batchUpdatesPromise;
}
function batchUpdates(updates) {
batchUpdatesQueue.push(updates);
return maybeFlushBatchUpdates();
}
/**
* Takes a collection of items (eg. {testKey_1:{a:'a'}, testKey_2:{b:'b'}})
* and runs it through a reducer function to return a subset of the data according to a selector.
* The resulting collection will only contain items that are returned by the selector.
*/
function reduceCollectionWithSelector(collection, selector, withOnyxInstanceState) {
return Object.entries(collection !== null && collection !== void 0 ? collection : {}).reduce((finalCollection, [key, item]) => {
// eslint-disable-next-line no-param-reassign
finalCollection[key] = selector(item, withOnyxInstanceState);
return finalCollection;
}, {});
}
/** Get some data from the store */
function get(key) {
// When we already have the value in cache - resolve right away
if (OnyxCache_1.default.hasCacheForKey(key)) {
return Promise.resolve(OnyxCache_1.default.get(key));
}
const taskName = `${OnyxCache_1.TASK.GET}:${key}`;
// When a value retrieving task for this key is still running hook to it
if (OnyxCache_1.default.hasPendingTask(taskName)) {
return OnyxCache_1.default.getTaskPromise(taskName);
}
// Otherwise retrieve the value from storage and capture a promise to aid concurrent usages
const promise = storage_1.default.getItem(key)
.then((val) => {
if (skippableCollectionMemberIDs.size) {
try {
const [, collectionMemberID] = splitCollectionMemberKey(key);
if (skippableCollectionMemberIDs.has(collectionMemberID)) {
// The key is a skippable one, so we set the value to undefined.
// eslint-disable-next-line no-param-reassign
val = undefined;
}
}
catch (e) {
// The key is not a collection one or something went wrong during split, so we proceed with the function's logic.
}
}
if (val === undefined) {
OnyxCache_1.default.addNullishStorageKey(key);
return undefined;
}
OnyxCache_1.default.set(key, val);
return val;
})
.catch((err) => Logger.logInfo(`Unable to get item from persistent storage. Key: ${key} Error: ${err}`));
return OnyxCache_1.default.captureTask(taskName, promise);
}
// multiGet the data first from the cache and then from the storage for the missing keys.
function multiGet(keys) {
// Keys that are not in the cache
const missingKeys = [];
// Tasks that are pending
const pendingTasks = [];
// Keys for the tasks that are pending
const pendingKeys = [];
// Data to be sent back to the invoker
const dataMap = new Map();
/**
* We are going to iterate over all the matching keys and check if we have the data in the cache.
* If we do then we add it to the data object. If we do not have them, then we check if there is a pending task
* for the key. If there is such task, then we add the promise to the pendingTasks array and the key to the pendingKeys
* array. If there is no pending task then we add the key to the missingKeys array.
*
* These missingKeys will be later used to multiGet the data from the storage.
*/
keys.forEach((key) => {
const cacheValue = OnyxCache_1.default.get(key);
if (cacheValue) {
dataMap.set(key, cacheValue);
return;
}
const pendingKey = `${OnyxCache_1.TASK.GET}:${key}`;
if (OnyxCache_1.default.hasPendingTask(pendingKey)) {
pendingTasks.push(OnyxCache_1.default.getTaskPromise(pendingKey));
pendingKeys.push(key);
}
else {
missingKeys.push(key);
}
});
return (Promise.all(pendingTasks)
// Wait for all the pending tasks to resolve and then add the data to the data map.
.then((values) => {
values.forEach((value, index) => {
dataMap.set(pendingKeys[index], value);
});
return Promise.resolve();
})
// Get the missing keys using multiGet from the storage.
.then(() => {
if (missingKeys.length === 0) {
return Promise.resolve(undefined);
}
return storage_1.default.multiGet(missingKeys);
})
// Add the data from the missing keys to the data map and also merge it to the cache.
.then((values) => {
if (!values || values.length === 0) {
return dataMap;
}
// temp object is used to merge the missing data into the cache
const temp = {};
values.forEach(([key, value]) => {
if (skippableCollectionMemberIDs.size) {
try {
const [, collectionMemberID] = OnyxUtils.splitCollectionMemberKey(key);
if (skippableCollectionMemberIDs.has(collectionMemberID)) {
// The key is a skippable one, so we skip this iteration.
return;
}
}
catch (e) {
// The key is not a collection one or something went wrong during split, so we proceed with the function's logic.
}
}
dataMap.set(key, value);
temp[key] = value;
});
OnyxCache_1.default.merge(temp);
return dataMap;
}));
}
/**
* This helper exists to map an array of Onyx keys such as `['report_', 'conciergeReportID']`
* to the values for those keys (correctly typed) such as `[OnyxCollection<Report>, OnyxEntry<string>]`
*
* Note: just using `.map`, you'd end up with `Array<OnyxCollection<Report>|OnyxEntry<string>>`, which is not what we want. This preserves the order of the keys provided.
*/
function tupleGet(keys) {
return Promise.all(keys.map((key) => OnyxUtils.get(key)));
}
/**
* Stores a subscription ID associated with a given key.
*
* @param subscriptionID - A subscription ID of the subscriber.
* @param key - A key that the subscriber is subscribed to.
*/
function storeKeyBySubscriptions(key, subscriptionID) {
if (!onyxKeyToSubscriptionIDs.has(key)) {
onyxKeyToSubscriptionIDs.set(key, []);
}
onyxKeyToSubscriptionIDs.get(key).push(subscriptionID);
}
/**
* Deletes a subscription ID associated with its corresponding key.
*
* @param subscriptionID - The subscription ID to be deleted.
*/
function deleteKeyBySubscriptions(subscriptionID) {
const subscriber = callbackToStateMapping[subscriptionID];
if (subscriber && onyxKeyToSubscriptionIDs.has(subscriber.key)) {
const updatedSubscriptionsIDs = onyxKeyToSubscriptionIDs.get(subscriber.key).filter((id) => id !== subscriptionID);
onyxKeyToSubscriptionIDs.set(subscriber.key, updatedSubscriptionsIDs);
}
lastConnectionCallbackData.delete(subscriptionID);
}
/** Returns current key names stored in persisted storage */
function getAllKeys() {
// When we've already read stored keys, resolve right away
const cachedKeys = OnyxCache_1.default.getAllKeys();
if (cachedKeys.size > 0) {
return Promise.resolve(cachedKeys);
}
// When a value retrieving task for all keys is still running hook to it
if (OnyxCache_1.default.hasPendingTask(OnyxCache_1.TASK.GET_ALL_KEYS)) {
return OnyxCache_1.default.getTaskPromise(OnyxCache_1.TASK.GET_ALL_KEYS);
}
// Otherwise retrieve the keys from storage and capture a promise to aid concurrent usages
const promise = storage_1.default.getAllKeys().then((keys) => {
OnyxCache_1.default.setAllKeys(keys);
// return the updated set of keys
return OnyxCache_1.default.getAllKeys();
});
return OnyxCache_1.default.captureTask(OnyxCache_1.TASK.GET_ALL_KEYS, promise);
}
/**
* Returns set of all registered collection keys
*/
function getCollectionKeys() {
return onyxCollectionKeySet;
}
/**
* Checks to see if the subscriber's supplied key
* is associated with a collection of keys.
*/
function isCollectionKey(key) {
return onyxCollectionKeySet.has(key);
}
function isCollectionMemberKey(collectionKey, key, collectionKeyLength) {
return key.startsWith(collectionKey) && key.length > collectionKeyLength;
}
/**
* Splits a collection member key into the collection key part and the ID part.
* @param key - The collection member key to split.
* @param collectionKey - The collection key of the `key` param that can be passed in advance to optimize the function.
* @returns A tuple where the first element is the collection part and the second element is the ID part,
* or throws an Error if the key is not a collection one.
*/
function splitCollectionMemberKey(key, collectionKey) {
if (collectionKey && !isCollectionMemberKey(collectionKey, key, collectionKey.length)) {
throw new Error(`Invalid '${collectionKey}' collection key provided, it isn't compatible with '${key}' key.`);
}
if (!collectionKey) {
// eslint-disable-next-line no-param-reassign
collectionKey = getCollectionKey(key);
}
return [collectionKey, key.slice(collectionKey.length)];
}
/**
* Checks to see if a provided key is the exact configured key of our connected subscriber
* or if the provided key is a collection member key (in case our configured key is a "collection key")
*/
function isKeyMatch(configKey, key) {
return isCollectionKey(configKey) ? Str.startsWith(key, configKey) : configKey === key;
}
/**
* Extracts the collection identifier of a given collection member key.
*
* For example:
* - `getCollectionKey("report_123")` would return "report_"
* - `getCollectionKey("report_")` would return "report_"
* - `getCollectionKey("report_-1_something")` would return "report_"
* - `getCollectionKey("sharedNVP_user_-1_something")` would return "sharedNVP_user_"
*
* @param key - The collection key to process.
* @returns The plain collection key or throws an Error if the key is not a collection one.
*/
function getCollectionKey(key) {
// Start by finding the position of the last underscore in the string
let lastUnderscoreIndex = key.lastIndexOf('_');
// Iterate backwards to find the longest key that ends with '_'
while (lastUnderscoreIndex > 0) {
const possibleKey = key.slice(0, lastUnderscoreIndex + 1);
// Check if the substring is a key in the Set
if (isCollectionKey(possibleKey)) {
// Return the matching key and the rest of the string
return possibleKey;
}
// Move to the next underscore to check smaller possible keys
lastUnderscoreIndex = key.lastIndexOf('_', lastUnderscoreIndex - 1);
}
throw new Error(`Invalid '${key}' key provided, only collection keys are allowed.`);
}
/**
* 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, mapping) {
let val = OnyxCache_1.default.get(key);
if (isCollectionKey(key)) {
const allCacheKeys = OnyxCache_1.default.getAllKeys();
// It is possible we haven't loaded all keys yet so we do not know if the
// collection actually exists.
if (allCacheKeys.size === 0) {
return;
}
const values = {};
allCacheKeys.forEach((cacheKey) => {
if (!cacheKey.startsWith(key)) {
return;
}
values[cacheKey] = OnyxCache_1.default.get(cacheKey);
});
val = values;
}
if (mapping === null || mapping === void 0 ? void 0 : mapping.selector) {
const state = mapping.withOnyxInstance ? mapping.withOnyxInstance.state : undefined;
if (isCollectionKey(key)) {
return reduceCollectionWithSelector(val, mapping.selector, state);
}
return mapping.selector(val, state);
}
return val;
}
function getCachedCollection(collectionKey, collectionMemberKeys) {
const allKeys = collectionMemberKeys || OnyxCache_1.default.getAllKeys();
const collection = {};
const collectionKeyLength = collectionKey.length;
// forEach exists on both Set and Array
allKeys.forEach((key) => {
// If we don't have collectionMemberKeys array then we have to check whether a key is a collection member key.
// Because in that case the keys will be coming from `cache.getAllKeys()` and we need to filter out the keys that
// are not part of the collection.
if (!collectionMemberKeys && !isCollectionMemberKey(collectionKey, key, collectionKeyLength)) {
return;
}
const cachedValue = OnyxCache_1.default.get(key);
if (cachedValue === undefined && !OnyxCache_1.default.hasNullishStorageKey(key)) {
return;
}
collection[key] = OnyxCache_1.default.get(key);
});
return collection;
}
/**
* When a collection of keys change, search for any callbacks matching the collection key and trigger those callbacks
*/
function keysChanged(collectionKey, partialCollection, partialPreviousCollection, notifyConnectSubscribers = true, notifyWithOnyxSubscribers = true) {
// We prepare the "cached collection" which is the entire collection + the new partial data that
// was merged in via mergeCollection().
const cachedCollection = getCachedCollection(collectionKey);
const previousCollection = partialPreviousCollection !== null && partialPreviousCollection !== void 0 ? partialPreviousCollection : {};
// We are iterating over all subscribers similar to keyChanged(). However, we are looking for subscribers who are subscribing to either a collection key or
// individual collection key member for the collection that is being updated. It is important to note that the collection parameter cane be a PARTIAL collection
// and does not represent all of the combined keys and values for a collection key. It is just the "new" data that was merged in via mergeCollection().
const stateMappingKeys = Object.keys(callbackToStateMapping);
const collectionKeyLength = collectionKey.length;
for (const stateMappingKey of stateMappingKeys) {
const subscriber = callbackToStateMapping[stateMappingKey];
if (!subscriber) {
continue;
}
// Skip iteration if we do not have a collection key or a collection member key on this subscriber
if (!Str.startsWith(subscriber.key, collectionKey)) {
continue;
}
/**
* e.g. Onyx.connect({key: ONYXKEYS.COLLECTION.REPORT, callback: ...});
*/
const isSubscribedToCollectionKey = subscriber.key === collectionKey;
/**
* e.g. Onyx.connect({key: `${ONYXKEYS.COLLECTION.REPORT}{reportID}`, callback: ...});
*/
const isSubscribedToCollectionMemberKey = isCollectionMemberKey(collectionKey, subscriber.key, collectionKeyLength);
// Regular Onyx.connect() subscriber found.
if (typeof subscriber.callback === 'function') {
if (!notifyConnectSubscribers) {
continue;
}
// If they are subscribed to the collection key and using waitForCollectionCallback then we'll
// send the whole cached collection.
if (isSubscribedToCollectionKey) {
if (subscriber.waitForCollectionCallback) {
subscriber.callback(cachedCollection, subscriber.key, partialCollection);
continue;
}
// If they are not using waitForCollectionCallback then we notify the subscriber with
// the new merged data but only for any keys in the partial collection.
const dataKeys = Object.keys(partialCollection !== null && partialCollection !== void 0 ? partialCollection : {});
for (const dataKey of dataKeys) {
if ((0, fast_equals_1.deepEqual)(cachedCollection[dataKey], previousCollection[dataKey])) {
continue;
}
subscriber.callback(cachedCollection[dataKey], dataKey);
}
continue;
}
// And if the subscriber is specifically only tracking a particular collection member key then we will
// notify them with the cached data for that key only.
if (isSubscribedToCollectionMemberKey) {
if ((0, fast_equals_1.deepEqual)(cachedCollection[subscriber.key], previousCollection[subscriber.key])) {
continue;
}
const subscriberCallback = subscriber.callback;
subscriberCallback(cachedCollection[subscriber.key], subscriber.key);
continue;
}
continue;
}
// React component subscriber found.
if (utils_1.default.hasWithOnyxInstance(subscriber)) {
if (!notifyWithOnyxSubscribers) {
continue;
}
// We are subscribed to a collection key so we must update the data in state with the new
// collection member key values from the partial update.
if (isSubscribedToCollectionKey) {
// If the subscriber has a selector, then the component's state must only be updated with the data
// returned by the selector.
const collectionSelector = subscriber.selector;
if (collectionSelector) {
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const previousData = prevState[subscriber.statePropertyName];
const newData = reduceCollectionWithSelector(cachedCollection, collectionSelector, subscriber.withOnyxInstance.state);
if ((0, fast_equals_1.deepEqual)(previousData, newData)) {
return null;
}
return {
[subscriber.statePropertyName]: newData,
};
});
continue;
}
subscriber.withOnyxInstance.setStateProxy((prevState) => {
var _a;
const prevCollection = (_a = prevState === null || prevState === void 0 ? void 0 : prevState[subscriber.statePropertyName]) !== null && _a !== void 0 ? _a : {};
const finalCollection = (0, clone_1.default)(prevCollection);
const dataKeys = Object.keys(partialCollection !== null && partialCollection !== void 0 ? partialCollection : {});
for (const dataKey of dataKeys) {
finalCollection[dataKey] = cachedCollection[dataKey];
}
if ((0, fast_equals_1.deepEqual)(prevCollection, finalCollection)) {
return null;
}
PerformanceUtils.logSetStateCall(subscriber, prevState === null || prevState === void 0 ? void 0 : prevState[subscriber.statePropertyName], finalCollection, 'keysChanged', collectionKey);
return {
[subscriber.statePropertyName]: finalCollection,
};
});
continue;
}
// If a React component is only interested in a single key then we can set the cached value directly to the state name.
if (isSubscribedToCollectionMemberKey) {
if ((0, fast_equals_1.deepEqual)(cachedCollection[subscriber.key], previousCollection[subscriber.key])) {
continue;
}
// However, we only want to update this subscriber if the partial data contains a change.
// Otherwise, we would update them with a value they already have and trigger an unnecessary re-render.
const dataFromCollection = partialCollection === null || partialCollection === void 0 ? void 0 : partialCollection[subscriber.key];
if (dataFromCollection === undefined) {
continue;
}
// If the subscriber has a selector, then the component's state must only be updated with the data
// returned by the selector and the state should only change when the subset of data changes from what
// it was previously.
const selector = subscriber.selector;
if (selector) {
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const prevData = prevState[subscriber.statePropertyName];
const newData = selector(cachedCollection[subscriber.key], subscriber.withOnyxInstance.state);
if ((0, fast_equals_1.deepEqual)(prevData, newData)) {
return null;
}
PerformanceUtils.logSetStateCall(subscriber, prevData, newData, 'keysChanged', collectionKey);
return {
[subscriber.statePropertyName]: newData,
};
});
continue;
}
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const prevData = prevState[subscriber.statePropertyName];
const newData = cachedCollection[subscriber.key];
// Avoids triggering unnecessary re-renders when feeding empty objects
if (utils_1.default.isEmptyObject(newData) && utils_1.default.isEmptyObject(prevData)) {
return null;
}
if ((0, fast_equals_1.deepEqual)(prevData, newData)) {
return null;
}
PerformanceUtils.logSetStateCall(subscriber, prevData, newData, 'keysChanged', collectionKey);
return {
[subscriber.statePropertyName]: newData,
};
});
}
}
}
}
/**
* When a key change happens, search for any callbacks matching the key or collection key and trigger those callbacks
*
* @example
* keyChanged(key, value, subscriber => subscriber.initWithStoredValues === false)
*/
function keyChanged(key, value, previousValue, canUpdateSubscriber = () => true, notifyConnectSubscribers = true, notifyWithOnyxSubscribers = true) {
var _a, _b;
// Add or remove this key from the recentlyAccessedKeys lists
if (value !== null) {
OnyxCache_1.default.addLastAccessedKey(key, isCollectionKey(key));
}
else {
OnyxCache_1.default.removeLastAccessedKey(key);
}
// We get the subscribers interested in the key that has just changed. If the subscriber's key is a collection key then we will
// notify them if the key that changed is a collection member. Or if it is a regular key notify them when there is an exact match. Depending on whether the subscriber
// was connected via withOnyx we will call setState() directly on the withOnyx instance. If it is a regular connection we will pass the data to the provided callback.
// 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 = (_a = onyxKeyToSubscriptionIDs.get(key)) !== null && _a !== void 0 ? _a : [];
let collectionKey;
try {
collectionKey = getCollectionKey(key);
}
catch (e) {
// If getCollectionKey() throws an error it means the key is not a collection key.
collectionKey = undefined;
}
if (collectionKey) {
// Getting the collection key from the specific key because only collection keys were stored in the mapping.
stateMappingKeys = [...stateMappingKeys, ...((_b = onyxKeyToSubscriptionIDs.get(collectionKey)) !== null && _b !== void 0 ? _b : [])];
if (stateMappingKeys.length === 0) {
return;
}
}
const cachedCollections = {};
for (const stateMappingKey of stateMappingKeys) {
const subscriber = callbackToStateMapping[stateMappingKey];
if (!subscriber || !isKeyMatch(subscriber.key, key) || !canUpdateSubscriber(subscriber)) {
continue;
}
// Subscriber is a regular call to connect() and provided a callback
if (typeof subscriber.callback === 'function') {
if (!notifyConnectSubscribers) {
continue;
}
if (lastConnectionCallbackData.has(subscriber.subscriptionID) && lastConnectionCallbackData.get(subscriber.subscriptionID) === value) {
continue;
}
if (isCollectionKey(subscriber.key) && subscriber.waitForCollectionCallback) {
let cachedCollection = cachedCollections[subscriber.key];
if (!cachedCollection) {
cachedCollection = getCachedCollection(subscriber.key);
cachedCollections[subscriber.key] = cachedCollection;
}
cachedCollection[key] = value;
subscriber.callback(cachedCollection, subscriber.key, { [key]: value });
continue;
}
const subscriberCallback = subscriber.callback;
subscriberCallback(value, key);
lastConnectionCallbackData.set(subscriber.subscriptionID, value);
continue;
}
// Subscriber connected via withOnyx() HOC
if (utils_1.default.hasWithOnyxInstance(subscriber)) {
if (!notifyWithOnyxSubscribers) {
continue;
}
const selector = subscriber.selector;
// Check if we are subscribing to a collection key and overwrite the collection member key value in state
if (isCollectionKey(subscriber.key)) {
// If the subscriber has a selector, then the consumer of this data must only be given the data
// returned by the selector and only when the selected data has changed.
if (selector) {
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const prevWithOnyxData = prevState[subscriber.statePropertyName];
const newWithOnyxData = {
[key]: selector(value, subscriber.withOnyxInstance.state),
};
const prevDataWithNewData = Object.assign(Object.assign({}, prevWithOnyxData), newWithOnyxData);
if ((0, fast_equals_1.deepEqual)(prevWithOnyxData, prevDataWithNewData)) {
return null;
}
PerformanceUtils.logSetStateCall(subscriber, prevWithOnyxData, newWithOnyxData, 'keyChanged', key);
return {
[subscriber.statePropertyName]: prevDataWithNewData,
};
});
continue;
}
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const prevCollection = prevState[subscriber.statePropertyName] || {};
const newCollection = Object.assign(Object.assign({}, prevCollection), { [key]: value });
if ((0, fast_equals_1.deepEqual)(prevCollection, newCollection)) {
return null;
}
PerformanceUtils.logSetStateCall(subscriber, prevCollection, newCollection, 'keyChanged', key);
return {
[subscriber.statePropertyName]: newCollection,
};
});
continue;
}
// If the subscriber has a selector, then the component's state must only be updated with the data
// returned by the selector and only if the selected data has changed.
if (selector) {
subscriber.withOnyxInstance.setStateProxy(() => {
const prevValue = selector(previousValue, subscriber.withOnyxInstance.state);
const newValue = selector(value, subscriber.withOnyxInstance.state);
if ((0, fast_equals_1.deepEqual)(prevValue, newValue)) {
return null;
}
return {
[subscriber.statePropertyName]: newValue,
};
});
continue;
}
// If we did not match on a collection key then we just set the new data to the state property
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const prevWithOnyxValue = prevState[subscriber.statePropertyName];
// Avoids triggering unnecessary re-renders when feeding empty objects
if (utils_1.default.isEmptyObject(value) && utils_1.default.isEmptyObject(prevWithOnyxValue)) {
return null;
}
if (prevWithOnyxValue === value) {
return null;
}
PerformanceUtils.logSetStateCall(subscriber, previousValue, value, 'keyChanged', key);
return {
[subscriber.statePropertyName]: value,
};
});
continue;
}
console.error('Warning: Found a matching subscriber to a key that changed, but no callback or withOnyxInstance could be found.');
}
}
/**
* Sends the data obtained from the keys to the connection. It either:
* - sets state on the withOnyxInstances
* - triggers the callback function
*/
function sendDataToConnection(mapping, value, matchedKey, isBatched) {
var _a, _b;
// 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.subscriptionID]) {
return;
}
if (utils_1.default.hasWithOnyxInstance(mapping)) {
let newData = value;
// If the mapping has a selector, then the component's state must only be updated with the data
// returned by the selector.
if (mapping.selector) {
if (isCollectionKey(mapping.key)) {
newData = reduceCollectionWithSelector(value, mapping.selector, mapping.withOnyxInstance.state);
}
else {
newData = mapping.selector(value, mapping.withOnyxInstance.state);
}
}
PerformanceUtils.logSetStateCall(mapping, null, newData, 'sendDataToConnection');
if (isBatched) {
batchUpdates(() => mapping.withOnyxInstance.setWithOnyxState(mapping.statePropertyName, newData));
}
else {
mapping.withOnyxInstance.setWithOnyxState(mapping.statePropertyName, newData);
}
return;
}
// When there are no matching keys in "Onyx.connect", we pass null to "sendDataToConnection" explicitly,
// to allow the withOnyx instance to set the value in the state initially and therefore stop the loading state once all
// required keys have been set.
// If we would pass undefined to setWithOnyxInstance instead, withOnyx would not set the value in the state.
// 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.subscriptionID);
lastConnectionCallbackData.get(mapping.subscriptionID);
// If the value has not changed we do not need to trigger the callback
if (lastConnectionCallbackData.has(mapping.subscriptionID) && valueToPass === lastValue) {
return;
}
(_b = (_a = mapping).callback) === null || _b === void 0 ? void 0 : _b.call(_a, valueToPass, matchedKey);
}
/**
* We check to see if this key is flagged as safe for eviction and add it to the recentlyAccessedKeys list so that when we
* run out of storage the least recently accessed key can be removed.
*/
function addKeyToRecentlyAccessedIfNeeded(mapping) {
if (!OnyxCache_1.default.isEvictableKey(mapping.key)) {
return;
}
// Add the key to recentKeys first (this makes it the most recent key)
OnyxCache_1.default.addToAccessedKeys(mapping.key);
// Try to free some cache whenever we connect to a safe eviction key
OnyxCache_1.default.removeLeastRecentlyUsedKeys();
if (utils_1.default.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.`);
}
OnyxCache_1.default.addLastAccessedKey(mapping.key, isCollectionKey(mapping.key));
}
}
/**
* Gets the data for a given an array of matching keys, combines them into an object, and sends the result back to the subscriber.
*/
function getCollectionDataAndSendAsObject(matchingKeys, mapping) {
multiGet(matchingKeys).then((dataMap) => {
const data = Object.fromEntries(dataMap.entries());
sendDataToConnection(mapping, data, undefined, true);
});
}
/**
* Schedules an update that will be appended to the macro task queue (so it doesn't update the subscribers immediately).
*
* @example
* scheduleSubscriberUpdate(key, value, subscriber => subscriber.initWithStoredValues === false)
*/
function scheduleSubscriberUpdate(key, value, previousValue, canUpdateSubscriber = () => true) {
const promise = Promise.resolve().then(() => keyChanged(key, value, previousValue, canUpdateSubscriber, true, false));
batchUpdates(() => keyChanged(key, value, previousValue, canUpdateSubscriber, false, true));
return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined);
}
/**
* This method is similar to notifySubscribersOnNextTick but it is built for working specifically with collections
* so that keysChanged() is triggered for the collection and not keyChanged(). If this was not done, then the
* subscriber callbacks receive the data in a different format than they normally expect and it breaks code.
*/
function scheduleNotifyCollectionSubscribers(key, value, previousValue) {
const promise = Promise.resolve().then(() => keysChanged(key, value, previousValue, true, false));
batchUpdates(() => keysChanged(key, value, previousValue, false, true));
return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined);
}
/**
* Remove a key from Onyx and update the subscribers
*/
function remove(key) {
const prevValue = OnyxCache_1.default.get(key, false);
OnyxCache_1.default.drop(key);
scheduleSubscriberUpdate(key, undefined, prevValue);
return storage_1.default.removeItem(key).then(() => undefined);
}
function reportStorageQuota() {
return storage_1.default.getDatabaseSize()
.then(({ bytesUsed, bytesRemaining }) => {
Logger.logInfo(`Storage Quota Check -- bytesUsed: ${bytesUsed} bytesRemaining: ${bytesRemaining}`);
})
.catch((dbSizeError) => {
Logger.logAlert(`Unable to get database size. Error: ${dbSizeError}`);
});
}
/**
* If we fail to set or merge we must handle this by
* evicting some data from Onyx and then retrying to do
* whatever it is we attempted to do.
*/
function evictStorageAndRetry(error, onyxMethod, ...args) {
Logger.logInfo(`Failed to save to storage. Error: ${error}. onyxMethod: ${onyxMethod.name}`);
if (error && Str.startsWith(error.message, "Failed to execute 'put' on 'IDBObjectStore'")) {
Logger.logAlert('Attempted to set invalid data set in Onyx. Please ensure all data is serializable.');
throw error;
}
// Find the first key that we can remove that has no subscribers in our blocklist
const keyForRemoval = OnyxCache_1.default.getKeyForEviction();
if (!keyForRemoval) {
// If we have no acceptable keys to remove then we are possibly trying to save mission critical data. If this is the case,
// then we should stop retrying as there is not much the user can do to fix this. Instead of getting them stuck in an infinite loop we
// will allow this write to be skipped.
Logger.logAlert('Out of storage. But found no acceptable keys to remove.');
return reportStorageQuota();
}
// Remove the least recently viewed key that is not currently being accessed and retry.
Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying.`);
reportStorageQuota();
// @ts-expect-error No overload matches this call.
return remove(keyForRemoval).then(() => onyxMethod(...args));
}
/**
* Notifies subscribers and writes current value to cache
*/
function broadcastUpdate(key, value, hasChanged) {
const prevValue = OnyxCache_1.default.get(key, false);
// Update subscribers if the cached value has changed, or when the subscriber specifically requires
// all updates regardless of value changes (indicated by initWithStoredValues set to false).
if (hasChanged) {
OnyxCache_1.default.set(key, value);
}
else {
OnyxCache_1.default.addToAccessedKeys(key);
}
return scheduleSubscriberUpdate(key, value, prevValue, (subscriber) => hasChanged || (subscriber === null || subscriber === void 0 ? void 0 : subscriber.initWithStoredValues) === false).then(() => undefined);
}
function hasPendingMergeForKey(key) {
return !!mergeQueue[key];
}
/**
* Removes a key from storage if the value is null.
* Otherwise removes all nested null values in objects,
* if shouldRemoveNestedNulls is true and returns the object.
*
* @returns The value without null values and a boolean "wasRemoved", which indicates if the key got removed completely
*/
function removeNullValues(key, value, shouldRemoveNestedNulls = true) {
if (value === null) {
remove(key);
return { value, wasRemoved: true };
}
if (value === undefined) {
return { value, wasRemoved: false };
}
// We can remove all null values in an object by merging it with itself
// utils.fastMerge recursively goes through the object and removes all null values
// Passing two identical objects as source and target to fastMerge will not change it, but only remove the null values
return { value: shouldRemoveNestedNulls ? utils_1.default.removeNestedNullValues(value) : value, wasRemoved: false };
}
/**
* Storage expects array like: [["@MyApp_user", value_1], ["@MyApp_key", value_2]]
* This method transforms an object like {'@MyApp_user': myUserValue, '@MyApp_key': myKeyValue}
* to an array of key-value pairs in the above format and removes key-value pairs that are being set to null
* @return an array of key - value pairs <[key, value]>
*/
function prepareKeyValuePairsForStorage(data, shouldRemoveNestedNulls) {
return Object.entries(data).reduce((pairs, [key, value]) => {
const { value: valueAfterRemoving, wasRemoved } = removeNullValues(key, value, shouldRemoveNestedNulls);
if (!wasRemoved && valueAfterRemoving !== undefined) {
pairs.push([key, valueAfterRemoving]);
}
return pairs;
}, []);
}
/**
* Merges an array of changes with an existing value
*
* @param changes Array of changes that should be applied to the existing value
*/
function applyMerge(existingValue, changes, shouldRemoveNestedNulls) {
const lastChange = changes === null || changes === void 0 ? void 0 : changes.at(-1);
if (Array.isArray(lastChange)) {
return lastChange;
}
if (changes.some((change) => change && typeof change === 'object')) {
// Object values are then merged one after the other
return changes.reduce((modifiedData, change) => utils_1.default.fastMerge(modifiedData, change, shouldRemoveNestedNulls), (existingValue || {}));
}
// If we have anything else we can't merge it so we'll
// simply return the last value that was queued
return lastChange;
}
/**
* Merge user provided default key value pairs.
*/
function initializeWithDefaultKeyStates() {
return storage_1.default.multiGet(Object.keys(defaultKeyStates)).then((pairs) => {
const existingDataAsObject = Object.fromEntries(pairs);
const merged = utils_1.default.fastMerge(existingDataAsObject, defaultKeyStates);
OnyxCache_1.default.merge(merged !== null && merged !== void 0 ? merged : {});
Object.entries(merged !== null && merged !== void 0 ? merged : {}).forEach(([key, value]) => keyChanged(key, value, existingDataAsObject));
});
}
/**
* Validate the collection is not empty and has a correct type before applying mergeCollection()
*/
function isValidNonEmptyCollectionForMerge(collection) {
return typeof collection === 'object' && !Array.isArray(collection) && !utils_1.default.isEmptyObject(collection);
}
/**
* Verify if all the collection keys belong to the same parent
*/
function doAllCollectionItemsBelongToSameParent(collectionKey, collectionKeys) {
let hasCollectionKeyCheckFailed = false;
collectionKeys.forEach((dataKey) => {
if (isKeyMatch(collectionKey, dataKey)) {
return;
}
if (process.env.NODE_ENV === 'development') {
throw new Error(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`);
}
hasCollectionKeyCheckFailed = true;
Logger.logAlert(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`);
});
return !hasCollectionKeyCheckFailed;
}
/**
* Subscribes to an Onyx key and listens to