From 55323a0f397f360476bfd26921e7d63de6e4b730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Wed, 4 Mar 2026 10:47:03 +0000 Subject: [PATCH 1/9] Phase 1: Centralize key utility functions into OnyxKeys module and add frozen collection snapshots for structural sharing --- API-INTERNAL.md | 145 +++-------------- lib/Onyx.ts | 19 +-- lib/OnyxCache.ts | 206 +++++++++++++----------- lib/OnyxConnectionManager.ts | 3 +- lib/OnyxKeys.ts | 186 ++++++++++++++++++++++ lib/OnyxMerge/index.native.ts | 3 +- lib/OnyxMerge/index.ts | 3 +- lib/OnyxSnapshotCache.ts | 4 +- lib/OnyxUtils.ts | 211 +++++-------------------- lib/useOnyx.ts | 9 +- tests/perf-test/OnyxUtils.perf-test.ts | 25 +-- tests/unit/OnyxSnapshotCacheTest.ts | 35 ++-- tests/unit/onyxCacheTest.tsx | 35 ++-- tests/unit/onyxUtilsTest.ts | 61 +++---- 14 files changed, 468 insertions(+), 477 deletions(-) create mode 100644 lib/OnyxKeys.ts 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_"]

-
    -
  • isRamOnlyKey("ramOnlyKey") would return true
  • -
  • isRamOnlyKey("ramOnlyCollection_") would return true
  • -
  • isRamOnlyKey("ramOnlyCollection_1") would return true
  • -
  • isRamOnlyKey("someOtherKey") would return false
  • -
-
-
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:

-
    -
  • 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_"
  • -
-
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/Onyx.ts b/lib/Onyx.ts index a508c4043..b34a11d4d 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); }); @@ -83,7 +84,7 @@ function init({ OnyxUtils.initStoreValues(keys, initialKeyStates, evictableKeys); // Initialize all of our keys with data provided then give green light to any pending connections - Promise.all([cache.addEvictableKeysToRecentlyAccessedList(OnyxUtils.isCollectionKey, OnyxUtils.getAllKeys), OnyxUtils.initializeWithDefaultKeyStates()]).then( + Promise.all([cache.addEvictableKeysToRecentlyAccessedList(OnyxKeys.isCollectionKey, OnyxUtils.getAllKeys), OnyxUtils.initializeWithDefaultKeyStates()]).then( OnyxUtils.getDeferredInitTask().resolve, ); } @@ -201,7 +202,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 @@ -352,7 +353,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]) { @@ -387,7 +388,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.includes(key) && !OnyxUtils.isRamOnlyKey(key)) + .filter((key) => !keysToPreserve.includes(key) && !OnyxKeys.isRamOnlyKey(key)) .reduce((obj: KeyValueMapping, key) => { // eslint-disable-next-line no-param-reassign obj[key] = defaultKeyStates[key]; @@ -490,8 +491,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..61761b179 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -2,8 +2,15 @@ import {deepEqual} from 'fast-equals'; 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 type {KeyValueMapping, NonUndefined, OnyxCollection, OnyxKey, OnyxValue} from './types'; +import OnyxKeys from './OnyxKeys'; + +type CollectionSnapshot = { + /** Monotonically increasing version number, bumped on any member change */ + version: number; + /** Frozen object containing all collection members — safe to return by reference */ + snapshot: Readonly>>; +}; // Task constants const TASK = { @@ -31,9 +38,6 @@ class OnyxCache { /** A map of cached values */ private storageMap: Record>; - /** Cache of complete collection data objects for O(1) retrieval */ - private collectionData: Record>>; - /** * Captured pending tasks for already running storage methods * Using a map yields better performance on operations such a delete @@ -52,19 +56,16 @@ 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(); + /** Versioned frozen collection snapshots for structural sharing */ + private collectionSnapshots: Map; constructor() { this.storageKeys = new Set(); this.nullishStorageKeys = new Set(); this.recentKeys = new Set(); this.storageMap = {}; - this.collectionData = {}; this.pendingPromises = new Map(); + this.collectionSnapshots = new Map(); // bind all public methods to prevent problems with `this` bindAll( @@ -94,11 +95,9 @@ class OnyxCache { 'addEvictableKeysToRecentlyAccessedList', 'getKeyForEviction', 'setCollectionKeys', - 'isCollectionKey', - 'getCollectionKey', + 'hasValueChanged', 'getCollectionData', - 'setRamOnlyKeys', - 'isRamOnlyKey', + 'getCollectionVersion', ); } @@ -127,6 +126,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,25 +172,24 @@ 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); + const oldValue = this.storageMap[key]; + if (value === null || value === undefined) { delete this.storageMap[key]; - // Remove from collection data cache if it's a collection member - if (collectionKey && this.collectionData[collectionKey]) { - delete this.collectionData[collectionKey][key]; + // Rebuild snapshot if a collection member was removed + if (collectionKey && oldValue !== undefined) { + this.rebuildCollectionSnapshot(collectionKey); } return undefined; } this.storageMap[key] = value; - // Update collection data cache if this is a collection member - if (collectionKey) { - if (!this.collectionData[collectionKey]) { - this.collectionData[collectionKey] = {}; - } - this.collectionData[collectionKey][key] = value; + // Rebuild snapshot when collection member value changes + if (collectionKey && oldValue !== value) { + this.rebuildCollectionSnapshot(collectionKey, key); } return value; @@ -200,19 +199,20 @@ class OnyxCache { drop(key: OnyxKey): void { delete this.storageMap[key]; - // Remove from collection data cache if this is a collection member - const collectionKey = this.getCollectionKey(key); - if (collectionKey && this.collectionData[collectionKey]) { - delete this.collectionData[collectionKey][key]; + // Rebuild snapshot if a collection member was dropped + const collectionKey = OnyxKeys.getCollectionKey(key); + if (collectionKey) { + this.rebuildCollectionSnapshot(collectionKey); } - // If this is a collection key, clear its data - if (this.isCollectionKey(key)) { - delete this.collectionData[key]; + // If this is a collection key, clear its snapshot + if (OnyxKeys.isCollectionKey(key)) { + this.collectionSnapshots.delete(key); } this.storageKeys.delete(key); this.recentKeys.delete(key); + OnyxKeys.deregisterMemberKey(key); } /** @@ -231,31 +231,38 @@ class OnyxCache { }).result, }; + const affectedCollections = new Set(); + const changedCollectionKeys = new Map>(); + for (const [key, value] of Object.entries(data)) { this.addKey(key); this.addToAccessedKeys(key); - const collectionKey = this.getCollectionKey(key); + const collectionKey = OnyxKeys.getCollectionKey(key); if (value === null || value === undefined) { this.addNullishStorageKey(key); - // Remove from collection data cache if it's a collection member - if (collectionKey && this.collectionData[collectionKey]) { - delete this.collectionData[collectionKey][key]; + if (collectionKey) { + affectedCollections.add(collectionKey); } } else { this.nullishStorageKeys.delete(key); - // Update collection data cache if this is a collection member if (collectionKey) { - if (!this.collectionData[collectionKey]) { - this.collectionData[collectionKey] = {}; + if (!changedCollectionKeys.has(collectionKey)) { + changedCollectionKeys.set(collectionKey, new Set()); } - this.collectionData[collectionKey][key] = this.storageMap[key]; + changedCollectionKeys.get(collectionKey)!.add(key); + affectedCollections.add(collectionKey); } } } + + // Rebuild frozen snapshots only for affected collections, passing new member keys + for (const collectionKey of affectedCollections) { + this.rebuildCollectionSnapshot(collectionKey, changedCollectionKeys.get(collectionKey)); + } } /** @@ -321,16 +328,23 @@ class OnyxCache { iterResult = iterator.next(); } + const affectedCollections = new Set(); + for (const key of keysToRemove) { delete this.storageMap[key]; - // Remove from collection data cache if this is a collection member - const collectionKey = this.getCollectionKey(key); - if (collectionKey && this.collectionData[collectionKey]) { - delete this.collectionData[collectionKey][key]; + // Track affected collections for snapshot rebuild + const collectionKey = OnyxKeys.getCollectionKey(key); + if (collectionKey) { + affectedCollections.add(collectionKey); } this.recentKeys.delete(key); } + + // Rebuild frozen snapshots for affected collections + for (const collectionKey of affectedCollections) { + this.rebuildCollectionSnapshot(collectionKey); + } } /** Set the recent keys list size */ @@ -364,17 +378,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 +415,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,61 +441,87 @@ 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 + // Initialize frozen snapshots for collection keys for (const collectionKey of collectionKeys) { - if (this.collectionData[collectionKey]) { - continue; + if (!this.collectionSnapshots.has(collectionKey)) { + this.collectionSnapshots.set(collectionKey, { + version: 0, + snapshot: Object.freeze({}), + }); } - this.collectionData[collectionKey] = {}; } - } - /** - * Check if a key is a collection key - */ - isCollectionKey(key: OnyxKey): boolean { - return this.collectionKeys.has(key); + // Pre-populate the reverse lookup map for any existing keys + for (const key of this.storageKeys) { + OnyxKeys.registerMemberKey(key); + } } /** - * Get the collection key for a given member key + * Rebuilds the frozen collection snapshot from current storageMap references. + * Derives membership from the previous snapshot (existing members) + additionalKeys (newly added members). + * Removed members are naturally excluded because they're gone from storageMap. + * The snapshot uses structural sharing: unchanged members keep their original references. + * + * @param collectionKey - The collection key to rebuild + * @param additionalKeys - New member keys not yet in the previous snapshot (single key or set of keys) */ - getCollectionKey(key: OnyxKey): OnyxKey | undefined { - for (const collectionKey of this.collectionKeys) { - if (key.startsWith(collectionKey) && key.length > collectionKey.length) { - return collectionKey; + private rebuildCollectionSnapshot(collectionKey: OnyxKey, additionalKeys?: OnyxKey | Set): void { + const existing = this.collectionSnapshots.get(collectionKey); + const newVersion = (existing?.version ?? 0) + 1; + + const members: NonUndefined> = {}; + + // Include existing members that still have values in storageMap + if (existing) { + for (const memberKey of Object.keys(existing.snapshot)) { + const val = this.storageMap[memberKey]; + if (val !== undefined && val !== null) { + members[memberKey] = val; + } } } - return undefined; + + // Include newly added member keys + if (additionalKeys) { + const keys = additionalKeys instanceof Set ? additionalKeys : [additionalKeys]; + for (const key of keys) { + const val = this.storageMap[key]; + if (val !== undefined && val !== null) { + members[key] = val; + } + } + } + + Object.freeze(members); + + this.collectionSnapshots.set(collectionKey, { + version: newVersion, + snapshot: members, + }); } /** - * Get all data for a collection key + * Get all data for a collection key. + * Returns a frozen snapshot with structural sharing — safe to return by reference. */ getCollectionData(collectionKey: OnyxKey): Record> | undefined { - const cachedCollection = this.collectionData[collectionKey]; - if (!cachedCollection || Object.keys(cachedCollection).length === 0) { + const entry = this.collectionSnapshots.get(collectionKey); + if (!entry || Object.keys(entry.snapshot).length === 0) { return undefined; } - // 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; + return entry.snapshot; } /** - * Check if a key is a RAM-only key + * Get the version number for a collection's snapshot. + * Useful for cheap O(1) change detection. */ - isRamOnlyKey(key: OnyxKey): boolean { - return this.ramOnlyKeys.has(key); + getCollectionVersion(collectionKey: OnyxKey): number { + return this.collectionSnapshots.get(collectionKey)?.version ?? 0; } } 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..d29c31574 --- /dev/null +++ b/lib/OnyxKeys.ts @@ -0,0 +1,186 @@ +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(); + +/** 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 { + if (memberToCollectionKeyMap.has(key)) { + return; + } + for (const collectionKey of collectionKeySet) { + if (isCollectionMemberKey(collectionKey, key)) { + memberToCollectionKeyMap.set(key, collectionKey); + return; + } + } +} + +/** + * Removes a member key from the reverse lookup map. + * Called when a key is dropped from cache. + */ +function deregisterMemberKey(key: OnyxKey): void { + memberToCollectionKeyMap.delete(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 - 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 { + const collectionKey = getCollectionKey(key); + if (collectionKey) { + return ramOnlyKeySet.has(collectionKey); + } + return ramOnlyKeySet.has(key); +} + +export default { + setCollectionKeys, + getCollectionKeys, + isCollectionKey, + isCollectionMemberKey, + isCollectionMember, + isKeyMatch, + getCollectionKey, + registerMemberKey, + deregisterMemberKey, + 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 749827fce..0fc6e26ac 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'; @@ -132,7 +132,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 50402ea10..1fdf74dd6 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -8,7 +8,6 @@ import cache, {TASK} from './OnyxCache'; import * as Str from './Str'; import Storage from './storage'; import type { - CollectionKey, CollectionKeyBase, ConnectOptions, DeepRecord, @@ -40,6 +39,7 @@ import * as GlobalSettings from './GlobalSettings'; import decorateWithMetrics from './metrics'; import type {StorageKeyValuePair} from './storage/providers/types'; import logMessages from './logMessages'; +import OnyxKeys from './OnyxKeys'; // Method constants const METHOD = { @@ -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(); @@ -189,9 +186,9 @@ function setSnapshotMergeKeys(keys: Set): void { */ function initStoreValues(keys: DeepRecord, initialKeyStates: Partial, evictableKeys: OnyxKey[]): void { // 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. + // key is a collection. We store it in a set 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()); @@ -204,8 +201,8 @@ function initStoreValues(keys: DeepRecord, initialKeyStates: Pa // Let Onyx know about which keys are safe to evict cache.setEvictionAllowList(evictableKeys); - // Set collection keys in cache for optimized storage - cache.setCollectionKeys(onyxCollectionKeySet); + // Initialize collection keys in the centralized OnyxKeys module and cache + cache.setCollectionKeys(collectionKeySet); if (typeof keys.COLLECTION === 'object' && typeof keys.COLLECTION.SNAPSHOT === 'string') { snapshotKey = keys.COLLECTION.SNAPSHOT; @@ -268,7 +265,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); } @@ -285,7 +282,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 @@ -334,7 +331,7 @@ function multiGet(keys: CollectionKeyBase[]): Promise); } @@ -385,7 +382,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 @@ -470,130 +467,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. @@ -601,7 +474,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; @@ -648,7 +521,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; } @@ -702,7 +575,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') { @@ -759,7 +632,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); } @@ -770,7 +643,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. @@ -784,7 +657,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; } @@ -794,7 +667,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) { @@ -924,7 +797,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(); } @@ -1113,7 +986,7 @@ function mergeInternal | undefined, TChange ex function initializeWithDefaultKeyStates(): Promise { // Filter out RAM-only keys from storage reads as they may have stale persisted data // from before the key was migrated to RAM-only. - const keysToFetch = Object.keys(defaultKeyStates).filter((key) => !isRamOnlyKey(key)); + const keysToFetch = Object.keys(defaultKeyStates).filter((key) => !OnyxKeys.isRamOnlyKey(key)); return Storage.multiGet(keysToFetch).then((pairs) => { const existingDataAsObject = Object.fromEntries(pairs) as Record; @@ -1139,7 +1012,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; } @@ -1182,7 +1055,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; } @@ -1355,7 +1228,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; @@ -1465,7 +1338,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) @@ -1501,7 +1374,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; @@ -1538,7 +1411,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; } @@ -1585,7 +1458,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; @@ -1654,12 +1527,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)); } @@ -1713,7 +1586,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; @@ -1738,7 +1611,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; } @@ -1782,18 +1655,11 @@ const OnyxUtils = { sendActionToDevTools, get, getAllKeys, - getCollectionKeys, - isCollectionKey, - isCollectionMemberKey, - isCollectionMember, - splitCollectionMemberKey, - isKeyMatch, tryGetCachedValue, getCachedCollection, keysChanged, keyChanged, sendDataToConnection, - getCollectionKey, getCollectionDataAndSendAsObject, scheduleSubscriberUpdate, scheduleNotifyCollectionSubscribers, @@ -1829,7 +1695,6 @@ const OnyxUtils = { setWithRetry, multiSetWithRetry, setCollectionWithRetry, - isRamOnlyKey, }; GlobalSettings.addGlobalSettingsChangeListener(({enablePerformanceMetrics}) => { @@ -1845,8 +1710,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 38397c0d9..ca83a37c7 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'; @@ -160,10 +161,10 @@ function useOnyx>( } try { - const previousCollectionKey = OnyxUtils.splitCollectionMemberKey(previousKey)[0]; - const collectionKey = OnyxUtils.splitCollectionMemberKey(key)[0]; + const previousCollectionKey = OnyxKeys.splitCollectionMemberKey(previousKey)[0]; + const collectionKey = OnyxKeys.splitCollectionMemberKey(key)[0]; - if (OnyxUtils.isCollectionMemberKey(previousCollectionKey, previousKey) && OnyxUtils.isCollectionMemberKey(collectionKey, key) && previousCollectionKey === collectionKey) { + if (OnyxKeys.isCollectionMemberKey(previousCollectionKey, previousKey) && OnyxKeys.isCollectionMemberKey(collectionKey, key) && previousCollectionKey === collectionKey) { return; } } catch (e) { @@ -345,7 +346,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..e9b5b66fb 100644 --- a/tests/perf-test/OnyxUtils.perf-test.ts +++ b/tests/perf-test/OnyxUtils.perf-test.ts @@ -6,6 +6,7 @@ import Onyx from '../../lib'; import StorageMock from '../../lib/storage'; import OnyxCache from '../../lib/OnyxCache'; import OnyxUtils, {clearOnyxUtilsInternals} from '../../lib/OnyxUtils'; +import OnyxKeys from '../../lib/OnyxKeys'; import type GenericCollection from '../utils/GenericCollection'; import type {OnyxUpdate} from '../../lib/Onyx'; import createDeferredTask from '../../lib/createDeferredTask'; @@ -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/OnyxSnapshotCacheTest.ts b/tests/unit/OnyxSnapshotCacheTest.ts index 1281bfc43..5281f9409 100644 --- a/tests/unit/OnyxSnapshotCacheTest.ts +++ b/tests/unit/OnyxSnapshotCacheTest.ts @@ -1,15 +1,18 @@ 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', () => ({ - isCollectionKey: jest.fn(), - getCollectionKey: jest.fn(), +// Mock OnyxKeys for testing +jest.mock('../../lib/OnyxKeys', () => ({ + __esModule: true, + default: { + isCollectionKey: jest.fn(), + getCollectionKey: jest.fn(), + }, })); -const mockedOnyxUtils = OnyxUtils as jest.Mocked; +const mockedOnyxKeys = OnyxKeys as jest.Mocked; // Test types type TestData = { @@ -155,8 +158,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'); @@ -172,8 +175,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'); @@ -191,8 +194,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_'); @@ -212,13 +215,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 5f2154287..5603f7464 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,7 @@ 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 +536,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_', + }, }; 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 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 +568,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 +615,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_', + }, }; 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 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 +643,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 @@ -676,9 +685,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..0bbf70932 100644 --- a/tests/unit/onyxUtilsTest.ts +++ b/tests/unit/onyxUtilsTest.ts @@ -1,6 +1,7 @@ import {act} from '@testing-library/react-native'; import Onyx from '../../lib'; import OnyxUtils from '../../lib/OnyxUtils'; +import OnyxKeys from '../../lib/OnyxKeys'; import type {GenericDeepRecord} from '../types'; import utils from '../../lib/utils'; import type {Collection, OnyxCollection} from '../../lib/types'; @@ -108,7 +109,7 @@ describe('OnyxUtils', () => { }; it.each(Object.keys(dataResult))('%s', (key) => { - const [collectionKey, id] = OnyxUtils.splitCollectionMemberKey(key); + const [collectionKey, id] = OnyxKeys.splitCollectionMemberKey(key); expect(collectionKey).toEqual(dataResult[key][0]); expect(id).toEqual(dataResult[key][1]); }); @@ -116,22 +117,22 @@ describe('OnyxUtils', () => { it('should throw error if key does not contain underscore', () => { expect(() => { - OnyxUtils.splitCollectionMemberKey(ONYXKEYS.TEST_KEY); + OnyxKeys.splitCollectionMemberKey(ONYXKEYS.TEST_KEY); }).toThrowError("Invalid 'test' key provided, only collection keys are allowed."); expect(() => { - OnyxUtils.splitCollectionMemberKey(''); + OnyxKeys.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); + 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(() => { - OnyxUtils.splitCollectionMemberKey(`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, ONYXKEYS.COLLECTION.TEST_LEVEL_KEY); + OnyxKeys.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."); }); }); @@ -337,44 +338,44 @@ describe('OnyxUtils', () => { }; it.each(Object.keys(dataResult))('%s', (key) => { - const collectionKey = OnyxUtils.getCollectionKey(key); + const collectionKey = OnyxKeys.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(); + expect(OnyxKeys.getCollectionKey(ONYXKEYS.TEST_KEY)).toBeUndefined(); + expect(OnyxKeys.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); + expect(OnyxKeys.isCollectionMember('test_123')).toBe(true); + expect(OnyxKeys.isCollectionMember('test_level_456')).toBe(true); + expect(OnyxKeys.isCollectionMember('test_level_last_789')).toBe(true); + expect(OnyxKeys.isCollectionMember('test_-1_something')).toBe(true); + expect(OnyxKeys.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); + expect(OnyxKeys.isCollectionMember('test_')).toBe(false); + expect(OnyxKeys.isCollectionMember('test_level_')).toBe(false); + expect(OnyxKeys.isCollectionMember('test_level_last_')).toBe(false); + expect(OnyxKeys.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); + expect(OnyxKeys.isCollectionMember('test')).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(OnyxUtils.isCollectionMember('invalid_key_123')).toBe(false); - expect(OnyxUtils.isCollectionMember('notregistered_')).toBe(false); - expect(OnyxUtils.isCollectionMember('notregistered_123')).toBe(false); + expect(OnyxKeys.isCollectionMember('invalid_key_123')).toBe(false); + expect(OnyxKeys.isCollectionMember('notregistered_')).toBe(false); + expect(OnyxKeys.isCollectionMember('notregistered_123')).toBe(false); }); }); @@ -494,27 +495,27 @@ describe('OnyxUtils', () => { describe('isRamOnlyKey', () => { it('should return true for RAM-only key', () => { - expect(OnyxUtils.isRamOnlyKey(ONYXKEYS.RAM_ONLY_KEY)).toBeTruthy(); + expect(OnyxKeys.isRamOnlyKey(ONYXKEYS.RAM_ONLY_KEY)).toBeTruthy(); }); it('should return true for RAM-only collection', () => { - expect(OnyxUtils.isRamOnlyKey(ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION)).toBeTruthy(); + expect(OnyxKeys.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(); + expect(OnyxKeys.isRamOnlyKey(`${ONYXKEYS.COLLECTION.RAM_ONLY_COLLECTION}1`)).toBeTruthy(); }); it('should return false for a normal key', () => { - expect(OnyxUtils.isRamOnlyKey(ONYXKEYS.TEST_KEY)).toBeFalsy(); + expect(OnyxKeys.isRamOnlyKey(ONYXKEYS.TEST_KEY)).toBeFalsy(); }); it('should return false for normal collection', () => { - expect(OnyxUtils.isRamOnlyKey(ONYXKEYS.COLLECTION.TEST_KEY)).toBeFalsy(); + expect(OnyxKeys.isRamOnlyKey(ONYXKEYS.COLLECTION.TEST_KEY)).toBeFalsy(); }); it('should return false for normal collection member', () => { - expect(OnyxUtils.isRamOnlyKey(`${ONYXKEYS.COLLECTION.TEST_KEY}1`)).toBeFalsy(); + expect(OnyxKeys.isRamOnlyKey(`${ONYXKEYS.COLLECTION.TEST_KEY}1`)).toBeFalsy(); }); }); From 4d29d713b7180d8940a036650036c5e9057739f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Wed, 4 Mar 2026 11:26:52 +0000 Subject: [PATCH 2/9] =?UTF-8?q?Phase=202:=20Eliminate=20redundant=20copies?= =?UTF-8?q?=20=E2=80=94=20per-key=20merge,=20reference=20equality=20fast?= =?UTF-8?q?=20path,=20return=20frozen=20snapshots=20by=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/OnyxCache.ts | 30 ++++++++++++++++++++---------- lib/OnyxUtils.ts | 7 ++++--- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index 61761b179..33f82db8a 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -224,13 +224,6 @@ class OnyxCache { throw new Error('data passed to cache.merge() must be an Object of onyx key/value pairs'); } - this.storageMap = { - ...utils.fastMerge(this.storageMap, data, { - shouldRemoveNestedNulls: true, - objectRemovalMode: 'replace', - }).result, - }; - const affectedCollections = new Set(); const changedCollectionKeys = new Map>(); @@ -240,8 +233,15 @@ class OnyxCache { const collectionKey = OnyxKeys.getCollectionKey(key); - if (value === null || value === undefined) { + if (value === undefined) { + this.addNullishStorageKey(key); + // undefined means "no change" — skip storageMap modification + continue; + } + + if (value === null) { this.addNullishStorageKey(key); + delete this.storageMap[key]; if (collectionKey) { affectedCollections.add(collectionKey); @@ -249,6 +249,13 @@ class OnyxCache { } else { this.nullishStorageKeys.delete(key); + // Per-key merge instead of spreading the entire storageMap + const existing = this.storageMap[key]; + this.storageMap[key] = utils.fastMerge(existing, value, { + shouldRemoveNestedNulls: true, + objectRemovalMode: 'replace', + }).result; + if (collectionKey) { if (!changedCollectionKeys.has(collectionKey)) { changedCollectionKeys.set(collectionKey, new Set()); @@ -352,9 +359,12 @@ class OnyxCache { this.maxRecentKeysSize = limit; } - /** Check if the value has changed */ + /** Check if the value has changed. Uses reference equality as a fast path, falls back to deep equality. */ hasValueChanged(key: OnyxKey, value: OnyxValue): boolean { - const currentValue = this.get(key, false); + const currentValue = this.storageMap[key]; + if (currentValue === value) { + return false; + } return !deepEqual(currentValue, value); } diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 1fdf74dd6..3a0cd8991 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -509,8 +509,8 @@ function getCachedCollection(collectionKey: TKey return filteredCollection; } - // Return a copy to avoid mutations affecting the cache - return {...collectionData}; + // Snapshot is frozen — safe to return by reference + return collectionData; } // Fallback to original implementation if collection data not available @@ -680,7 +680,8 @@ function keyChanged( cachedCollections[subscriber.key] = cachedCollection; } - cachedCollection[key] = value; + // The cache is always updated before keyChanged runs, so the snapshot + // already contains the new value — no need to copy or patch it. subscriber.callback(cachedCollection, subscriber.key, {[key]: value}); continue; } From 1cc276bc8f7f590c8bae5aaddb84e212cea40e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Wed, 4 Mar 2026 13:03:57 +0000 Subject: [PATCH 3/9] Phase 3: Indexed subscriber lookup in keysChanged, reference equality for change detection, reference-stable fastMerge and removeNestedNullValues --- lib/OnyxCache.ts | 10 +- lib/OnyxUtils.ts | 92 +++++++-------- lib/utils.ts | 31 ++++- tests/unit/{fastMergeTest.ts => utilsTest.ts} | 110 ++++++++++++++++++ 4 files changed, 183 insertions(+), 60 deletions(-) rename tests/unit/{fastMergeTest.ts => utilsTest.ts} (56%) diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index 33f82db8a..00353dc9c 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -251,11 +251,19 @@ class OnyxCache { // Per-key merge instead of spreading the entire storageMap const existing = this.storageMap[key]; - this.storageMap[key] = utils.fastMerge(existing, value, { + const merged = utils.fastMerge(existing, value, { shouldRemoveNestedNulls: true, objectRemovalMode: 'replace', }).result; + // fastMerge is reference-stable: returns the original target when + // nothing changed, so a simple === check detects no-ops. + if (merged === existing) { + continue; + } + + this.storageMap[key] = merged; + if (collectionKey) { if (!changedCollectionKeys.has(collectionKey)) { changedCollectionKeys.set(collectionKey, new Set()); diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 3a0cd8991..affd7d9bf 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1,4 +1,3 @@ -import {deepEqual} from 'fast-equals'; import type {ValueOf} from 'type-fest'; import _ from 'underscore'; import DevTools from './DevTools'; @@ -545,76 +544,63 @@ function keysChanged( partialCollection: OnyxCollection, partialPreviousCollection: OnyxCollection | undefined, ): void { - // We prepare the "cached collection" which is the entire collection + the new partial data that - // was merged in via mergeCollection(). + // The cache is already updated before keysChanged runs, so the frozen snapshot + // already contains the new values. const cachedCollection = getCachedCollection(collectionKey); const previousCollection = partialPreviousCollection ?? {}; - // We are iterating over all subscribers similar to keyChanged(). However, we are looking for subscribers who are subscribing to either a collection key or - // individual collection key member for the collection that is being updated. It is important to note that the collection parameter cane be a PARTIAL collection - // and does not represent all of the combined keys and values for a collection key. It is just the "new" data that was merged in via mergeCollection(). - const stateMappingKeys = Object.keys(callbackToStateMapping); + const changedMemberKeys = Object.keys(partialCollection ?? {}); + + // Use indexed lookup instead of scanning all subscribers. + // We need subscribers for: (1) the collection key itself, and (2) individual changed member keys. + const collectionSubscriberIDs = onyxKeyToSubscriptionIDs.get(collectionKey) ?? []; + const memberSubscriberIDs: number[] = []; + for (const memberKey of changedMemberKeys) { + const ids = onyxKeyToSubscriptionIDs.get(memberKey); + if (ids) { + for (const id of ids) { + memberSubscriberIDs.push(id); + } + } + } - for (const stateMappingKey of stateMappingKeys) { - const subscriber = callbackToStateMapping[stateMappingKey]; - if (!subscriber) { + // Notify collection-level subscribers + for (const subID of collectionSubscriberIDs) { + const subscriber = callbackToStateMapping[subID]; + if (!subscriber || typeof subscriber.callback !== 'function') { 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)) { + if (subscriber.waitForCollectionCallback) { + subscriber.callback(cachedCollection, subscriber.key, partialCollection); 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 = OnyxKeys.isCollectionMemberKey(collectionKey, subscriber.key); - - // Regular Onyx.connect() subscriber found. - if (typeof subscriber.callback === 'function') { - // 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 ?? {}); - for (const dataKey of dataKeys) { - if (deepEqual(cachedCollection[dataKey], previousCollection[dataKey])) { - continue; - } - - subscriber.callback(cachedCollection[dataKey], dataKey); - } + // Not using waitForCollectionCallback — notify per changed key + for (const dataKey of changedMemberKeys) { + if (cachedCollection[dataKey] === previousCollection[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 (deepEqual(cachedCollection[subscriber.key], previousCollection[subscriber.key])) { - continue; - } + subscriber.callback(cachedCollection[dataKey], dataKey); + } + } - const subscriberCallback = subscriber.callback as DefaultConnectCallback; - subscriberCallback(cachedCollection[subscriber.key], subscriber.key as TKey); - lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection[subscriber.key]); - continue; - } + // Notify member-level subscribers (e.g. subscribed to `report_123`) + for (const subID of memberSubscriberIDs) { + const subscriber = callbackToStateMapping[subID]; + if (!subscriber || typeof subscriber.callback !== 'function') { + continue; + } + if (cachedCollection[subscriber.key] === previousCollection[subscriber.key]) { continue; } + + const subscriberCallback = subscriber.callback as DefaultConnectCallback; + subscriberCallback(cachedCollection[subscriber.key], subscriber.key as TKey); + lastConnectionCallbackData.set(subscriber.subscriptionID, cachedCollection[subscriber.key]); } } diff --git a/lib/utils.ts b/lib/utils.ts index d7d296531..7147cad36 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -89,6 +89,10 @@ function mergeObject>( const targetObject = isMergeableObject(target) ? target : undefined; + // Track whether the merge actually changed anything compared to target. + // If nothing changed, we return the original target reference for reference stability. + let hasChanged = !targetObject; + // First we want to copy over all keys from the target into the destination object, // in case "target" is a mergable object. // If "shouldRemoveNestedNulls" is true, we want to remove null values from the merged object @@ -103,6 +107,7 @@ function mergeObject>( const shouldOmitNullishProperty = options.shouldRemoveNestedNulls && (targetProperty === null || sourceProperty === null); if (targetProperty === undefined || shouldOmitNullishProperty) { + hasChanged = true; continue; } @@ -125,6 +130,9 @@ function mergeObject>( // If the source value is not a mergable object, we need to set the key directly. if (!isMergeableObject(sourceProperty)) { + if (destination[key] !== sourceProperty) { + hasChanged = true; + } destination[key] = sourceProperty; continue; } @@ -134,6 +142,7 @@ function mergeObject>( // To achieve this, we first mark these nested objects with an internal flag. // When calling fastMerge again with "mark" removal mode, the marked objects will be removed. if (options.objectRemovalMode === 'mark' && targetProperty === null) { + hasChanged = true; targetProperty = {[ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true}; metadata.replaceNullPatches.push([[...basePath, key], {...sourceProperty}]); } @@ -142,6 +151,7 @@ function mergeObject>( // has the internal flag set, we replace the entire destination object with the source one and remove // the flag. if (options.objectRemovalMode === 'replace' && sourceProperty[ONYX_INTERNALS__REPLACE_OBJECT_MARK]) { + hasChanged = true; // We do a spread here in order to have a new object reference and allow us to delete the internal flag // of the merged object only. const sourcePropertyWithoutMark = {...sourceProperty}; @@ -150,10 +160,14 @@ function mergeObject>( continue; } - destination[key] = fastMerge(targetProperty, sourceProperty, options, metadata, [...basePath, key]).result; + const merged = fastMerge(targetProperty, sourceProperty, options, metadata, [...basePath, key]).result; + if (merged !== targetProperty) { + hasChanged = true; + } + destination[key] = merged; } - return destination as TObject; + return hasChanged ? (destination as TObject) : (targetObject as TObject); } /** Checks whether the given object is an object and not null/undefined. */ @@ -170,7 +184,7 @@ function isMergeableObject>(value: unkno return isNonNullObject && !(value instanceof RegExp) && !(value instanceof Date) && !Array.isArray(value); } -/** Deep removes the nested null values from the given value. */ +/** Deep removes the nested null values from the given value. Returns the original reference if no nulls were found. */ function removeNestedNullValues | null>(value: TValue): TValue { if (value === null || value === undefined || typeof value !== 'object') { return value; @@ -180,6 +194,7 @@ function removeNestedNullValues | null>(value: return [...value] as TValue; } + let hasChanged = false; const result: Record = {}; // eslint-disable-next-line no-restricted-syntax, guard-for-in @@ -187,18 +202,22 @@ function removeNestedNullValues | null>(value: const propertyValue = value[key]; if (propertyValue === null || propertyValue === undefined) { + hasChanged = true; continue; } if (typeof propertyValue === 'object' && !Array.isArray(propertyValue)) { - const valueWithoutNestedNulls = removeNestedNullValues(propertyValue); - result[key] = valueWithoutNestedNulls; + const cleaned = removeNestedNullValues(propertyValue); + if (cleaned !== propertyValue) { + hasChanged = true; + } + result[key] = cleaned; } else { result[key] = propertyValue; } } - return result as TValue; + return hasChanged ? (result as TValue) : value; } /** Formats the action name by uppercasing and adding the key if provided. */ diff --git a/tests/unit/fastMergeTest.ts b/tests/unit/utilsTest.ts similarity index 56% rename from tests/unit/fastMergeTest.ts rename to tests/unit/utilsTest.ts index edcbdfab1..9b6604ab2 100644 --- a/tests/unit/fastMergeTest.ts +++ b/tests/unit/utilsTest.ts @@ -209,5 +209,115 @@ describe('fastMerge', () => { const result = utils.fastMerge(data, testObject); expect(result.result).toEqual(testObject); }); + + it('should return the same reference when source values match target', () => { + const target = {a: 1, b: 'hello', c: true}; + const source = {a: 1, b: 'hello'}; + const result = utils.fastMerge(target, source); + expect(result.result).toBe(target); + }); + + it('should return a new reference when source adds a key', () => { + const target = {a: 1}; + const source = {a: 1, b: 2}; + const result = utils.fastMerge(target, source); + expect(result.result).not.toBe(target); + expect(result.result).toEqual({a: 1, b: 2}); + }); + + it('should return a new reference when source changes a value', () => { + const target = {a: 1, b: 2}; + const source = {b: 3}; + const result = utils.fastMerge(target, source); + expect(result.result).not.toBe(target); + expect(result.result).toEqual({a: 1, b: 3}); + }); + + it('should preserve nested object references when unchanged', () => { + const nested = {x: 1, y: 2}; + const target = {a: 'hello', b: nested}; + const source = {a: 'hello'}; + const result = utils.fastMerge(target, source); + expect(result.result).toBe(target); + expect(result.result.b).toBe(nested); + }); + + it('should preserve unchanged nested references when sibling changes', () => { + const nested = {x: 1, y: 2}; + const target = {a: nested, b: 'old'}; + const source = {b: 'new'}; + const result = utils.fastMerge(target, source); + expect(result.result).not.toBe(target); + expect(result.result.a).toBe(nested); + }); + + it('should return a new reference when nested object changes', () => { + const target = {a: {x: 1, y: 2}, b: 'hello'}; + const source = {a: {x: 99}}; + const result = utils.fastMerge(target, source); + expect(result.result).not.toBe(target); + expect(result.result.a).not.toBe(target.a); + expect(result.result.a).toEqual({x: 99, y: 2}); + }); + + it('should return a new reference when shouldRemoveNestedNulls removes a key', () => { + const target = {a: 1, b: null}; + const source = {a: 1}; + const result = utils.fastMerge(target, source, {shouldRemoveNestedNulls: true}); + expect(result.result).not.toBe(target); + expect(result.result).toEqual({a: 1}); + }); + + it('should return the same reference when merging with empty source keys', () => { + const target = {a: 1, b: 2}; + const source = {}; + const result = utils.fastMerge(target, source); + expect(result.result).toBe(target); + }); + }); + + describe('removeNestedNullValues', () => { + it('should return the same reference when no nulls exist', () => { + const value = {a: 1, b: 'hello', c: true}; + const result = utils.removeNestedNullValues(value); + expect(result).toBe(value); + }); + + it('should return the same reference for nested objects without nulls', () => { + const nested = {x: 1, y: 2}; + const value = {a: 'hello', b: nested}; + const result = utils.removeNestedNullValues(value); + expect(result).toBe(value); + expect((result as Record).b).toBe(nested); + }); + + it('should return a new reference when a null property is removed', () => { + const value = {a: 1, b: null}; + const result = utils.removeNestedNullValues(value); + expect(result).not.toBe(value); + expect(result).toEqual({a: 1}); + }); + + it('should return a new reference when an undefined property is removed', () => { + const value = {a: 1, b: undefined}; + const result = utils.removeNestedNullValues(value); + expect(result).not.toBe(value); + expect(result).toEqual({a: 1}); + }); + + it('should return a new reference when a deeply nested null is removed', () => { + const value = {a: {b: {c: null, d: 1}}}; + const result = utils.removeNestedNullValues(value); + expect(result).not.toBe(value); + expect(result).toEqual({a: {b: {d: 1}}}); + }); + + it('should preserve sibling references when a nested null is removed', () => { + const sibling = {x: 1}; + const value = {a: sibling, b: {c: null}}; + const result = utils.removeNestedNullValues(value); + expect(result).not.toBe(value); + expect((result as Record).a).toBe(sibling); + }); }); }); From 0de97a0ed3261766485eee75f3d8de25e36f1c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Wed, 4 Mar 2026 13:22:00 +0000 Subject: [PATCH 4/9] =?UTF-8?q?Phase=204:=20Simplify=20useOnyx=20=E2=80=94?= =?UTF-8?q?=20replace=20shallowEqual=20with=20reference=20equality,=20fix?= =?UTF-8?q?=20memoized=20selector=20re-running=20when=20input=20changes=20?= =?UTF-8?q?but=20output=20doesn't?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/useOnyx.ts | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index ca83a37c7..9278cb448 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -90,17 +90,17 @@ function useOnyx>( // Recompute if input changed, dependencies changed, or first time const dependenciesChanged = !shallowEqual(lastDependencies, currentDependencies); if (!hasComputed || lastInput !== input || dependenciesChanged) { - // Only proceed if we have a valid selector - if (selector) { - const newOutput = selector(input); - - // Deep equality mode: only update if output actually changed - if (!hasComputed || !deepEqual(lastOutput, newOutput) || dependenciesChanged) { - lastInput = input; - lastOutput = newOutput; - lastDependencies = [...currentDependencies]; - hasComputed = true; - } + const newOutput = selector(input); + + // Always track the current input to avoid re-running the selector + // when the same input is seen again (even if the output didn't change). + lastInput = input; + + // Only update the output reference if it actually changed + if (!hasComputed || !deepEqual(lastOutput, newOutput) || dependenciesChanged) { + lastOutput = newOutput; + lastDependencies = [...currentDependencies]; + hasComputed = true; } } @@ -276,18 +276,12 @@ function useOnyx>( newFetchStatus = 'loading'; } - // Optimized equality checking: - // - Memoized selectors already handle deep equality internally, so we can use fast reference equality - // - Non-selector cases use shallow equality for object reference checks + // Reference equality is sufficient for both selector and non-selector paths: + // - Memoized selectors already handle deep equality internally and return stable references + // - Non-selector values have stable references thanks to frozen collection snapshots + // and reference-stable fastMerge/removeNestedNullValues // - Normalize null to undefined to ensure consistent comparison (both represent "no value") - let areValuesEqual: boolean; - if (memoizedSelector) { - const normalizedPrevious = previousValueRef.current ?? undefined; - const normalizedNew = newValueRef.current ?? undefined; - areValuesEqual = normalizedPrevious === normalizedNew; - } else { - areValuesEqual = shallowEqual(previousValueRef.current ?? undefined, newValueRef.current); - } + const areValuesEqual = (previousValueRef.current ?? undefined) === (newValueRef.current ?? undefined); // We update the cached value and the result in the following conditions: // We will update the cached value and the result in any of the following situations: From cc27a85fc479b2162cc578765a1a21f3f1623f2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Wed, 4 Mar 2026 14:04:55 +0000 Subject: [PATCH 5/9] =?UTF-8?q?perf:=20lazy=20collection=20snapshot=20rebu?= =?UTF-8?q?ilds=20=E2=80=94=20mark=20dirty=20on=20write,=20rebuild=20on=20?= =?UTF-8?q?next=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/OnyxCache.ts | 45 ++++++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index 00353dc9c..0df2ab4c1 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -59,6 +59,9 @@ class OnyxCache { /** Versioned frozen collection snapshots for structural sharing */ private collectionSnapshots: Map; + /** Collections whose snapshots need rebuilding (lazy — rebuilt on next read) */ + private dirtyCollections: Set; + constructor() { this.storageKeys = new Set(); this.nullishStorageKeys = new Set(); @@ -66,6 +69,7 @@ class OnyxCache { this.storageMap = {}; this.pendingPromises = new Map(); this.collectionSnapshots = new Map(); + this.dirtyCollections = new Set(); // bind all public methods to prevent problems with `this` bindAll( @@ -178,18 +182,16 @@ class OnyxCache { if (value === null || value === undefined) { delete this.storageMap[key]; - // Rebuild snapshot if a collection member was removed if (collectionKey && oldValue !== undefined) { - this.rebuildCollectionSnapshot(collectionKey); + this.dirtyCollections.add(collectionKey); } return undefined; } this.storageMap[key] = value; - // Rebuild snapshot when collection member value changes if (collectionKey && oldValue !== value) { - this.rebuildCollectionSnapshot(collectionKey, key); + this.dirtyCollections.add(collectionKey); } return value; @@ -199,10 +201,9 @@ class OnyxCache { drop(key: OnyxKey): void { delete this.storageMap[key]; - // Rebuild snapshot if a collection member was dropped const collectionKey = OnyxKeys.getCollectionKey(key); if (collectionKey) { - this.rebuildCollectionSnapshot(collectionKey); + this.dirtyCollections.add(collectionKey); } // If this is a collection key, clear its snapshot @@ -274,9 +275,9 @@ class OnyxCache { } } - // Rebuild frozen snapshots only for affected collections, passing new member keys + // Mark affected collections as dirty — snapshots will be lazily rebuilt on next read for (const collectionKey of affectedCollections) { - this.rebuildCollectionSnapshot(collectionKey, changedCollectionKeys.get(collectionKey)); + this.dirtyCollections.add(collectionKey); } } @@ -356,9 +357,8 @@ class OnyxCache { this.recentKeys.delete(key); } - // Rebuild frozen snapshots for affected collections for (const collectionKey of affectedCollections) { - this.rebuildCollectionSnapshot(collectionKey); + this.dirtyCollections.add(collectionKey); } } @@ -486,26 +486,15 @@ class OnyxCache { * @param collectionKey - The collection key to rebuild * @param additionalKeys - New member keys not yet in the previous snapshot (single key or set of keys) */ - private rebuildCollectionSnapshot(collectionKey: OnyxKey, additionalKeys?: OnyxKey | Set): void { + private rebuildCollectionSnapshot(collectionKey: OnyxKey): void { const existing = this.collectionSnapshots.get(collectionKey); const newVersion = (existing?.version ?? 0) + 1; const members: NonUndefined> = {}; - // Include existing members that still have values in storageMap - if (existing) { - for (const memberKey of Object.keys(existing.snapshot)) { - const val = this.storageMap[memberKey]; - if (val !== undefined && val !== null) { - members[memberKey] = val; - } - } - } - - // Include newly added member keys - if (additionalKeys) { - const keys = additionalKeys instanceof Set ? additionalKeys : [additionalKeys]; - for (const key of keys) { + // Discover all current members by scanning storageKeys with prefix matching + for (const key of this.storageKeys) { + if (OnyxKeys.isCollectionMemberKey(collectionKey, key)) { const val = this.storageMap[key]; if (val !== undefined && val !== null) { members[key] = val; @@ -524,8 +513,14 @@ class OnyxCache { /** * Get all data for a collection key. * Returns a frozen snapshot with structural sharing — safe to return by reference. + * Lazily rebuilds the snapshot if the collection was modified since the last read. */ getCollectionData(collectionKey: OnyxKey): Record> | undefined { + if (this.dirtyCollections.has(collectionKey)) { + this.rebuildCollectionSnapshot(collectionKey); + this.dirtyCollections.delete(collectionKey); + } + const entry = this.collectionSnapshots.get(collectionKey); if (!entry || Object.keys(entry.snapshot).length === 0) { return undefined; From c67da03f22c8d754ec58e85106a2f9f084e44440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Tue, 10 Mar 2026 11:33:47 +0000 Subject: [PATCH 6/9] fix: return stable empty reference for empty collections to prevent extra re-renders --- lib/OnyxCache.ts | 12 ++++++++++++ lib/OnyxUtils.ts | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index 0df2ab4c1..d214cf8fb 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -12,6 +12,13 @@ type CollectionSnapshot = { snapshot: Readonly>>; }; +/** + * Stable frozen empty object used as the canonical value for empty collections. + * Returning the same reference avoids unnecessary re-renders in useSyncExternalStore, + * which relies on === equality to detect changes. + */ +const FROZEN_EMPTY_COLLECTION: Readonly>> = Object.freeze({}); + // Task constants const TASK = { GET: 'get', @@ -523,6 +530,11 @@ class OnyxCache { const entry = this.collectionSnapshots.get(collectionKey); if (!entry || Object.keys(entry.snapshot).length === 0) { + // If we know we have storage keys loaded, return a stable empty reference + // to avoid new {} allocations that break useSyncExternalStore === equality. + if (this.storageKeys.size > 0) { + return FROZEN_EMPTY_COLLECTION; + } return undefined; } diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index affd7d9bf..cb2e49a68 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -482,7 +482,9 @@ function tryGetCachedValue(key: TKey): OnyxValue if (cache.getAllKeys().size === 0) { return; } - // Set an empty collection object for collections that exist but have no data + // getCollectionData returns undefined only when storageKeys is empty (loading state). + // Since we checked getAllKeys().size > 0, this path shouldn't normally be reached, + // but as a safety fallback we still set an empty object. val = {}; } } From 73444b39ab25aed13c08b5fff856f5d9f60fcb60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Wed, 11 Mar 2026 08:09:02 +0000 Subject: [PATCH 7/9] fix: preserve array reference stability in removeNestedNullValues --- lib/utils.ts | 6 +----- tests/unit/utilsTest.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/utils.ts b/lib/utils.ts index 7147cad36..4fb0ca540 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -186,14 +186,10 @@ function isMergeableObject>(value: unkno /** Deep removes the nested null values from the given value. Returns the original reference if no nulls were found. */ function removeNestedNullValues | null>(value: TValue): TValue { - if (value === null || value === undefined || typeof value !== 'object') { + if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value)) { return value; } - if (Array.isArray(value)) { - return [...value] as TValue; - } - let hasChanged = false; const result: Record = {}; diff --git a/tests/unit/utilsTest.ts b/tests/unit/utilsTest.ts index 9b6604ab2..2370f27e8 100644 --- a/tests/unit/utilsTest.ts +++ b/tests/unit/utilsTest.ts @@ -319,5 +319,19 @@ describe('fastMerge', () => { expect(result).not.toBe(value); expect((result as Record).a).toBe(sibling); }); + + it('should return the same array reference', () => { + const arr = [1, 2, 3]; + const result = utils.removeNestedNullValues(arr); + expect(result).toBe(arr); + }); + + it('should return the same reference for objects containing arrays', () => { + const arr = ['a', 'b']; + const value = {items: arr, count: 2}; + const result = utils.removeNestedNullValues(value); + expect(result).toBe(value); + expect((result as Record).items).toBe(arr); + }); }); }); From ec005f683dfca457d9b86cfc1a2a72aac17f0142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Thu, 12 Mar 2026 10:18:29 +0000 Subject: [PATCH 8/9] fix: optimize collection snapshot rebuild with indexed lookup and reference stability --- lib/OnyxCache.ts | 58 ++++++++++++++++++++++++++++++++++++++---------- lib/OnyxKeys.ts | 42 ++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 13 deletions(-) diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index d214cf8fb..47e28795f 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -130,6 +130,11 @@ class OnyxCache { */ setAllKeys(keys: OnyxKey[]) { this.storageKeys = new Set(keys); + + // Register all keys in the member→collection forward index + for (const key of keys) { + OnyxKeys.registerMemberKey(key); + } } /** Saves a key in the storage keys list @@ -486,33 +491,62 @@ class OnyxCache { /** * Rebuilds the frozen collection snapshot from current storageMap references. - * Derives membership from the previous snapshot (existing members) + additionalKeys (newly added members). - * Removed members are naturally excluded because they're gone from storageMap. - * The snapshot uses structural sharing: unchanged members keep their original references. + * Uses the indexed collection→members map for O(collectionMembers) instead of O(totalKeys). + * Returns the previous snapshot reference when all member references are identical, + * preventing unnecessary re-renders in useSyncExternalStore. * * @param collectionKey - The collection key to rebuild - * @param additionalKeys - New member keys not yet in the previous snapshot (single key or set of keys) */ private rebuildCollectionSnapshot(collectionKey: OnyxKey): void { const existing = this.collectionSnapshots.get(collectionKey); - const newVersion = (existing?.version ?? 0) + 1; + const oldSnapshot = existing?.snapshot; const members: NonUndefined> = {}; + let hasChanges = false; + let newMemberCount = 0; - // Discover all current members by scanning storageKeys with prefix matching - for (const key of this.storageKeys) { - if (OnyxKeys.isCollectionMemberKey(collectionKey, key)) { - const val = this.storageMap[key]; - if (val !== undefined && val !== null) { - members[key] = val; + // Use the indexed forward lookup for O(collectionMembers) iteration. + // Falls back to scanning all storageKeys if the index isn't populated yet. + const memberKeys = OnyxKeys.getMembersOfCollection(collectionKey); + const keysToScan = memberKeys ?? this.storageKeys; + const needsPrefixCheck = !memberKeys; + + for (const key of keysToScan) { + if (needsPrefixCheck && !OnyxKeys.isCollectionMemberKey(collectionKey, key)) { + continue; + } + const val = this.storageMap[key]; + if (val !== undefined && val !== null) { + members[key] = val; + newMemberCount++; + + // Check if this member's reference changed from the old snapshot + if (!hasChanges && (!oldSnapshot || oldSnapshot[key] !== val)) { + hasChanges = true; } } } + // Check if any members were removed (old snapshot had more keys) + if (!hasChanges && oldSnapshot) { + const oldMemberCount = Object.keys(oldSnapshot).length; + if (oldMemberCount !== newMemberCount) { + hasChanges = true; + } + } + + // If nothing actually changed, reuse the old snapshot reference. + // This is critical: useSyncExternalStore uses === to detect changes, + // so returning the same reference prevents unnecessary re-renders. + if (!hasChanges && oldSnapshot) { + // Don't even bump the version — nothing changed + return; + } + Object.freeze(members); this.collectionSnapshots.set(collectionKey, { - version: newVersion, + version: (existing?.version ?? 0) + 1, snapshot: members, }); } diff --git a/lib/OnyxKeys.ts b/lib/OnyxKeys.ts index d29c31574..b76fa4b7a 100644 --- a/lib/OnyxKeys.ts +++ b/lib/OnyxKeys.ts @@ -6,6 +6,9 @@ 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(); @@ -99,12 +102,30 @@ function getCollectionKey(key: CollectionKey | OnyxKey): string | undefined { * Called from OnyxCache.addKey() to ensure the Map stays populated. */ function registerMemberKey(key: OnyxKey): void { - if (memberToCollectionKeyMap.has(key)) { + 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; } for (const collectionKey of collectionKeySet) { if (isCollectionMemberKey(collectionKey, key)) { memberToCollectionKeyMap.set(key, collectionKey); + + // Also register in the forward lookup (collection → members) + let members = collectionToMembersMap.get(collectionKey); + if (!members) { + members = new Set(); + collectionToMembersMap.set(collectionKey, members); + } + members.add(key); return; } } @@ -115,9 +136,27 @@ function registerMemberKey(key: OnyxKey): void { * 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. * @@ -180,6 +219,7 @@ export default { getCollectionKey, registerMemberKey, deregisterMemberKey, + getMembersOfCollection, splitCollectionMemberKey, setRamOnlyKeys, isRamOnlyKey, From b6227012b4086ff37ccf04faa1015abf510c2006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Mon, 16 Mar 2026 14:25:33 +0000 Subject: [PATCH 9/9] perf: batch multiSet notifications per collection to avoid N redundant subscriber callbacks --- lib/OnyxUtils.ts | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index a334bba40..9b6706015 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1276,15 +1276,44 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom const keyValuePairsToSet = OnyxUtils.prepareKeyValuePairsForStorage(newData, true); + // Group keys by collection for batched notification, and track non-collection keys separately. + // This avoids firing N individual keyChanged() calls for N collection members — instead we + // update all members in cache first, then notify collection subscribers once per collection. + const collectionBatches = new Map>; previous: Record>}>(); + const nonCollectionPairs: Array<[string, OnyxValue]> = []; + for (const [key, value] of keyValuePairsToSet) { - // When we use multiSet to set a key we want to clear the current delta changes from Onyx.merge that were queued - // before the value was set. If Onyx.merge is currently reading the old value from storage, it will then not apply the changes. + // Clear any pending merge deltas for this key if (OnyxUtils.hasPendingMergeForKey(key)) { delete OnyxUtils.getMergeQueue()[key]; } - // Update cache and optimistically inform subscribers - cache.set(key, value); + const collectionKey = OnyxKeys.getCollectionKey(key); + if (collectionKey && OnyxKeys.isCollectionMemberKey(collectionKey, key)) { + // Capture the previous value before updating cache + const previousValue = cache.get(key); + cache.set(key, value); + + let batch = collectionBatches.get(collectionKey); + if (!batch) { + batch = {partial: {}, previous: {}}; + collectionBatches.set(collectionKey, batch); + } + batch.partial[key] = value; + batch.previous[key] = previousValue; + } else { + cache.set(key, value); + nonCollectionPairs.push([key, value]); + } + } + + // Notify collection subscribers once per collection (batched) + for (const [collectionKey, batch] of collectionBatches) { + keysChanged(collectionKey as CollectionKeyBase, batch.partial, batch.previous); + } + + // Notify non-collection key subscribers individually + for (const [key, value] of nonCollectionPairs) { keyChanged(key, value); }