react-native-onyx
Version:
State management for React Native
1,056 lines • 60.4 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 () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__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 });
exports.clearOnyxUtilsInternals = clearOnyxUtilsInternals;
/* eslint-disable no-continue */
const fast_equals_1 = require("fast-equals");
const pick_1 = __importDefault(require("lodash/pick"));
const underscore_1 = __importDefault(require("underscore"));
const DevTools_1 = __importDefault(require("./DevTools"));
const Logger = __importStar(require("./Logger"));
const OnyxCache_1 = __importStar(require("./OnyxCache"));
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"));
const logMessages_1 = __importDefault(require("./logMessages"));
// 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
let mergeQueue = {};
let mergeQueuePromise = {};
// Holds a mapping of all the React components that want their state subscribed to a store key
let 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
let 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.
let 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);
// Set collection keys in cache for optimized storage
OnyxCache_1.default.setCollectionKeys(onyxCollectionKeySet);
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) {
return Object.entries(collection !== null && collection !== void 0 ? collection : {}).reduce((finalCollection, [key, item]) => {
// eslint-disable-next-line no-param-reassign
finalCollection[key] = selector(item);
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] = 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) => 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) {
return key.startsWith(collectionKey) && key.length > collectionKey.length;
}
/**
* 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)) {
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) {
let val = OnyxCache_1.default.get(key);
if (isCollectionKey(key)) {
const collectionData = OnyxCache_1.default.getCollectionData(key);
if (collectionData !== undefined) {
val = collectionData;
}
else {
// If we haven't loaded all keys yet, we can't determine if the collection exists
if (OnyxCache_1.default.getAllKeys().size === 0) {
return;
}
// Set an empty collection object for collections that exist but have no data
val = {};
}
}
return val;
}
function getCachedCollection(collectionKey, collectionMemberKeys) {
// Use optimized collection data retrieval when cache is populated
const collectionData = OnyxCache_1.default.getCollectionData(collectionKey);
const allKeys = collectionMemberKeys || OnyxCache_1.default.getAllKeys();
if (collectionData !== undefined && (Array.isArray(allKeys) ? allKeys.length > 0 : allKeys.size > 0)) {
// If we have specific member keys, filter the collection
if (collectionMemberKeys) {
const filteredCollection = {};
collectionMemberKeys.forEach((key) => {
if (collectionData[key] !== undefined) {
filteredCollection[key] = collectionData[key];
}
else if (OnyxCache_1.default.hasNullishStorageKey(key)) {
filteredCollection[key] = OnyxCache_1.default.get(key);
}
});
return filteredCollection;
}
// Return a copy to avoid mutations affecting the cache
return Object.assign({}, collectionData);
}
// Fallback to original implementation if collection data not available
const collection = {};
// 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)) {
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) {
// 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);
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);
// 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);
lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection[subscriber.key]);
continue;
}
continue;
}
}
}
/**
* 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, canUpdateSubscriber = () => true, notifyConnectSubscribers = 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.
// 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;
}
console.error('Warning: Found a matching subscriber to a key that changed, but no callback could be found.');
}
}
/**
* Sends the data obtained from the keys to the connection.
*/
function sendDataToConnection(mapping, value, matchedKey) {
var _a, _b;
// If the mapping no longer exists then we should not send any data.
// This means our subscriber was disconnected.
if (!callbackToStateMapping[mapping.subscriptionID]) {
return;
}
// 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(key) {
if (!OnyxCache_1.default.isEvictableKey(key)) {
return;
}
// Add the key to recentKeys first (this makes it the most recent key)
OnyxCache_1.default.addToAccessedKeys(key);
// Try to free some cache whenever we connect to a safe eviction key
OnyxCache_1.default.removeLeastRecentlyUsedKeys();
}
/**
* 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);
});
}
/**
* 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, canUpdateSubscriber = () => true) {
const promise = Promise.resolve().then(() => keyChanged(key, value, canUpdateSubscriber, true));
batchUpdates(() => keyChanged(key, value, canUpdateSubscriber, false));
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));
batchUpdates(() => keysChanged(key, value, previousValue, false));
return Promise.all([maybeFlushBatchUpdates(), promise]).then(() => undefined);
}
/**
* Remove a key from Onyx and update the subscribers
*/
function remove(key) {
OnyxCache_1.default.drop(key);
scheduleSubscriberUpdate(key, undefined);
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) {
// 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, (subscriber) => hasChanged || (subscriber === null || subscriber === void 0 ? void 0 : subscriber.initWithStoredValues) === false).then(() => undefined);
}
function hasPendingMergeForKey(key) {
return !!mergeQueue[key];
}
/**
* 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, replaceNullPatches) {
const pairs = [];
Object.entries(data).forEach(([key, value]) => {
if (value === null) {
remove(key);
return;
}
const valueWithoutNestedNullValues = (shouldRemoveNestedNulls !== null && shouldRemoveNestedNulls !== void 0 ? shouldRemoveNestedNulls : true) ? utils_1.default.removeNestedNullValues(value) : value;
if (valueWithoutNestedNullValues !== undefined) {
pairs.push([key, valueWithoutNestedNullValues, replaceNullPatches === null || replaceNullPatches === void 0 ? void 0 : replaceNullPatches[key]]);
}
});
return pairs;
}
/**
* Merges an array of changes with an existing value or creates a single change.
*
* @param changes Array of changes that should be merged
* @param existingValue The existing value that should be merged with the changes
*/
function mergeChanges(changes, existingValue) {
return mergeInternal('merge', changes, existingValue);
}
/**
* Merges an array of changes with an existing value or creates a single change.
* It will also mark deep nested objects that need to be entirely replaced during the merge.
*
* @param changes Array of changes that should be merged
* @param existingValue The existing value that should be merged with the changes
*/
function mergeAndMarkChanges(changes, existingValue) {
return mergeInternal('mark', changes, existingValue);
}
/**
* Merges an array of changes with an existing value or creates a single change.
*
* @param changes Array of changes that should be merged
* @param existingValue The existing value that should be merged with the changes
*/
function mergeInternal(mode, changes, existingValue) {
const lastChange = changes === null || changes === void 0 ? void 0 : changes.at(-1);
if (Array.isArray(lastChange)) {
return { result: lastChange, replaceNullPatches: [] };
}
if (changes.some((change) => change && typeof change === 'object')) {
// Object values are then merged one after the other
return changes.reduce((modifiedData, change) => {
const options = mode === 'merge' ? { shouldRemoveNestedNulls: true, objectRemovalMode: 'replace' } : { objectRemovalMode: 'mark' };
const { result, replaceNullPatches } = utils_1.default.fastMerge(modifiedData.result, change, options);
// eslint-disable-next-line no-param-reassign
modifiedData.result = result;
// eslint-disable-next-line no-param-reassign
modifiedData.replaceNullPatches = [...modifiedData.replaceNullPatches, ...replaceNullPatches];
return modifiedData;
}, {
result: (existingValue !== null && existingValue !== void 0 ? existingValue : {}),
replaceNullPatches: [],
});
}
// If we have anything else we can't merge it so we'll
// simply return the last value that was queued
return { result: lastChange, replaceNullPatches: [] };
}
/**
* 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, {
shouldRemoveNestedNulls: true,
}).result;
OnyxCache_1.default.merge(merged !== null && merged !== void 0 ? merged : {});
Object.entries(merged !== null && merged !== void 0 ? merged : {}).forEach(([key, value]) => keyChanged(key, value));
});
}
/**
* 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 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) {
const mapping = connectOptions;
const subscriptionID = lastSubscriptionID++;
callbackToStateMapping[subscriptionID] = 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.key))
.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 (!!mapping.key && typeof mapping.key === 'string' && !isCollectionKey(mapping.key) && OnyxCache_1.default.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 = [];
// Performance optimization: For single key subscriptions, avoid O(n) iteration
if (!isCollectionKey(mapping.key)) {
if (keys.has(mapping.key)) {
matchingKeys.push(mapping.key);
}
}
else {
// Collection case - need to iterate through all keys to find matches (O(n))
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.
if (matchingKeys.length === 0) {
if (mapping.key) {
OnyxCache_1.default.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);
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, key);
});
});
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, mapping.key));
return;
}
console.error('Warning: Onyx.connect() was found without a callback');
});
// 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) {
if (!callbackToStateMapping[subscriptionID]) {
return;
}
deleteKeyBySubscriptions(lastSubscriptionID);
delete callbackToStateMapping[subscriptionID];
}
function updateSnapshots(data, mergeFn) {
const snapshotCollectionKey = getSnapshotKey();
if (!snapshotCollectionKey)
return [];
const promises = [];
const snapshotCollection = getCachedCollection(snapshotCollectionKey);
Object.entries(snapshotCollection).forEach(([snapshotEntryKey, snapshotEntryValue]) => {
// Snapshots may not be present in cache. We don't know how to update them so we skip.
if (!snapshotEntryValue) {
return;
}
let updatedData = {};
data.forEach(({ key, value }) => {
// snapshots are normal keys so we want to skip update if they are written to Onyx
if (isCollectionMemberKey(snapshotCollectionKey, key)) {
return;
}
if (typeof snapshotEntryValue !== 'object' || !('data' in snapshotEntryValue)) {
return;
}
const snapshotData = snapshotEntryValue.data;
if (!snapshotData || !snapshotData[key]) {
return;
}
if (Array.isArray(value) || Array.isArray(snapshotData[key])) {
updatedData[key] = value || [];
return;
}
if (value === null) {
updatedData[key] = value;
return;
}
const oldValue = updatedData[key] || {};
const newValue = (0, pick_1.default)(value, Object.keys(snapshotData[key]));
updatedData = Object.assign(Object.assign({}, updatedData), { [key]: Object.assign(oldValue, newValue) });
});
// Skip the update if there's no data to be merged
if (utils_1.default.isEmptyObject(updatedData)) {
return;
}
promises.push(() => mergeFn(snapshotEntryKey, { data: updatedData }));
});
return promises;
}
/**
* Merges a collection based on their keys.
* Serves as core implementation for `Onyx.mergeCollection()` public function, the difference being
* that this internal function allows passing an additional `mergeReplaceNullPatches` parameter.
*
* @param collectionKey e.g. `ONYXKEYS.COLLECTION.REPORT`
* @param collection Object collection keyed by individual collection member keys and values
* @param mergeReplaceNullPatches Record where the key is a collection member key and the value is a list of
* tuples that we'll use to replace the nested objects of that collection member record with something else.
*/
function mergeCollectionWithPatches(collectionKey, collection, mergeReplaceNullPatches) {
if (!isValidNonEmptyCollectionForMerge(collection)) {
Logger.logInfo('mergeCollection() called with invalid or empty value. Skipping this update.');
return Promise.resolve();
}
let resultCollection = collection;
let resultCollectionKeys = Object.keys(resultCollection);
// Confirm all the collection keys belong to the same parent
if (!doAllCollectionItemsBelongToSameParent(collectionKey, resultCollectionKeys)) {
return Promise.resolve();
}
if (skippableCollectionMemberIDs.size) {
resultCollection = resultCollectionKeys.reduce((result, key) => {
try {
const [, collectionMemberID] = splitCollectionMemberKey(key, collectionKey);
// If the collection member key is a skippable one we set its value to null.
// eslint-disable-next-line no-param-reassign
result[key] = !skippableCollectionMemberIDs.has(collectionMemberID) ? resultCollection[key] : null;
}
catch (_a) {
// Something went wrong during split, so we assign the data to result anyway.
// eslint-disable-next-line no-param-reassign
result[key] = resultCollection[key];
}
return result;
}, {});
}
resultCollectionKeys = Object.keys(resultCollection);
return getAllKeys()
.then((persistedKeys) => {
// Split to keys that exist in storage and keys that don't
const keys = resultCollectionKeys.filter((key) => {
if (resultCollection[key] === null) {
remove(key);
return false;
}
return true;
});
const existingKeys = keys.filter((key) => persistedKeys.has(key));
const cachedCollectionForExistingKeys = getCachedCollection(collectionKey, existingKeys);
const existingKeyCollection = existingKeys.reduce((obj, key) => {
const { isCompatible, existingValueType, newValueType } = utils_1.default.checkCompatibilityWithExistingValue(resultCollection[key], cachedCollectionForExistingKeys[key]);
if (!isCompatible) {
Logger.logAlert