diff --git a/API-INTERNAL.md b/API-INTERNAL.md index 34ec4bf37..59208f80f 100644 --- a/API-INTERNAL.md +++ b/API-INTERNAL.md @@ -17,6 +17,11 @@
getDeferredInitTask()

Getter - returns the deffered init task.

+
afterInit(action)
+

Executes an action after Onyx has been initialized. +If Onyx is already initialized, the action is executed immediately. +Otherwise, it waits for initialization to complete before executing.

+
getSkippableCollectionMemberIDs()

Getter - returns the skippable collection member IDs.

@@ -54,45 +59,6 @@ to the values for those keys (correctly typed) such as [OnyxCollection<
getAllKeys()

Returns current key names stored in persisted storage

-
getCollectionKeys()
-

Returns set of all registered collection keys

-
-
isCollectionKey()
-

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

-
-
isCollectionMember(key)
-

Checks if a given key is a collection member key (not just a collection key).

-
-
isRamOnlyKey(key)
-

Checks if a given key is a RAM-only key, RAM-only collection key, or a RAM-only collection member

-

For example:

-

For the following Onyx setup

-

ramOnlyKeys: ["ramOnlyKey", "ramOnlyCollection_"]

- -
-
splitCollectionMemberKey(key, collectionKey)
-

Splits a collection member key into the collection key part and the ID part.

-
-
isKeyMatch()
-

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")

-
-
getCollectionKey(key)
-

Extracts the collection identifier of a given collection member key.

-

For example:

- -
tryGetCachedValue()

Tries to get a value from the cache. If the value is not present in cache it will return the default value or undefined. If the requested key is a collection, it will return an object with all the collection members.

@@ -223,6 +189,20 @@ Getter - returns the default key states. Getter - returns the deffered init task. **Kind**: global function + + +## afterInit(action) ⇒ +Executes an action after Onyx has been initialized. +If Onyx is already initialized, the action is executed immediately. +Otherwise, it waits for initialization to complete before executing. + +**Kind**: global function +**Returns**: The result of the action + +| Param | Description | +| --- | --- | +| action | The action to execute after initialization | + ## getSkippableCollectionMemberIDs() @@ -312,93 +292,6 @@ Deletes a subscription ID associated with its corresponding key. 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 subscriber's supplied key -is associated with a collection of keys. - -**Kind**: global function - - -## isCollectionMember(key) ⇒ -Checks if a given key is a collection member key (not just a collection key). - -**Kind**: global function -**Returns**: true if the key is a collection member, false otherwise - -| Param | Description | -| --- | --- | -| key | The key to check | - - - -## isRamOnlyKey(key) ⇒ -Checks if a given key is a RAM-only key, RAM-only collection key, or a RAM-only collection member - -For example: - -For the following Onyx setup - -ramOnlyKeys: ["ramOnlyKey", "ramOnlyCollection_"] - -- `isRamOnlyKey("ramOnlyKey")` would return true -- `isRamOnlyKey("ramOnlyCollection_")` would return true -- `isRamOnlyKey("ramOnlyCollection_1")` would return true -- `isRamOnlyKey("someOtherKey")` would return false - -**Kind**: global function -**Returns**: true if key is a RAM-only key, RAM-only collection key, or a RAM-only collection member - -| Param | Description | -| --- | --- | -| key | The key to check | - - - -## splitCollectionMemberKey(key, collectionKey) ⇒ -Splits a collection member key into the collection key part and the ID part. - -**Kind**: global 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. - -| Param | Description | -| --- | --- | -| key | The collection member key to split. | -| collectionKey | The collection key of the `key` param that can be passed in advance to optimize the function. | - - - -## isKeyMatch() -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") - -**Kind**: global function - - -## getCollectionKey(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_" - -**Kind**: global function -**Returns**: The plain collection key or throws an Error if the key is not a collection one. - -| Param | Description | -| --- | --- | -| key | The collection key to process. | - ## tryGetCachedValue() diff --git a/lib/DevTools/RealDevTools.ts b/lib/DevTools/RealDevTools.ts index 87c2e47f6..5c26a70f9 100644 --- a/lib/DevTools/RealDevTools.ts +++ b/lib/DevTools/RealDevTools.ts @@ -1,5 +1,5 @@ import type {IDevTools, DevtoolsOptions, DevtoolsConnection, ReduxDevtools} from './types'; -import OnyxUtils from '../OnyxUtils'; +import OnyxKeys from '../OnyxKeys'; const ERROR_LABEL = 'Onyx DevTools - Error: '; @@ -77,7 +77,7 @@ class RealDevTools implements IDevTools { clearState(keysToPreserve: string[] = []): void { const newState = Object.entries(this.state).reduce((obj: Record, [key, value]) => { // eslint-disable-next-line no-param-reassign - obj[key] = keysToPreserve.some((preserveKey) => OnyxUtils.isKeyMatch(preserveKey, key)) ? value : this.defaultState[key]; + obj[key] = keysToPreserve.some((preserveKey) => OnyxKeys.isKeyMatch(preserveKey, key)) ? value : this.defaultState[key]; return obj; }, {}); diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 4a3f26317..f129fc62a 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -23,6 +23,7 @@ import type { SetOptions, } from './types'; import OnyxUtils from './OnyxUtils'; +import OnyxKeys from './OnyxKeys'; import logMessages from './logMessages'; import type {Connection} from './OnyxConnectionManager'; import connectionManager from './OnyxConnectionManager'; @@ -55,13 +56,13 @@ function init({ OnyxUtils.setSkippableCollectionMemberIDs(new Set(skippableCollectionMemberIDs)); OnyxUtils.setSnapshotMergeKeys(new Set(snapshotMergeKeys)); - cache.setRamOnlyKeys(new Set(ramOnlyKeys)); + OnyxKeys.setRamOnlyKeys(new Set(ramOnlyKeys)); if (shouldSyncMultipleInstances) { Storage.keepInstancesSync?.((key, value) => { // RAM-only keys should never sync from storage as they may have stale persisted data // from before the key was migrated to RAM-only. - if (OnyxUtils.isRamOnlyKey(key)) { + if (OnyxKeys.isRamOnlyKey(key)) { return; } @@ -70,7 +71,7 @@ function init({ // Check if this is a collection member key to prevent duplicate callbacks // When a collection is updated, individual members sync separately to other tabs // Setting isProcessingCollectionUpdate=true prevents triggering collection callbacks for each individual update - const isKeyCollectionMember = OnyxUtils.isCollectionMember(key); + const isKeyCollectionMember = OnyxKeys.isCollectionMember(key); OnyxUtils.keyChanged(key, value as OnyxValue, undefined, isKeyCollectionMember); }); @@ -87,7 +88,7 @@ function init({ // eager cache loading populates the key index (cache.setAllKeys) inside initializeWithDefaultKeyStates, // and the evictable keys list depends on that index being populated. OnyxUtils.initializeWithDefaultKeyStates() - .then(() => cache.addEvictableKeysToRecentlyAccessedList(OnyxUtils.isCollectionKey, OnyxUtils.getAllKeys)) + .then(() => cache.addEvictableKeysToRecentlyAccessedList(OnyxKeys.isCollectionKey, OnyxUtils.getAllKeys)) .then(OnyxUtils.getDeferredInitTask().resolve); } @@ -204,7 +205,7 @@ function merge(key: TKey, changes: OnyxMergeInput): const skippableCollectionMemberIDs = OnyxUtils.getSkippableCollectionMemberIDs(); if (skippableCollectionMemberIDs.size) { try { - const [, collectionMemberID] = OnyxUtils.splitCollectionMemberKey(key); + const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(key); if (skippableCollectionMemberIDs.has(collectionMemberID)) { // The key is a skippable one, so we set the new changes to undefined. // eslint-disable-next-line no-param-reassign @@ -348,7 +349,7 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { // to null would cause unknown behavior) // 2.1 However, if a default key was explicitly set to null, we need to reset it to the default value for (const key of allKeys) { - const isKeyToPreserve = keysToPreserve.some((preserveKey) => OnyxUtils.isKeyMatch(preserveKey, key)); + const isKeyToPreserve = keysToPreserve.some((preserveKey) => OnyxKeys.isKeyMatch(preserveKey, key)); const isDefaultKey = key in defaultKeyStates; // If the key is being removed or reset to default: @@ -361,7 +362,7 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { if (newValue !== oldValue) { cache.set(key, newValue); - const collectionKey = OnyxUtils.getCollectionKey(key); + const collectionKey = OnyxKeys.getCollectionKey(key); if (collectionKey) { if (!keyValuesToResetAsCollection[collectionKey]) { @@ -396,7 +397,7 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { // Exclude RAM-only keys to prevent them from being saved to storage const defaultKeyValuePairs = Object.entries( Object.keys(defaultKeyStates) - .filter((key) => !keysToPreserve.some((preserveKey) => OnyxUtils.isKeyMatch(preserveKey, key)) && !OnyxUtils.isRamOnlyKey(key)) + .filter((key) => !keysToPreserve.some((preserveKey) => OnyxKeys.isKeyMatch(preserveKey, key)) && !OnyxKeys.isRamOnlyKey(key)) .reduce((obj: KeyValueMapping, key) => { // eslint-disable-next-line no-param-reassign obj[key] = defaultKeyStates[key]; @@ -499,8 +500,8 @@ function update(data: Array>): Promise OnyxUtils.isKeyMatch(collectionKey, key)); + for (const collectionKey of OnyxKeys.getCollectionKeys()) { + const collectionItemKeys = Object.keys(updateQueue).filter((key) => OnyxKeys.isKeyMatch(collectionKey, key)); if (collectionItemKeys.length <= 1) { // If there are no items of this collection in the updateQueue, we should skip it. // If there is only one item, we should update it individually, therefore retain it in the updateQueue. diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index fb6d4f795..0444de370 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -3,7 +3,7 @@ import bindAll from 'lodash/bindAll'; import type {ValueOf} from 'type-fest'; import utils from './utils'; import type {OnyxKey, OnyxValue} from './types'; -import * as Str from './Str'; +import OnyxKeys from './OnyxKeys'; // Task constants const TASK = { @@ -52,12 +52,6 @@ class OnyxCache { /** List of keys that have been directly subscribed to or recently modified from least to most recent */ private recentlyAccessedKeys = new Set(); - /** Set of collection keys for fast lookup */ - private collectionKeys = new Set(); - - /** Set of RAM-only keys for fast lookup */ - private ramOnlyKeys = new Set(); - constructor() { this.storageKeys = new Set(); this.nullishStorageKeys = new Set(); @@ -94,11 +88,8 @@ class OnyxCache { 'addEvictableKeysToRecentlyAccessedList', 'getKeyForEviction', 'setCollectionKeys', - 'isCollectionKey', - 'getCollectionKey', 'getCollectionData', - 'setRamOnlyKeys', - 'isRamOnlyKey', + 'hasValueChanged', ); } @@ -120,6 +111,9 @@ class OnyxCache { */ setAllKeys(keys: OnyxKey[]) { this.storageKeys = new Set(keys); + for (const key of keys) { + OnyxKeys.registerMemberKey(key); + } } /** Saves a key in the storage keys list @@ -127,6 +121,7 @@ class OnyxCache { */ addKey(key: OnyxKey): void { this.storageKeys.add(key); + OnyxKeys.registerMemberKey(key); } /** Used to set keys that are null/undefined in storage without adding null to the storage map */ @@ -172,7 +167,7 @@ class OnyxCache { // since it will either be set to a non nullish value or removed from the cache completely. this.nullishStorageKeys.delete(key); - const collectionKey = this.getCollectionKey(key); + const collectionKey = OnyxKeys.getCollectionKey(key); if (value === null || value === undefined) { delete this.storageMap[key]; @@ -201,18 +196,19 @@ class OnyxCache { delete this.storageMap[key]; // Remove from collection data cache if this is a collection member - const collectionKey = this.getCollectionKey(key); + const collectionKey = OnyxKeys.getCollectionKey(key); if (collectionKey && this.collectionData[collectionKey]) { delete this.collectionData[collectionKey][key]; } // If this is a collection key, clear its data - if (this.isCollectionKey(key)) { + if (OnyxKeys.isCollectionKey(key)) { delete this.collectionData[key]; } this.storageKeys.delete(key); this.recentKeys.delete(key); + OnyxKeys.deregisterMemberKey(key); } /** @@ -235,7 +231,7 @@ class OnyxCache { this.addKey(key); this.addToAccessedKeys(key); - const collectionKey = this.getCollectionKey(key); + const collectionKey = OnyxKeys.getCollectionKey(key); if (value === null || value === undefined) { this.addNullishStorageKey(key); @@ -325,7 +321,7 @@ class OnyxCache { delete this.storageMap[key]; // Remove from collection data cache if this is a collection member - const collectionKey = this.getCollectionKey(key); + const collectionKey = OnyxKeys.getCollectionKey(key); if (collectionKey && this.collectionData[collectionKey]) { delete this.collectionData[collectionKey][key]; } @@ -364,17 +360,7 @@ class OnyxCache { * @param testKey - Key to check */ isEvictableKey(testKey: OnyxKey): boolean { - return this.evictionAllowList.some((key) => this.isKeyMatch(key, testKey)); - } - - /** - * Check if a given key matches a pattern key - * @param configKey - Pattern that may contain a wildcard - * @param key - Key to test against the pattern - */ - private isKeyMatch(configKey: OnyxKey, key: OnyxKey): boolean { - const isCollectionKey = configKey.endsWith('_'); - return isCollectionKey ? Str.startsWith(key, configKey) : configKey === key; + return this.evictionAllowList.some((key) => OnyxKeys.isKeyMatch(key, testKey)); } /** @@ -411,7 +397,7 @@ class OnyxCache { return getAllKeysFn().then((keys: Set) => { for (const evictableKey of this.evictionAllowList) { for (const key of keys) { - if (!this.isKeyMatch(evictableKey, key)) { + if (!OnyxKeys.isKeyMatch(evictableKey, key)) { continue; } @@ -437,7 +423,7 @@ class OnyxCache { * Set the collection keys for optimized storage */ setCollectionKeys(collectionKeys: Set): void { - this.collectionKeys = collectionKeys; + OnyxKeys.setCollectionKeys(collectionKeys); // Initialize collection data for existing collection keys for (const collectionKey of collectionKeys) { @@ -448,25 +434,6 @@ class OnyxCache { } } - /** - * Check if a key is a collection key - */ - isCollectionKey(key: OnyxKey): boolean { - return this.collectionKeys.has(key); - } - - /** - * Get the collection key for a given member key - */ - getCollectionKey(key: OnyxKey): OnyxKey | undefined { - for (const collectionKey of this.collectionKeys) { - if (key.startsWith(collectionKey) && key.length > collectionKey.length) { - return collectionKey; - } - } - return undefined; - } - /** * Get all data for a collection key */ @@ -479,20 +446,6 @@ class OnyxCache { // Return a shallow copy to ensure React detects changes when items are added/removed return {...cachedCollection}; } - - /** - * Set the RAM-only keys for optimized storage - */ - setRamOnlyKeys(ramOnlyKeys: Set): void { - this.ramOnlyKeys = ramOnlyKeys; - } - - /** - * Check if a key is a RAM-only key - */ - isRamOnlyKey(key: OnyxKey): boolean { - return this.ramOnlyKeys.has(key); - } } const instance = new OnyxCache(); diff --git a/lib/OnyxConnectionManager.ts b/lib/OnyxConnectionManager.ts index b9d8d56cd..0d5792c87 100644 --- a/lib/OnyxConnectionManager.ts +++ b/lib/OnyxConnectionManager.ts @@ -2,6 +2,7 @@ import bindAll from 'lodash/bindAll'; import * as Logger from './Logger'; import type {ConnectOptions} from './Onyx'; import OnyxUtils from './OnyxUtils'; +import OnyxKeys from './OnyxKeys'; import * as Str from './Str'; import type {CollectionConnectCallback, DefaultConnectCallback, DefaultConnectOptions, OnyxKey, OnyxValue} from './types'; import cache from './OnyxCache'; @@ -129,7 +130,7 @@ class OnyxConnectionManager { if ( reuseConnection === false || initWithStoredValues === false || - (OnyxUtils.isCollectionKey(key) && (waitForCollectionCallback === undefined || waitForCollectionCallback === false)) + (OnyxKeys.isCollectionKey(key) && (waitForCollectionCallback === undefined || waitForCollectionCallback === false)) ) { suffix += `,uniqueID=${Str.guid()}`; } diff --git a/lib/OnyxKeys.ts b/lib/OnyxKeys.ts new file mode 100644 index 000000000..748b717d9 --- /dev/null +++ b/lib/OnyxKeys.ts @@ -0,0 +1,230 @@ +import type {CollectionKeyBase, CollectionKey, OnyxKey} from './types'; + +/** Single source of truth for the set of registered collection keys */ +let collectionKeySet = new Set(); + +/** Reverse lookup: member key → collection key for O(1) access */ +const memberToCollectionKeyMap = new Map(); + +/** Forward lookup: collection key → set of member keys for O(collectionMembers) iteration */ +const collectionToMembersMap = new Map>(); + +/** Set of keys that should only be stored in RAM, not persisted to storage */ +let ramOnlyKeySet = new Set(); + +/** + * Initializes the collection key set. Called once during Onyx.init(). + */ +function setCollectionKeys(keys: Set): void { + collectionKeySet = keys; +} + +/** + * Returns the set of all registered collection keys. + */ +function getCollectionKeys(): Set { + return collectionKeySet; +} + +/** + * Checks if the given key is a registered collection key (e.g. "report_"). + */ +function isCollectionKey(key: OnyxKey): key is CollectionKeyBase { + return collectionKeySet.has(key); +} + +/** + * Checks if the given key is a member of the specified collection key. + * e.g. isCollectionMemberKey("report_", "report_123") → true + */ +function isCollectionMemberKey(collectionKey: TCollectionKey, key: string): key is `${TCollectionKey}${string}` { + return key.startsWith(collectionKey) && key.length > collectionKey.length; +} + +/** + * Checks if a given key is a collection member key (not just a collection key). + */ +function isCollectionMember(key: OnyxKey): boolean { + const collectionKey = getCollectionKey(key); + return !!collectionKey && key.length > collectionKey.length; +} + +/** + * Checks if the provided key matches the config key — either an exact match + * or a collection prefix match. + */ +function isKeyMatch(configKey: OnyxKey, key: OnyxKey): boolean { + return isCollectionKey(configKey) ? key.startsWith(configKey) : configKey === key; +} + +/** + * Extracts the collection key from a collection member key. + * + * Uses a pre-computed Map for O(1) lookup. Falls back to string parsing + * for keys not yet in the map (e.g. before they're cached). + * + * Examples: + * - getCollectionKey("report_123") → "report_" + * - getCollectionKey("report_") → "report_" + * - getCollectionKey("sharedNVP_user_-1_something") → "sharedNVP_user_" + */ +function getCollectionKey(key: CollectionKey | OnyxKey): string | undefined { + // Fast path: O(1) Map lookup for known member keys + const cached = memberToCollectionKeyMap.get(key); + if (cached !== undefined) { + return cached; + } + + // If the key itself is a collection key, return it directly + if (isCollectionKey(key)) { + return key; + } + + // Slow path: string parsing — use a `string` variable to avoid + // TypeScript narrowing `key` to `never` after the isCollectionKey guard. + const keyStr: string = key; + let lastUnderscoreIndex = keyStr.lastIndexOf('_'); + while (lastUnderscoreIndex > 0) { + const possibleKey = keyStr.slice(0, lastUnderscoreIndex + 1); + if (isCollectionKey(possibleKey)) { + // Cache for future O(1) lookups + memberToCollectionKeyMap.set(key, possibleKey); + return possibleKey; + } + lastUnderscoreIndex = keyStr.lastIndexOf('_', lastUnderscoreIndex - 1); + } + + return undefined; +} + +/** + * Pre-computes and caches the member → collection key mapping for a given key. + * Called from OnyxCache.addKey() to ensure the Map stays populated. + */ +function registerMemberKey(key: OnyxKey): void { + const existingCollectionKey = memberToCollectionKeyMap.get(key); + if (existingCollectionKey !== undefined) { + // Already in reverse map — ensure forward map is also populated. + // getCollectionKey() can populate memberToCollectionKeyMap without + // updating collectionToMembersMap, so we must sync here. + let members = collectionToMembersMap.get(existingCollectionKey); + if (!members) { + members = new Set(); + collectionToMembersMap.set(existingCollectionKey, members); + } + members.add(key); + return; + } + // Find the longest (most specific) matching collection key. + // e.g. for 'test_level_1', prefer 'test_level_' over 'test_'. + let matchedCollectionKey: OnyxKey | undefined; + for (const collectionKey of collectionKeySet) { + if (isCollectionMemberKey(collectionKey, key)) { + if (!matchedCollectionKey || collectionKey.length > matchedCollectionKey.length) { + matchedCollectionKey = collectionKey; + } + } + } + + if (matchedCollectionKey) { + memberToCollectionKeyMap.set(key, matchedCollectionKey); + + // Also register in the forward lookup (collection → members) + let members = collectionToMembersMap.get(matchedCollectionKey); + if (!members) { + members = new Set(); + collectionToMembersMap.set(matchedCollectionKey, members); + } + members.add(key); + } +} + +/** + * Removes a member key from the reverse lookup map. + * Called when a key is dropped from cache. + */ +function deregisterMemberKey(key: OnyxKey): void { + const collectionKey = memberToCollectionKeyMap.get(key); + if (collectionKey) { + const members = collectionToMembersMap.get(collectionKey); + if (members) { + members.delete(key); + if (members.size === 0) { + collectionToMembersMap.delete(collectionKey); + } + } + } + memberToCollectionKeyMap.delete(key); +} + +/** + * Returns the set of member keys for a given collection key. + * O(1) lookup using the forward index. + */ +function getMembersOfCollection(collectionKey: OnyxKey): Set | undefined { + return collectionToMembersMap.get(collectionKey); +} + +/** + * Splits a collection member key into the collection key part and the ID part. + * + * @param key - The collection member key to split + * @param collectionKey - Optional pre-resolved collection key for optimization + * @returns A tuple of [collectionKey, memberId] + * @throws If the key is not a valid collection member key + */ +function splitCollectionMemberKey( + key: TKey, + collectionKey?: string, +): [CollectionKeyType, string] { + if (collectionKey && !isCollectionMemberKey(collectionKey, key)) { + throw new Error(`Invalid '${collectionKey}' collection key provided, it isn't compatible with '${key}' key.`); + } + + if (!collectionKey) { + const resolvedKey = getCollectionKey(key); + if (!resolvedKey) { + throw new Error(`Invalid '${key}' key provided, only collection keys are allowed.`); + } + // eslint-disable-next-line no-param-reassign + collectionKey = resolvedKey; + } + + return [collectionKey as CollectionKeyType, key.slice(collectionKey.length)]; +} + +/** + * Initializes the RAM-only key set. Called once during Onyx.init(). + */ +function setRamOnlyKeys(keys: Set): void { + ramOnlyKeySet = keys; +} + +/** + * Checks if a given key is a RAM-only key, RAM-only collection key, or a RAM-only collection member. + * + * For example, given ramOnlyKeys: ["ramOnlyKey", "ramOnlyCollection_"]: + * - isRamOnlyKey("ramOnlyKey") → true + * - isRamOnlyKey("ramOnlyCollection_") → true + * - isRamOnlyKey("ramOnlyCollection_1") → true + * - isRamOnlyKey("someOtherKey") → false + */ +function isRamOnlyKey(key: OnyxKey): boolean { + return ramOnlyKeySet.has(key) || ramOnlyKeySet.has(getCollectionKey(key) ?? ''); +} + +export default { + setCollectionKeys, + getCollectionKeys, + isCollectionKey, + isCollectionMemberKey, + isCollectionMember, + isKeyMatch, + getCollectionKey, + registerMemberKey, + deregisterMemberKey, + getMembersOfCollection, + splitCollectionMemberKey, + setRamOnlyKeys, + isRamOnlyKey, +}; diff --git a/lib/OnyxMerge/index.native.ts b/lib/OnyxMerge/index.native.ts index ec8c242e3..558532a7d 100644 --- a/lib/OnyxMerge/index.native.ts +++ b/lib/OnyxMerge/index.native.ts @@ -1,3 +1,4 @@ +import OnyxKeys from '../OnyxKeys'; import OnyxUtils from '../OnyxUtils'; import type {OnyxInput, OnyxKey, OnyxValue} from '../types'; import cache from '../OnyxCache'; @@ -28,7 +29,7 @@ const applyMerge: ApplyMerge = , hasChanged); - const shouldSkipStorageOperations = !hasChanged || OnyxUtils.isRamOnlyKey(key); + const shouldSkipStorageOperations = !hasChanged || OnyxKeys.isRamOnlyKey(key); // If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead. // If the key is marked as RAM-only, it should not be saved nor updated in the storage. diff --git a/lib/OnyxMerge/index.ts b/lib/OnyxMerge/index.ts index 7eac789cb..ddf5525c8 100644 --- a/lib/OnyxMerge/index.ts +++ b/lib/OnyxMerge/index.ts @@ -1,4 +1,5 @@ import cache from '../OnyxCache'; +import OnyxKeys from '../OnyxKeys'; import OnyxUtils from '../OnyxUtils'; import Storage from '../storage'; import type {OnyxInput, OnyxKey, OnyxValue} from '../types'; @@ -20,7 +21,7 @@ const applyMerge: ApplyMerge = , hasChanged); - const shouldSkipStorageOperations = !hasChanged || OnyxUtils.isRamOnlyKey(key); + const shouldSkipStorageOperations = !hasChanged || OnyxKeys.isRamOnlyKey(key); // If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead. // If the key is marked as RAM-only, it should not be saved nor updated in the storage. diff --git a/lib/OnyxSnapshotCache.ts b/lib/OnyxSnapshotCache.ts index 891a42460..49ce6a0c3 100644 --- a/lib/OnyxSnapshotCache.ts +++ b/lib/OnyxSnapshotCache.ts @@ -1,4 +1,4 @@ -import OnyxUtils from './OnyxUtils'; +import OnyxKeys from './OnyxKeys'; import type {OnyxKey, OnyxValue} from './types'; import type {UseOnyxOptions, UseOnyxResult, UseOnyxSelector} from './useOnyx'; @@ -130,7 +130,7 @@ class OnyxSnapshotCache { this.snapshotCache.delete(keyToInvalidate); // Check if the key is a collection member and invalidate the collection base key - const collectionBaseKey = OnyxUtils.getCollectionKey(keyToInvalidate); + const collectionBaseKey = OnyxKeys.getCollectionKey(keyToInvalidate); if (collectionBaseKey) { this.snapshotCache.delete(collectionBaseKey); } diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index b160e1034..975812496 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -5,10 +5,10 @@ import DevTools from './DevTools'; import * as Logger from './Logger'; import type Onyx from './Onyx'; import cache, {TASK} from './OnyxCache'; +import OnyxKeys from './OnyxKeys'; import * as Str from './Str'; import Storage from './storage'; import type { - CollectionKey, CollectionKeyBase, ConnectOptions, DeepRecord, @@ -80,9 +80,6 @@ let nextMacrotaskPromise: Promise | null = null; // Holds a mapping of all the React components that want their state subscribed to a store key let 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 subscriptionID for faster lookups let onyxKeyToSubscriptionIDs = new Map(); @@ -193,7 +190,7 @@ function initStoreValues(keys: DeepRecord, initialKeyStates: Pa // 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(keys.COLLECTION ?? {}) as string[]; - onyxCollectionKeySet = collectionValues.reduce((acc, val) => { + const collectionKeySet = collectionValues.reduce((acc, val) => { acc.add(val); return acc; }, new Set()); @@ -207,7 +204,7 @@ function initStoreValues(keys: DeepRecord, initialKeyStates: Pa cache.setEvictionAllowList(evictableKeys); // Set collection keys in cache for optimized storage - cache.setCollectionKeys(onyxCollectionKeySet); + cache.setCollectionKeys(collectionKeySet); if (typeof keys.COLLECTION === 'object' && typeof keys.COLLECTION.SNAPSHOT === 'string') { snapshotKey = keys.COLLECTION.SNAPSHOT; @@ -270,7 +267,7 @@ function get>(key: TKey): P // RAM-only keys should never read from storage (they may have stale persisted data // from before the key was migrated to RAM-only). Mark as nullish so future get() calls // short-circuit via hasCacheForKey and avoid re-running this branch. - if (isRamOnlyKey(key)) { + if (OnyxKeys.isRamOnlyKey(key)) { cache.addNullishStorageKey(key); return Promise.resolve(undefined as TValue); } @@ -287,7 +284,7 @@ function get>(key: TKey): P .then((val) => { if (skippableCollectionMemberIDs.size) { try { - const [, collectionMemberID] = splitCollectionMemberKey(key); + const [, collectionMemberID] = OnyxKeys.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 @@ -336,7 +333,7 @@ function multiGet(keys: CollectionKeyBase[]): Promise); } @@ -387,7 +384,7 @@ function multiGet(keys: CollectionKeyBase[]): Promise> { const promise = Storage.getAllKeys().then((keys) => { // Filter out RAM-only keys from storage results as they may be stale entries // from before the key was migrated to RAM-only. - const filteredKeys = keys.filter((key) => !isRamOnlyKey(key)); + const filteredKeys = keys.filter((key) => !OnyxKeys.isRamOnlyKey(key)); cache.setAllKeys(filteredKeys); // return the updated set of keys @@ -472,130 +469,6 @@ function getAllKeys(): Promise> { return cache.captureTask(TASK.GET_ALL_KEYS, promise) as Promise>; } -/** - * Returns set of all registered collection keys - */ -function getCollectionKeys(): Set { - return onyxCollectionKeySet; -} - -/** - * Checks to see if the subscriber's supplied key - * is associated with a collection of keys. - */ -function isCollectionKey(key: OnyxKey): key is CollectionKeyBase { - return onyxCollectionKeySet.has(key); -} - -function isCollectionMemberKey(collectionKey: TCollectionKey, key: string): key is `${TCollectionKey}${string}` { - return key.startsWith(collectionKey) && key.length > collectionKey.length; -} - -/** - * Checks if a given key is a collection member key (not just a collection key). - * @param key - The key to check - * @returns true if the key is a collection member, false otherwise - */ -function isCollectionMember(key: OnyxKey): boolean { - const collectionKey = getCollectionKey(key); - // If the key is longer than the collection key, it's a collection member - return !!collectionKey && key.length > collectionKey.length; -} - -/** - * Checks if a given key is a RAM-only key, RAM-only collection key, or a RAM-only collection member - * - * For example: - * - * For the following Onyx setup - * - * ramOnlyKeys: ["ramOnlyKey", "ramOnlyCollection_"] - * - * - `isRamOnlyKey("ramOnlyKey")` would return true - * - `isRamOnlyKey("ramOnlyCollection_")` would return true - * - `isRamOnlyKey("ramOnlyCollection_1")` would return true - * - `isRamOnlyKey("someOtherKey")` would return false - * - * @param key - The key to check - * @returns true if key is a RAM-only key, RAM-only collection key, or a RAM-only collection member - */ -function isRamOnlyKey(key: OnyxKey): boolean { - const collectionKey = getCollectionKey(key); - if (collectionKey) { - return cache.isRamOnlyKey(collectionKey); - } - - return cache.isRamOnlyKey(key); -} - -/** - * 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: TKey, - collectionKey?: string, -): [CollectionKeyType, string] { - if (collectionKey && !isCollectionMemberKey(collectionKey, key)) { - throw new Error(`Invalid '${collectionKey}' collection key provided, it isn't compatible with '${key}' key.`); - } - - if (!collectionKey) { - const resolvedKey = getCollectionKey(key); - if (!resolvedKey) { - throw new Error(`Invalid '${key}' key provided, only collection keys are allowed.`); - } - // eslint-disable-next-line no-param-reassign - collectionKey = resolvedKey; - } - - return [collectionKey as CollectionKeyType, 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: OnyxKey, key: OnyxKey): boolean { - 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 undefined if the key is not a collection one. - */ -function getCollectionKey(key: CollectionKey): string | undefined { - // 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); - } - - return undefined; -} - /** * 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. @@ -603,7 +476,7 @@ function getCollectionKey(key: CollectionKey): string | undefined { function tryGetCachedValue(key: TKey): OnyxValue { let val = cache.get(key); - if (isCollectionKey(key)) { + if (OnyxKeys.isCollectionKey(key)) { const collectionData = cache.getCollectionData(key); if (collectionData !== undefined) { val = collectionData; @@ -650,7 +523,7 @@ function getCachedCollection(collectionKey: TKey // 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)) { + if (!collectionMemberKeys && !OnyxKeys.isCollectionMemberKey(collectionKey, key)) { continue; } @@ -704,7 +577,7 @@ function keysChanged( /** * e.g. Onyx.connect({key: `${ONYXKEYS.COLLECTION.REPORT}{reportID}`, callback: ...}); */ - const isSubscribedToCollectionMemberKey = isCollectionMemberKey(collectionKey, subscriber.key); + const isSubscribedToCollectionMemberKey = OnyxKeys.isCollectionMemberKey(collectionKey, subscriber.key); // Regular Onyx.connect() subscriber found. if (typeof subscriber.callback === 'function') { @@ -761,7 +634,7 @@ function keyChanged( ): void { // Add or remove this key from the recentlyAccessedKeys lists if (value !== null) { - cache.addLastAccessedKey(key, isCollectionKey(key)); + cache.addLastAccessedKey(key, OnyxKeys.isCollectionKey(key)); } else { cache.removeLastAccessedKey(key); } @@ -772,7 +645,7 @@ function keyChanged( // 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 = onyxKeyToSubscriptionIDs.get(key) ?? []; - const collectionKey = getCollectionKey(key); + const collectionKey = OnyxKeys.getCollectionKey(key); if (collectionKey) { // Getting the collection key from the specific key because only collection keys were stored in the mapping. @@ -786,7 +659,7 @@ function keyChanged( for (const stateMappingKey of stateMappingKeys) { const subscriber = callbackToStateMapping[stateMappingKey]; - if (!subscriber || !isKeyMatch(subscriber.key, key) || !canUpdateSubscriber(subscriber)) { + if (!subscriber || !OnyxKeys.isKeyMatch(subscriber.key, key) || !canUpdateSubscriber(subscriber)) { continue; } @@ -796,7 +669,7 @@ function keyChanged( continue; } - if (isCollectionKey(subscriber.key) && subscriber.waitForCollectionCallback) { + if (OnyxKeys.isCollectionKey(subscriber.key) && subscriber.waitForCollectionCallback) { // Skip individual key changes for collection callbacks during collection updates // to prevent duplicate callbacks - the collection update will handle this properly if (isProcessingCollectionUpdate) { @@ -926,7 +799,7 @@ function remove(key: TKey, isProcessingCollectionUpdate?: cache.drop(key); scheduleSubscriberUpdate(key, undefined as OnyxValue, undefined, isProcessingCollectionUpdate); - if (isRamOnlyKey(key)) { + if (OnyxKeys.isRamOnlyKey(key)) { return Promise.resolve(); } @@ -1123,13 +996,13 @@ function initializeWithDefaultKeyStates(): Promise { for (const [key, value] of pairs) { // RAM-only keys should not be cached from storage as they may have stale persisted data // from before the key was migrated to RAM-only. - if (isRamOnlyKey(key)) { + if (OnyxKeys.isRamOnlyKey(key)) { continue; } // Skip collection members that are marked as skippable - if (skippableCollectionMemberIDs.size && getCollectionKey(key)) { - const [, collectionMemberID] = splitCollectionMemberKey(key); + if (skippableCollectionMemberIDs.size && OnyxKeys.getCollectionKey(key)) { + const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(key); if (skippableCollectionMemberIDs.has(collectionMemberID)) { continue; @@ -1198,7 +1071,7 @@ function isValidNonEmptyCollectionForMerge(colle function doAllCollectionItemsBelongToSameParent(collectionKey: TKey, collectionKeys: string[]): boolean { let hasCollectionKeyCheckFailed = false; for (const dataKey of collectionKeys) { - if (isKeyMatch(collectionKey, dataKey)) { + if (OnyxKeys.isKeyMatch(collectionKey, dataKey)) { continue; } @@ -1241,7 +1114,7 @@ function subscribeToKey(connectOptions: ConnectOptions(connectOptions: ConnectOptions(connectOptions: ConnectOptions(connectOptions: ConnectOptions(data: Array>, me for (const {key, value} of data) { // snapshots are normal keys so we want to skip update if they are written to Onyx - if (isCollectionMemberKey(snapshotCollectionKey, key)) { + if (OnyxKeys.isCollectionMemberKey(snapshotCollectionKey, key)) { continue; } @@ -1414,7 +1287,7 @@ function setWithRetry({key, value, options}: SetParams({key, value, options}: SetParams { try { - const [, collectionMemberID] = OnyxUtils.splitCollectionMemberKey(key); + const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(key); // 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) ? newData[key] : null; @@ -1530,7 +1403,7 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom const keyValuePairsToStore = keyValuePairsToSet.filter((keyValuePair) => { const [key] = keyValuePair; // Filter out the RAM-only key value pairs, as they should not be saved to storage - return !isRamOnlyKey(key); + return !OnyxKeys.isRamOnlyKey(key); }); return Storage.multiSet(keyValuePairsToStore) @@ -1566,7 +1439,7 @@ function setCollectionWithRetry({collectionKey, if (skippableCollectionMemberIDs.size) { resultCollection = resultCollectionKeys.reduce((result: OnyxInputKeyValueMapping, key) => { try { - const [, collectionMemberID] = OnyxUtils.splitCollectionMemberKey(key, collectionKey); + const [, collectionMemberID] = OnyxKeys.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; @@ -1603,7 +1476,7 @@ function setCollectionWithRetry({collectionKey, const updatePromise = OnyxUtils.scheduleNotifyCollectionSubscribers(collectionKey, mutableCollection, previousCollection); // RAM-only keys are not supposed to be saved to storage - if (isRamOnlyKey(collectionKey)) { + if (OnyxKeys.isRamOnlyKey(collectionKey)) { OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection); return updatePromise; } @@ -1650,7 +1523,7 @@ function mergeCollectionWithPatches( if (skippableCollectionMemberIDs.size) { resultCollection = resultCollectionKeys.reduce((result: OnyxInputKeyValueMapping, key) => { try { - const [, collectionMemberID] = splitCollectionMemberKey(key, collectionKey); + const [, collectionMemberID] = OnyxKeys.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; @@ -1728,12 +1601,12 @@ function mergeCollectionWithPatches( // New keys will be added via multiSet while existing keys will be updated using multiMerge // This is because setting a key that doesn't exist yet with multiMerge will throw errors // We can skip this step for RAM-only keys as they should never be saved to storage - if (!isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) { + if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForExistingCollection.length > 0) { promises.push(Storage.multiMerge(keyValuePairsForExistingCollection)); } // We can skip this step for RAM-only keys as they should never be saved to storage - if (!isRamOnlyKey(collectionKey) && keyValuePairsForNewCollection.length > 0) { + if (!OnyxKeys.isRamOnlyKey(collectionKey) && keyValuePairsForNewCollection.length > 0) { promises.push(Storage.multiSet(keyValuePairsForNewCollection)); } @@ -1787,7 +1660,7 @@ function partialSetCollection({collectionKey, co if (skippableCollectionMemberIDs.size) { resultCollection = resultCollectionKeys.reduce((result: OnyxInputKeyValueMapping, key) => { try { - const [, collectionMemberID] = splitCollectionMemberKey(key, collectionKey); + const [, collectionMemberID] = OnyxKeys.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; @@ -1812,7 +1685,7 @@ function partialSetCollection({collectionKey, co const updatePromise = scheduleNotifyCollectionSubscribers(collectionKey, mutableCollection, previousCollection); - if (isRamOnlyKey(collectionKey)) { + if (OnyxKeys.isRamOnlyKey(collectionKey)) { sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection); return updatePromise; } @@ -1856,18 +1729,11 @@ const OnyxUtils = { sendActionToDevTools, get, getAllKeys, - getCollectionKeys, - isCollectionKey, - isCollectionMemberKey, - isCollectionMember, - splitCollectionMemberKey, - isKeyMatch, tryGetCachedValue, getCachedCollection, keysChanged, keyChanged, sendDataToConnection, - getCollectionKey, getCollectionDataAndSendAsObject, scheduleSubscriberUpdate, scheduleNotifyCollectionSubscribers, @@ -1903,7 +1769,6 @@ const OnyxUtils = { setWithRetry, multiSetWithRetry, setCollectionWithRetry, - isRamOnlyKey, }; GlobalSettings.addGlobalSettingsChangeListener(({enablePerformanceMetrics}) => { @@ -1919,8 +1784,6 @@ GlobalSettings.addGlobalSettingsChangeListener(({enablePerformanceMetrics}) => { // @ts-expect-error Reassign getAllKeys = decorateWithMetrics(getAllKeys, 'OnyxUtils.getAllKeys'); // @ts-expect-error Reassign - getCollectionKeys = decorateWithMetrics(getCollectionKeys, 'OnyxUtils.getCollectionKeys'); - // @ts-expect-error Reassign keysChanged = decorateWithMetrics(keysChanged, 'OnyxUtils.keysChanged'); // @ts-expect-error Reassign keyChanged = decorateWithMetrics(keyChanged, 'OnyxUtils.keyChanged'); diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 5808dcb2d..49a057813 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -5,6 +5,7 @@ import OnyxCache, {TASK} from './OnyxCache'; import type {Connection} from './OnyxConnectionManager'; import connectionManager from './OnyxConnectionManager'; import OnyxUtils from './OnyxUtils'; +import OnyxKeys from './OnyxKeys'; import * as GlobalSettings from './GlobalSettings'; import type {CollectionKeyBase, OnyxKey, OnyxValue} from './types'; import usePrevious from './usePrevious'; @@ -309,7 +310,7 @@ function useOnyx>( onStoreChange(); }, initWithStoredValues: options?.initWithStoredValues, - waitForCollectionCallback: OnyxUtils.isCollectionKey(key) as true, + waitForCollectionCallback: OnyxKeys.isCollectionKey(key) as true, reuseConnection: options?.reuseConnection, }); diff --git a/tests/perf-test/OnyxUtils.perf-test.ts b/tests/perf-test/OnyxUtils.perf-test.ts index 5a00d910e..a26d7d695 100644 --- a/tests/perf-test/OnyxUtils.perf-test.ts +++ b/tests/perf-test/OnyxUtils.perf-test.ts @@ -5,6 +5,7 @@ import type {Selector} from '../../lib'; import Onyx from '../../lib'; import StorageMock from '../../lib/storage'; import OnyxCache from '../../lib/OnyxCache'; +import OnyxKeys from '../../lib/OnyxKeys'; import OnyxUtils, {clearOnyxUtilsInternals} from '../../lib/OnyxUtils'; import type GenericCollection from '../utils/GenericCollection'; import type {OnyxUpdate} from '../../lib/Onyx'; @@ -137,57 +138,57 @@ describe('OnyxUtils', () => { describe('getCollectionKeys', () => { test('one call', async () => { - await measureFunction(() => OnyxUtils.getCollectionKeys()); + await measureFunction(() => OnyxKeys.getCollectionKeys()); }); }); describe('isCollectionKey', () => { test('one call', async () => { - await measureFunction(() => OnyxUtils.isCollectionKey(collectionKey)); + await measureFunction(() => OnyxKeys.isCollectionKey(collectionKey)); }); }); describe('isRamOnlyKey', () => { test('one call for RAM-only key', async () => { - await measureFunction(() => OnyxUtils.isRamOnlyKey(ONYXKEYS.RAM_ONLY_TEST_KEY)); + await measureFunction(() => OnyxKeys.isRamOnlyKey(ONYXKEYS.RAM_ONLY_TEST_KEY)); }); test('one call for RAM-only collection key', async () => { - await measureFunction(() => OnyxUtils.isRamOnlyKey(ONYXKEYS.COLLECTION.RAM_ONLY_TEST_COLLECTION)); + await measureFunction(() => OnyxKeys.isRamOnlyKey(ONYXKEYS.COLLECTION.RAM_ONLY_TEST_COLLECTION)); }); test('one call for RAM-only collection member key', async () => { - await measureFunction(() => OnyxUtils.isRamOnlyKey(`${ONYXKEYS.COLLECTION.RAM_ONLY_TEST_COLLECTION}1`)); + await measureFunction(() => OnyxKeys.isRamOnlyKey(`${ONYXKEYS.COLLECTION.RAM_ONLY_TEST_COLLECTION}1`)); }); }); describe('isCollectionMemberKey', () => { test('one call with correct key', async () => { - await measureFunction(() => OnyxUtils.isCollectionMemberKey(collectionKey, `${collectionKey}entry1`)); + await measureFunction(() => OnyxKeys.isCollectionMemberKey(collectionKey, `${collectionKey}entry1`)); }); test('one call with wrong key', async () => { - await measureFunction(() => OnyxUtils.isCollectionMemberKey(collectionKey, `${ONYXKEYS.COLLECTION.TEST_KEY_2}entry1`)); + await measureFunction(() => OnyxKeys.isCollectionMemberKey(collectionKey, `${ONYXKEYS.COLLECTION.TEST_KEY_2}entry1`)); }); }); describe('splitCollectionMemberKey', () => { test('one call without passing the collection key', async () => { - await measureFunction(() => OnyxUtils.splitCollectionMemberKey(`${collectionKey}entry1`)); + await measureFunction(() => OnyxKeys.splitCollectionMemberKey(`${collectionKey}entry1`)); }); test('one call passing the collection key', async () => { - await measureFunction(() => OnyxUtils.splitCollectionMemberKey(`${collectionKey}entry1`, collectionKey)); + await measureFunction(() => OnyxKeys.splitCollectionMemberKey(`${collectionKey}entry1`, collectionKey)); }); }); describe('isKeyMatch', () => { test('one call passing normal key', async () => { - await measureFunction(() => OnyxUtils.isKeyMatch(ONYXKEYS.TEST_KEY, ONYXKEYS.TEST_KEY_2)); + await measureFunction(() => OnyxKeys.isKeyMatch(ONYXKEYS.TEST_KEY, ONYXKEYS.TEST_KEY_2)); }); test('one call passing collection key', async () => { - await measureFunction(() => OnyxUtils.isKeyMatch(collectionKey, `${collectionKey}entry1`)); + await measureFunction(() => OnyxKeys.isKeyMatch(collectionKey, `${collectionKey}entry1`)); }); }); @@ -388,7 +389,7 @@ describe('OnyxUtils', () => { describe('getCollectionKey', () => { test('one call', async () => { - await measureFunction(() => OnyxUtils.getCollectionKey(`${ONYXKEYS.COLLECTION.TEST_NESTED_NESTED_KEY}entry1`)); + await measureFunction(() => OnyxKeys.getCollectionKey(`${ONYXKEYS.COLLECTION.TEST_NESTED_NESTED_KEY}entry1`)); }); }); diff --git a/tests/unit/OnyxKeysTest.ts b/tests/unit/OnyxKeysTest.ts new file mode 100644 index 000000000..0e5661894 --- /dev/null +++ b/tests/unit/OnyxKeysTest.ts @@ -0,0 +1,374 @@ +import Onyx from '../../lib'; +import OnyxKeys from '../../lib/OnyxKeys'; + +const ONYXKEYS = { + TEST_KEY: 'test', + COLLECTION: { + TEST_KEY: 'test_', + TEST_LEVEL_KEY: 'test_level_', + TEST_LEVEL_LAST_KEY: 'test_level_last_', + ROUTES: 'routes_', + RAM_ONLY_COLLECTION: 'ramOnlyCollection_', + }, + RAM_ONLY_KEY: 'ramOnlyKey', +}; + +describe('OnyxKeys', () => { + beforeAll(() => + Onyx.init({ + keys: ONYXKEYS, + ramOnlyKeys: [ONYXKEYS.RAM_ONLY_KEY, ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION], + }), + ); + + beforeEach(() => Onyx.clear()); + + afterEach(() => jest.clearAllMocks()); + + describe('splitCollectionMemberKey', () => { + describe('should return correct values', () => { + const dataResult: Record = { + // Collection key with no member ID + test_: ['test_', ''], + // Nested collection key with no member ID + test_level_: ['test_level_', ''], + // Nested collection keys with member IDs + test_level_1: ['test_level_', '1'], + test_level_2: ['test_level_', '2'], + // Deeply nested collection member key, matches longest prefix + test_level_last_3: ['test_level_last_', '3'], + // Underscores in the ID portion, only the first matching prefix is the collection key + test___FAKE__: ['test_', '__FAKE__'], + // Negative/compound IDs, the collection is 'test_', everything after is the ID + 'test_-1_something': ['test_', '-1_something'], + // Nested collection with compound ID, 'test_level_' is the longest matching collection + 'test_level_-1_something': ['test_level_', '-1_something'], + }; + + it.each(Object.keys(dataResult))('%s', (key) => { + const [collectionKey, id] = OnyxKeys.splitCollectionMemberKey(key); + expect(collectionKey).toEqual(dataResult[key][0]); + expect(id).toEqual(dataResult[key][1]); + }); + }); + + it('should throw error if key does not contain underscore', () => { + expect(() => { + OnyxKeys.splitCollectionMemberKey(ONYXKEYS.TEST_KEY); + }).toThrow("Invalid 'test' key provided, only collection keys are allowed."); + expect(() => { + OnyxKeys.splitCollectionMemberKey(''); + }).toThrow("Invalid '' key provided, only collection keys are allowed."); + }); + + it('should allow passing the collection key beforehand for performance gains', () => { + const [collectionKey, id] = OnyxKeys.splitCollectionMemberKey(`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, ONYXKEYS.COLLECTION.TEST_KEY); + expect(collectionKey).toEqual(ONYXKEYS.COLLECTION.TEST_KEY); + expect(id).toEqual('id1'); + }); + + it("should throw error if the passed collection key isn't compatible with the key", () => { + expect(() => { + OnyxKeys.splitCollectionMemberKey(`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, ONYXKEYS.COLLECTION.TEST_LEVEL_KEY); + }).toThrow("Invalid 'test_level_' collection key provided, it isn't compatible with 'test_id1' key."); + }); + }); + + describe('getCollectionKey', () => { + describe('should return correct values', () => { + const dataResult: Record = { + test_: 'test_', + test_level_: 'test_level_', + test_level_1: 'test_level_', + test_level_2: 'test_level_', + test_level_last_3: 'test_level_last_', + test___FAKE__: 'test_', + 'test_-1_something': 'test_', + 'test_level_-1_something': 'test_level_', + }; + + it.each(Object.keys(dataResult))('%s', (key) => { + const collectionKey = OnyxKeys.getCollectionKey(key); + expect(collectionKey).toEqual(dataResult[key]); + }); + }); + + it('should return undefined if key does not contain underscore', () => { + expect(OnyxKeys.getCollectionKey(ONYXKEYS.TEST_KEY)).toBeUndefined(); + expect(OnyxKeys.getCollectionKey('')).toBeUndefined(); + }); + }); + + describe('isCollectionMember', () => { + it('should return true for collection member keys', () => { + expect(OnyxKeys.isCollectionMember(`${ONYXKEYS.COLLECTION.TEST_KEY}123`)).toBe(true); + expect(OnyxKeys.isCollectionMember(`${ONYXKEYS.COLLECTION.TEST_LEVEL_KEY}456`)).toBe(true); + expect(OnyxKeys.isCollectionMember(`${ONYXKEYS.COLLECTION.TEST_LEVEL_LAST_KEY}789`)).toBe(true); + expect(OnyxKeys.isCollectionMember(`${ONYXKEYS.COLLECTION.TEST_KEY}-1_something`)).toBe(true); + expect(OnyxKeys.isCollectionMember(`${ONYXKEYS.COLLECTION.ROUTES}abc`)).toBe(true); + }); + + it('should return false for collection keys themselves', () => { + expect(OnyxKeys.isCollectionMember(ONYXKEYS.COLLECTION.TEST_KEY)).toBe(false); + expect(OnyxKeys.isCollectionMember(ONYXKEYS.COLLECTION.TEST_LEVEL_KEY)).toBe(false); + expect(OnyxKeys.isCollectionMember(ONYXKEYS.COLLECTION.TEST_LEVEL_LAST_KEY)).toBe(false); + expect(OnyxKeys.isCollectionMember(ONYXKEYS.COLLECTION.ROUTES)).toBe(false); + }); + + it('should return false for non-collection keys', () => { + expect(OnyxKeys.isCollectionMember(ONYXKEYS.TEST_KEY)).toBe(false); + expect(OnyxKeys.isCollectionMember('someRegularKey')).toBe(false); + expect(OnyxKeys.isCollectionMember('notACollection')).toBe(false); + expect(OnyxKeys.isCollectionMember('')).toBe(false); + }); + + it('should return false for invalid keys', () => { + expect(OnyxKeys.isCollectionMember('invalid_key_123')).toBe(false); + expect(OnyxKeys.isCollectionMember('notregistered_')).toBe(false); + expect(OnyxKeys.isCollectionMember('notregistered_123')).toBe(false); + }); + }); + + describe('isCollectionKey', () => { + it('should return true for registered collection keys', () => { + expect(OnyxKeys.isCollectionKey(ONYXKEYS.COLLECTION.TEST_KEY)).toBe(true); + expect(OnyxKeys.isCollectionKey(ONYXKEYS.COLLECTION.TEST_LEVEL_KEY)).toBe(true); + expect(OnyxKeys.isCollectionKey(ONYXKEYS.COLLECTION.ROUTES)).toBe(true); + }); + + it('should return false for non-collection keys', () => { + expect(OnyxKeys.isCollectionKey(ONYXKEYS.TEST_KEY)).toBe(false); + expect(OnyxKeys.isCollectionKey('')).toBe(false); + }); + + it('should return false for collection member keys', () => { + expect(OnyxKeys.isCollectionKey(`${ONYXKEYS.COLLECTION.TEST_KEY}123`)).toBe(false); + expect(OnyxKeys.isCollectionKey(`${ONYXKEYS.COLLECTION.ROUTES}abc`)).toBe(false); + }); + }); + + describe('isCollectionMemberKey', () => { + it('should return true when key starts with collection key and is longer', () => { + expect(OnyxKeys.isCollectionMemberKey(ONYXKEYS.COLLECTION.TEST_KEY, `${ONYXKEYS.COLLECTION.TEST_KEY}123`)).toBe(true); + expect(OnyxKeys.isCollectionMemberKey(ONYXKEYS.COLLECTION.TEST_LEVEL_KEY, `${ONYXKEYS.COLLECTION.TEST_LEVEL_KEY}456`)).toBe(true); + expect(OnyxKeys.isCollectionMemberKey(ONYXKEYS.COLLECTION.ROUTES, `${ONYXKEYS.COLLECTION.ROUTES}abc`)).toBe(true); + }); + + it('should return false when key equals the collection key exactly', () => { + expect(OnyxKeys.isCollectionMemberKey(ONYXKEYS.COLLECTION.TEST_KEY, ONYXKEYS.COLLECTION.TEST_KEY)).toBe(false); + expect(OnyxKeys.isCollectionMemberKey(ONYXKEYS.COLLECTION.ROUTES, ONYXKEYS.COLLECTION.ROUTES)).toBe(false); + }); + + it('should return false when key does not start with collection key', () => { + expect(OnyxKeys.isCollectionMemberKey(ONYXKEYS.COLLECTION.TEST_KEY, `${ONYXKEYS.COLLECTION.ROUTES}123`)).toBe(false); + expect(OnyxKeys.isCollectionMemberKey(ONYXKEYS.COLLECTION.TEST_LEVEL_KEY, `${ONYXKEYS.COLLECTION.TEST_KEY}123`)).toBe(false); + }); + }); + + describe('isKeyMatch', () => { + it('should match exact non-collection keys', () => { + expect(OnyxKeys.isKeyMatch(ONYXKEYS.TEST_KEY, ONYXKEYS.TEST_KEY)).toBe(true); + }); + + it('should not match different non-collection keys', () => { + expect(OnyxKeys.isKeyMatch(ONYXKEYS.TEST_KEY, ONYXKEYS.RAM_ONLY_KEY)).toBe(false); + }); + + it('should match collection key as prefix of member key', () => { + expect(OnyxKeys.isKeyMatch(ONYXKEYS.COLLECTION.TEST_KEY, `${ONYXKEYS.COLLECTION.TEST_KEY}123`)).toBe(true); + expect(OnyxKeys.isKeyMatch(ONYXKEYS.COLLECTION.ROUTES, `${ONYXKEYS.COLLECTION.ROUTES}abc`)).toBe(true); + }); + + it('should match collection key against itself', () => { + expect(OnyxKeys.isKeyMatch(ONYXKEYS.COLLECTION.TEST_KEY, ONYXKEYS.COLLECTION.TEST_KEY)).toBe(true); + }); + + it('should not match collection key against unrelated key', () => { + expect(OnyxKeys.isKeyMatch(ONYXKEYS.COLLECTION.TEST_KEY, `${ONYXKEYS.COLLECTION.ROUTES}123`)).toBe(false); + }); + }); + + describe('getCollectionKeys', () => { + it('should return the set of registered collection keys', () => { + const keys = OnyxKeys.getCollectionKeys(); + expect(keys.has(ONYXKEYS.COLLECTION.TEST_KEY)).toBe(true); + expect(keys.has(ONYXKEYS.COLLECTION.ROUTES)).toBe(true); + expect(keys.has(ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION)).toBe(true); + }); + + it('should not contain non-collection keys', () => { + const keys = OnyxKeys.getCollectionKeys(); + expect(keys.has(ONYXKEYS.TEST_KEY)).toBe(false); + expect(keys.has(ONYXKEYS.RAM_ONLY_KEY)).toBe(false); + }); + }); + + describe('registerMemberKey / deregisterMemberKey / getMembersOfCollection', () => { + it('should register a member key and make it retrievable via getMembersOfCollection', () => { + const memberKey = `${ONYXKEYS.COLLECTION.TEST_KEY}newKey1`; + OnyxKeys.registerMemberKey(memberKey); + + const members = OnyxKeys.getMembersOfCollection(ONYXKEYS.COLLECTION.TEST_KEY); + expect(members).toBeDefined(); + expect(members?.has(memberKey)).toBe(true); + + // Clean up + OnyxKeys.deregisterMemberKey(memberKey); + }); + + it('should resolve to the most specific (longest) collection key for overlapping prefixes', () => { + // 'test_level_' and 'test_' both match 'test_level_1', but 'test_level_' is more specific + const memberKey = `${ONYXKEYS.COLLECTION.TEST_LEVEL_KEY}1`; + OnyxKeys.registerMemberKey(memberKey); + + expect(OnyxKeys.getCollectionKey(memberKey)).toBe(ONYXKEYS.COLLECTION.TEST_LEVEL_KEY); + + const testLevelMembers = OnyxKeys.getMembersOfCollection(ONYXKEYS.COLLECTION.TEST_LEVEL_KEY); + expect(testLevelMembers?.has(memberKey)).toBe(true); + + // Should NOT be registered under the shorter 'test_' collection + const testMembers = OnyxKeys.getMembersOfCollection(ONYXKEYS.COLLECTION.TEST_KEY); + expect(testMembers?.has(memberKey)).toBeFalsy(); + + // Clean up + OnyxKeys.deregisterMemberKey(memberKey); + }); + + it('should populate the reverse lookup so getCollectionKey returns O(1)', () => { + const memberKey = `${ONYXKEYS.COLLECTION.ROUTES}xyz`; + OnyxKeys.registerMemberKey(memberKey); + + expect(OnyxKeys.getCollectionKey(memberKey)).toBe(ONYXKEYS.COLLECTION.ROUTES); + + // Clean up + OnyxKeys.deregisterMemberKey(memberKey); + }); + + it('should not register keys that do not belong to any collection', () => { + OnyxKeys.registerMemberKey('unknownKey'); + + expect(OnyxKeys.getCollectionKey('unknownKey')).toBeUndefined(); + expect(OnyxKeys.getMembersOfCollection('unknownKey')).toBeUndefined(); + }); + + it('should deregister a member key from both forward and reverse maps', () => { + const memberKey = `${ONYXKEYS.COLLECTION.TEST_KEY}toRemove`; + OnyxKeys.registerMemberKey(memberKey); + expect(OnyxKeys.getMembersOfCollection(ONYXKEYS.COLLECTION.TEST_KEY)?.has(memberKey)).toBe(true); + + OnyxKeys.deregisterMemberKey(memberKey); + expect(OnyxKeys.getMembersOfCollection(ONYXKEYS.COLLECTION.TEST_KEY)?.has(memberKey)).toBeFalsy(); + }); + + it('should handle registering the same key twice without duplicates', () => { + const memberKey = `${ONYXKEYS.COLLECTION.TEST_KEY}duplicate`; + OnyxKeys.registerMemberKey(memberKey); + OnyxKeys.registerMemberKey(memberKey); + + const members = OnyxKeys.getMembersOfCollection(ONYXKEYS.COLLECTION.TEST_KEY); + const count = Array.from(members ?? []).filter((k) => k === memberKey).length; + expect(count).toBe(1); + + // Clean up + OnyxKeys.deregisterMemberKey(memberKey); + }); + + it('should register multiple members and return all via getMembersOfCollection', () => { + const key1 = `${ONYXKEYS.COLLECTION.ROUTES}a`; + const key2 = `${ONYXKEYS.COLLECTION.ROUTES}b`; + const key3 = `${ONYXKEYS.COLLECTION.ROUTES}c`; + + OnyxKeys.registerMemberKey(key1); + OnyxKeys.registerMemberKey(key2); + OnyxKeys.registerMemberKey(key3); + + const members = OnyxKeys.getMembersOfCollection(ONYXKEYS.COLLECTION.ROUTES); + expect(members).toBeDefined(); + expect(members?.size).toBe(3); + expect(members?.has(key1)).toBe(true); + expect(members?.has(key2)).toBe(true); + expect(members?.has(key3)).toBe(true); + + // Clean up + OnyxKeys.deregisterMemberKey(key1); + OnyxKeys.deregisterMemberKey(key2); + OnyxKeys.deregisterMemberKey(key3); + }); + + it('should only remove the deregistered member and keep the rest', () => { + const key1 = `${ONYXKEYS.COLLECTION.ROUTES}keep1`; + const key2 = `${ONYXKEYS.COLLECTION.ROUTES}remove`; + const key3 = `${ONYXKEYS.COLLECTION.ROUTES}keep2`; + + OnyxKeys.registerMemberKey(key1); + OnyxKeys.registerMemberKey(key2); + OnyxKeys.registerMemberKey(key3); + + OnyxKeys.deregisterMemberKey(key2); + + const members = OnyxKeys.getMembersOfCollection(ONYXKEYS.COLLECTION.ROUTES); + expect(members?.size).toBe(2); + expect(members?.has(key1)).toBe(true); + expect(members?.has(key2)).toBe(false); + expect(members?.has(key3)).toBe(true); + + // Clean up + OnyxKeys.deregisterMemberKey(key1); + OnyxKeys.deregisterMemberKey(key3); + }); + + it('should track members across different collections independently', () => { + const testKey = `${ONYXKEYS.COLLECTION.TEST_KEY}member1`; + const routeKey = `${ONYXKEYS.COLLECTION.ROUTES}member1`; + + OnyxKeys.registerMemberKey(testKey); + OnyxKeys.registerMemberKey(routeKey); + + const testMembers = OnyxKeys.getMembersOfCollection(ONYXKEYS.COLLECTION.TEST_KEY); + const routeMembers = OnyxKeys.getMembersOfCollection(ONYXKEYS.COLLECTION.ROUTES); + + expect(testMembers?.size).toBe(1); + expect(testMembers?.has(testKey)).toBe(true); + expect(testMembers?.has(routeKey)).toBe(false); + expect(routeMembers?.size).toBe(1); + expect(routeMembers?.has(routeKey)).toBe(true); + expect(routeMembers?.has(testKey)).toBe(false); + + // Clean up + OnyxKeys.deregisterMemberKey(testKey); + OnyxKeys.deregisterMemberKey(routeKey); + }); + + it('should handle deregistering a key that was never registered', () => { + expect(() => { + OnyxKeys.deregisterMemberKey(`${ONYXKEYS.COLLECTION.TEST_KEY}neverRegistered`); + }).not.toThrow(); + }); + }); + + describe('isRamOnlyKey', () => { + it('should return true for RAM-only key', () => { + expect(OnyxKeys.isRamOnlyKey(ONYXKEYS.RAM_ONLY_KEY)).toBeTruthy(); + }); + + it('should return true for RAM-only collection', () => { + expect(OnyxKeys.isRamOnlyKey(ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION)).toBeTruthy(); + }); + + it('should return true for RAM-only collection member', () => { + expect(OnyxKeys.isRamOnlyKey(`${ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION}1`)).toBeTruthy(); + }); + + it('should return false for a normal key', () => { + expect(OnyxKeys.isRamOnlyKey(ONYXKEYS.TEST_KEY)).toBeFalsy(); + }); + + it('should return false for normal collection', () => { + expect(OnyxKeys.isRamOnlyKey(ONYXKEYS.COLLECTION.TEST_KEY)).toBeFalsy(); + }); + + it('should return false for normal collection member', () => { + expect(OnyxKeys.isRamOnlyKey(`${ONYXKEYS.COLLECTION.TEST_KEY}1`)).toBeFalsy(); + }); + }); +}); diff --git a/tests/unit/OnyxSnapshotCacheTest.ts b/tests/unit/OnyxSnapshotCacheTest.ts index 8c4faa19d..21c6e5238 100644 --- a/tests/unit/OnyxSnapshotCacheTest.ts +++ b/tests/unit/OnyxSnapshotCacheTest.ts @@ -1,15 +1,15 @@ import type {OnyxKey} from '../../lib'; import {OnyxSnapshotCache} from '../../lib/OnyxSnapshotCache'; -import OnyxUtils from '../../lib/OnyxUtils'; +import OnyxKeys from '../../lib/OnyxKeys'; import type {UseOnyxOptions, UseOnyxResult, UseOnyxSelector} from '../../lib/useOnyx'; -// Mock OnyxUtils for testing -jest.mock('../../lib/OnyxUtils', () => ({ +// Mock OnyxKeys for testing +jest.mock('../../lib/OnyxKeys', () => ({ isCollectionKey: jest.fn(), getCollectionKey: jest.fn(), })); -const mockedOnyxUtils = OnyxUtils as jest.Mocked; +const mockedOnyxKeys = OnyxKeys as jest.Mocked; // Test types type TestData = { @@ -153,8 +153,8 @@ describe('OnyxSnapshotCache', () => { }); it('should invalidate non-collection keys without affecting others', () => { - mockedOnyxUtils.isCollectionKey.mockReturnValue(false); - mockedOnyxUtils.getCollectionKey.mockReturnValue(undefined); + mockedOnyxKeys.isCollectionKey.mockReturnValue(false); + mockedOnyxKeys.getCollectionKey.mockReturnValue(undefined); cache.invalidateForKey('nonCollectionKey'); @@ -170,8 +170,8 @@ describe('OnyxSnapshotCache', () => { }); it('should invalidate collection member key and its base collection only', () => { - mockedOnyxUtils.isCollectionKey.mockReturnValue(true); - mockedOnyxUtils.getCollectionKey.mockReturnValue('reports_'); + mockedOnyxKeys.isCollectionKey.mockReturnValue(true); + mockedOnyxKeys.getCollectionKey.mockReturnValue('reports_'); cache.invalidateForKey('reports_123'); @@ -189,8 +189,8 @@ describe('OnyxSnapshotCache', () => { }); it('should invalidate collection base key without cascading to members', () => { - mockedOnyxUtils.isCollectionKey.mockReturnValue(true); - mockedOnyxUtils.getCollectionKey.mockReturnValue('reports_'); + mockedOnyxKeys.isCollectionKey.mockReturnValue(true); + mockedOnyxKeys.getCollectionKey.mockReturnValue('reports_'); // When base key equals the key to invalidate, it's a collection base key cache.invalidateForKey('reports_'); @@ -210,13 +210,13 @@ describe('OnyxSnapshotCache', () => { it('should handle multiple different collection keys independently', () => { // Invalidate reports collection member - mockedOnyxUtils.isCollectionKey.mockReturnValueOnce(true); - mockedOnyxUtils.getCollectionKey.mockReturnValueOnce('reports_'); + mockedOnyxKeys.isCollectionKey.mockReturnValueOnce(true); + mockedOnyxKeys.getCollectionKey.mockReturnValueOnce('reports_'); cache.invalidateForKey('reports_123'); // Invalidate users collection member - mockedOnyxUtils.isCollectionKey.mockReturnValueOnce(true); - mockedOnyxUtils.getCollectionKey.mockReturnValueOnce('users_'); + mockedOnyxKeys.isCollectionKey.mockReturnValueOnce(true); + mockedOnyxKeys.getCollectionKey.mockReturnValueOnce('users_'); cache.invalidateForKey('users_789'); // Reports: member and base should be invalidated diff --git a/tests/unit/onyxCacheTest.tsx b/tests/unit/onyxCacheTest.tsx index 1d1656dca..fdaed1d51 100644 --- a/tests/unit/onyxCacheTest.tsx +++ b/tests/unit/onyxCacheTest.tsx @@ -1,6 +1,7 @@ import type OnyxInstance from '../../lib/Onyx'; import type OnyxCache from '../../lib/OnyxCache'; import type {CacheTask} from '../../lib/OnyxCache'; +import type OnyxKeysType from '../../lib/OnyxKeys'; import type {Connection} from '../../lib/OnyxConnectionManager'; import type MockedStorage from '../../lib/storage/__mocks__'; import type {InitOptions} from '../../lib/types'; @@ -419,6 +420,7 @@ describe('Onyx', () => { describe('Onyx with Cache', () => { let Onyx: typeof OnyxInstance; let StorageMock: typeof MockedStorage; + let OnyxKeys: typeof OnyxKeysType; /** @type OnyxCache */ let cache: typeof OnyxCache; @@ -455,6 +457,8 @@ describe('Onyx', () => { StorageMock = require('../../lib/storage').default; cache = require('../../lib/OnyxCache').default; + + OnyxKeys = require('../../lib/OnyxKeys').default; }); it('Should keep recently accessed items in cache', () => { @@ -533,17 +537,20 @@ describe('Onyx', () => { it('Should prioritize eviction of evictableKeys over non-evictable keys when cache limit is reached', () => { const testKeys = { ...ONYX_KEYS, - SAFE_FOR_EVICTION: 'evictable_', - NOT_SAFE_FOR_EVICTION: 'critical_', + COLLECTION: { + ...ONYX_KEYS.COLLECTION, + SAFE_FOR_EVICTION: 'evictable_', + NOT_SAFE_FOR_EVICTION: 'critical_', + }, }; - const criticalKey1 = `${testKeys.NOT_SAFE_FOR_EVICTION}1`; - const criticalKey2 = `${testKeys.NOT_SAFE_FOR_EVICTION}2`; - const criticalKey3 = `${testKeys.NOT_SAFE_FOR_EVICTION}3`; - const evictableKey1 = `${testKeys.SAFE_FOR_EVICTION}1`; - const evictableKey2 = `${testKeys.SAFE_FOR_EVICTION}2`; - const evictableKey3 = `${testKeys.SAFE_FOR_EVICTION}3`; - const triggerKey = `${testKeys.SAFE_FOR_EVICTION}trigger`; + const criticalKey1 = `${testKeys.COLLECTION.NOT_SAFE_FOR_EVICTION}1`; + const criticalKey2 = `${testKeys.COLLECTION.NOT_SAFE_FOR_EVICTION}2`; + const criticalKey3 = `${testKeys.COLLECTION.NOT_SAFE_FOR_EVICTION}3`; + const evictableKey1 = `${testKeys.COLLECTION.SAFE_FOR_EVICTION}1`; + const evictableKey2 = `${testKeys.COLLECTION.SAFE_FOR_EVICTION}2`; + const evictableKey3 = `${testKeys.COLLECTION.SAFE_FOR_EVICTION}3`; + const triggerKey = `${testKeys.COLLECTION.SAFE_FOR_EVICTION}trigger`; StorageMock.getItem.mockResolvedValue('"mockValue"'); const allKeys = [ @@ -562,7 +569,7 @@ describe('Onyx', () => { return initOnyx({ keys: testKeys, maxCachedKeysCount: 3, - evictableKeys: [testKeys.SAFE_FOR_EVICTION], + evictableKeys: [testKeys.COLLECTION.SAFE_FOR_EVICTION], }) .then(() => { // Verify keys are correctly identified as evictable or not @@ -609,16 +616,19 @@ describe('Onyx', () => { it('Should not evict non-evictable keys even when cache limit is exceeded', () => { const testKeys = { ...ONYX_KEYS, - SAFE_FOR_EVICTION: 'evictable_', - NOT_SAFE_FOR_EVICTION: 'critical_', + COLLECTION: { + ...ONYX_KEYS.COLLECTION, + SAFE_FOR_EVICTION: 'evictable_', + NOT_SAFE_FOR_EVICTION: 'critical_', + }, }; - const criticalKey1 = `${testKeys.NOT_SAFE_FOR_EVICTION}1`; - const criticalKey2 = `${testKeys.NOT_SAFE_FOR_EVICTION}2`; - const criticalKey3 = `${testKeys.NOT_SAFE_FOR_EVICTION}3`; - const evictableKey1 = `${testKeys.SAFE_FOR_EVICTION}1`; + const criticalKey1 = `${testKeys.COLLECTION.NOT_SAFE_FOR_EVICTION}1`; + const criticalKey2 = `${testKeys.COLLECTION.NOT_SAFE_FOR_EVICTION}2`; + const criticalKey3 = `${testKeys.COLLECTION.NOT_SAFE_FOR_EVICTION}3`; + const evictableKey1 = `${testKeys.COLLECTION.SAFE_FOR_EVICTION}1`; // Additional trigger key for natural eviction - const triggerKey = `${testKeys.SAFE_FOR_EVICTION}trigger`; + const triggerKey = `${testKeys.COLLECTION.SAFE_FOR_EVICTION}trigger`; StorageMock.getItem.mockResolvedValue('"mockValue"'); const allKeys = [ @@ -634,7 +644,7 @@ describe('Onyx', () => { return initOnyx({ keys: testKeys, maxCachedKeysCount: 2, - evictableKeys: [testKeys.SAFE_FOR_EVICTION], + evictableKeys: [testKeys.COLLECTION.SAFE_FOR_EVICTION], }) .then(() => { Onyx.connect({key: criticalKey1, callback: jest.fn()}); // Should never be evicted @@ -758,9 +768,9 @@ describe('Onyx', () => { keys: testKeys, ramOnlyKeys: [testKeys.COLLECTION.RAM_ONLY_COLLECTION, testKeys.RAM_ONLY_KEY], }).then(() => { - expect(cache.isRamOnlyKey(testKeys.RAM_ONLY_KEY)).toBeTruthy(); - expect(cache.isRamOnlyKey(testKeys.COLLECTION.RAM_ONLY_COLLECTION)).toBeTruthy(); - expect(cache.isRamOnlyKey(testKeys.TEST_KEY)).toBeFalsy(); + expect(OnyxKeys.isRamOnlyKey(testKeys.RAM_ONLY_KEY)).toBeTruthy(); + expect(OnyxKeys.isRamOnlyKey(testKeys.COLLECTION.RAM_ONLY_COLLECTION)).toBeTruthy(); + expect(OnyxKeys.isRamOnlyKey(testKeys.TEST_KEY)).toBeFalsy(); }); }); }); diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts index 07ed29beb..bd4d1a3a3 100644 --- a/tests/unit/onyxUtilsTest.ts +++ b/tests/unit/onyxUtilsTest.ts @@ -94,48 +94,6 @@ describe('OnyxUtils', () => { afterEach(() => jest.clearAllMocks()); - describe('splitCollectionMemberKey', () => { - describe('should return correct values', () => { - const dataResult: Record = { - test_: ['test_', ''], - test_level_: ['test_level_', ''], - test_level_1: ['test_level_', '1'], - test_level_2: ['test_level_', '2'], - test_level_last_3: ['test_level_last_', '3'], - test___FAKE__: ['test_', '__FAKE__'], - 'test_-1_something': ['test_', '-1_something'], - 'test_level_-1_something': ['test_level_', '-1_something'], - }; - - it.each(Object.keys(dataResult))('%s', (key) => { - const [collectionKey, id] = OnyxUtils.splitCollectionMemberKey(key); - expect(collectionKey).toEqual(dataResult[key][0]); - expect(id).toEqual(dataResult[key][1]); - }); - }); - - it('should throw error if key does not contain underscore', () => { - expect(() => { - OnyxUtils.splitCollectionMemberKey(ONYXKEYS.TEST_KEY); - }).toThrowError("Invalid 'test' key provided, only collection keys are allowed."); - expect(() => { - OnyxUtils.splitCollectionMemberKey(''); - }).toThrowError("Invalid '' key provided, only collection keys are allowed."); - }); - - it('should allow passing the collection key beforehand for performance gains', () => { - const [collectionKey, id] = OnyxUtils.splitCollectionMemberKey(`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, ONYXKEYS.COLLECTION.TEST_KEY); - expect(collectionKey).toEqual(ONYXKEYS.COLLECTION.TEST_KEY); - expect(id).toEqual('id1'); - }); - - it("should throw error if the passed collection key isn't compatible with the key", () => { - expect(() => { - OnyxUtils.splitCollectionMemberKey(`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, ONYXKEYS.COLLECTION.TEST_LEVEL_KEY); - }).toThrowError("Invalid 'test_level_' collection key provided, it isn't compatible with 'test_id1' key."); - }); - }); - describe('partialSetCollection', () => { beforeEach(() => { Onyx.clear(); @@ -323,61 +281,6 @@ describe('OnyxUtils', () => { }); }); - describe('getCollectionKey', () => { - describe('should return correct values', () => { - const dataResult: Record = { - test_: 'test_', - test_level_: 'test_level_', - test_level_1: 'test_level_', - test_level_2: 'test_level_', - test_level_last_3: 'test_level_last_', - test___FAKE__: 'test_', - 'test_-1_something': 'test_', - 'test_level_-1_something': 'test_level_', - }; - - it.each(Object.keys(dataResult))('%s', (key) => { - const collectionKey = OnyxUtils.getCollectionKey(key); - expect(collectionKey).toEqual(dataResult[key]); - }); - }); - - it('should return undefined if key does not contain underscore', () => { - expect(OnyxUtils.getCollectionKey(ONYXKEYS.TEST_KEY)).toBeUndefined(); - expect(OnyxUtils.getCollectionKey('')).toBeUndefined(); - }); - }); - - describe('isCollectionMember', () => { - it('should return true for collection member keys', () => { - expect(OnyxUtils.isCollectionMember('test_123')).toBe(true); - expect(OnyxUtils.isCollectionMember('test_level_456')).toBe(true); - expect(OnyxUtils.isCollectionMember('test_level_last_789')).toBe(true); - expect(OnyxUtils.isCollectionMember('test_-1_something')).toBe(true); - expect(OnyxUtils.isCollectionMember('routes_abc')).toBe(true); - }); - - it('should return false for collection keys themselves', () => { - expect(OnyxUtils.isCollectionMember('test_')).toBe(false); - expect(OnyxUtils.isCollectionMember('test_level_')).toBe(false); - expect(OnyxUtils.isCollectionMember('test_level_last_')).toBe(false); - expect(OnyxUtils.isCollectionMember('routes_')).toBe(false); - }); - - it('should return false for non-collection keys', () => { - expect(OnyxUtils.isCollectionMember('test')).toBe(false); - expect(OnyxUtils.isCollectionMember('someRegularKey')).toBe(false); - expect(OnyxUtils.isCollectionMember('notACollection')).toBe(false); - expect(OnyxUtils.isCollectionMember('')).toBe(false); - }); - - it('should return false for invalid keys', () => { - expect(OnyxUtils.isCollectionMember('invalid_key_123')).toBe(false); - expect(OnyxUtils.isCollectionMember('notregistered_')).toBe(false); - expect(OnyxUtils.isCollectionMember('notregistered_123')).toBe(false); - }); - }); - describe('mergeChanges', () => { it("should return the last change if it's an array", () => { const {result} = OnyxUtils.mergeChanges([...testMergeChanges, [0, 1, 2]], testObject); @@ -492,32 +395,6 @@ describe('OnyxUtils', () => { }); }); - describe('isRamOnlyKey', () => { - it('should return true for RAM-only key', () => { - expect(OnyxUtils.isRamOnlyKey(ONYXKEYS.RAM_ONLY_KEY)).toBeTruthy(); - }); - - it('should return true for RAM-only collection', () => { - expect(OnyxUtils.isRamOnlyKey(ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION)).toBeTruthy(); - }); - - it('should return true for RAM-only collection member', () => { - expect(OnyxUtils.isRamOnlyKey(`${ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION}1`)).toBeTruthy(); - }); - - it('should return false for a normal key', () => { - expect(OnyxUtils.isRamOnlyKey(ONYXKEYS.TEST_KEY)).toBeFalsy(); - }); - - it('should return false for normal collection', () => { - expect(OnyxUtils.isRamOnlyKey(ONYXKEYS.COLLECTION.TEST_KEY)).toBeFalsy(); - }); - - it('should return false for normal collection member', () => { - expect(OnyxUtils.isRamOnlyKey(`${ONYXKEYS.COLLECTION.TEST_KEY}1`)).toBeFalsy(); - }); - }); - describe('afterInit', () => { beforeEach(() => { // Resets the deferred init task before each test.