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 @@
[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 2b836bb19..ea69750f5 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
@@ -351,7 +352,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]) {
@@ -376,7 +377,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];
@@ -486,8 +487,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..47e28795f 100644
--- a/lib/OnyxCache.ts
+++ b/lib/OnyxCache.ts
@@ -2,8 +2,22 @@ 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>>;
+};
+
+/**
+ * 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 = {
@@ -31,9 +45,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 +63,20 @@ 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();
+ /** Versioned frozen collection snapshots for structural sharing */
+ private collectionSnapshots: Map;
- /** Set of RAM-only keys for fast lookup */
- private ramOnlyKeys = new Set();
+ /** Collections whose snapshots need rebuilding (lazy — rebuilt on next read) */
+ private dirtyCollections: Set;
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();
+ this.dirtyCollections = new Set();
// bind all public methods to prevent problems with `this`
bindAll(
@@ -94,11 +106,9 @@ class OnyxCache {
'addEvictableKeysToRecentlyAccessedList',
'getKeyForEviction',
'setCollectionKeys',
- 'isCollectionKey',
- 'getCollectionKey',
+ 'hasValueChanged',
'getCollectionData',
- 'setRamOnlyKeys',
- 'isRamOnlyKey',
+ 'getCollectionVersion',
);
}
@@ -120,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
@@ -127,6 +142,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 +188,22 @@ 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];
+ if (collectionKey && oldValue !== undefined) {
+ this.dirtyCollections.add(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;
+ if (collectionKey && oldValue !== value) {
+ this.dirtyCollections.add(collectionKey);
}
return value;
@@ -200,19 +213,19 @@ 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];
+ const collectionKey = OnyxKeys.getCollectionKey(key);
+ if (collectionKey) {
+ this.dirtyCollections.add(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);
}
/**
@@ -224,38 +237,60 @@ 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>();
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 === undefined) {
+ this.addNullishStorageKey(key);
+ // undefined means "no change" — skip storageMap modification
+ continue;
+ }
- if (value === null || value === undefined) {
+ if (value === null) {
this.addNullishStorageKey(key);
+ 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];
+ if (collectionKey) {
+ affectedCollections.add(collectionKey);
}
} else {
this.nullishStorageKeys.delete(key);
- // Update collection data cache if this is a collection member
+ // Per-key merge instead of spreading the entire storageMap
+ const existing = this.storageMap[key];
+ 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 (!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);
}
}
}
+
+ // Mark affected collections as dirty — snapshots will be lazily rebuilt on next read
+ for (const collectionKey of affectedCollections) {
+ this.dirtyCollections.add(collectionKey);
+ }
}
/**
@@ -321,16 +356,22 @@ 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);
}
+
+ for (const collectionKey of affectedCollections) {
+ this.dirtyCollections.add(collectionKey);
+ }
}
/** Set the recent keys list size */
@@ -338,9 +379,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);
}
@@ -364,17 +408,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 +445,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 +471,116 @@ 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.
+ * 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
*/
- 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): void {
+ const existing = this.collectionSnapshots.get(collectionKey);
+ const oldSnapshot = existing?.snapshot;
+
+ const members: NonUndefined> = {};
+ let hasChanges = false;
+ let newMemberCount = 0;
+
+ // 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;
+ }
}
}
- return undefined;
+
+ // 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: (existing?.version ?? 0) + 1,
+ 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.
+ * Lazily rebuilds the snapshot if the collection was modified since the last read.
*/
getCollectionData(collectionKey: OnyxKey): Record> | undefined {
- const cachedCollection = this.collectionData[collectionKey];
- if (!cachedCollection || Object.keys(cachedCollection).length === 0) {
- return undefined;
+ if (this.dirtyCollections.has(collectionKey)) {
+ this.rebuildCollectionSnapshot(collectionKey);
+ this.dirtyCollections.delete(collectionKey);
}
- // Return a shallow copy to ensure React detects changes when items are added/removed
- return {...cachedCollection};
- }
+ 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;
+ }
- /**
- * 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..b76fa4b7a
--- /dev/null
+++ b/lib/OnyxKeys.ts
@@ -0,0 +1,226 @@
+import type {CollectionKeyBase, CollectionKey, OnyxKey} from './types';
+
+/** Single source of truth for the set of registered collection keys */
+let collectionKeySet = new Set();
+
+/** Reverse lookup: member key → collection key for O(1) access */
+const memberToCollectionKeyMap = new Map();
+
+/** Forward lookup: collection key → set of member keys for O(collectionMembers) iteration */
+const collectionToMembersMap = new Map>();
+
+/** Set of keys that should only be stored in RAM, not persisted to storage */
+let ramOnlyKeySet = new Set();
+
+/**
+ * Initializes the collection key set. Called once during Onyx.init().
+ */
+function setCollectionKeys(keys: Set): void {
+ collectionKeySet = keys;
+}
+
+/**
+ * Returns the set of all registered collection keys.
+ */
+function getCollectionKeys(): Set {
+ return collectionKeySet;
+}
+
+/**
+ * Checks if the given key is a registered collection key (e.g. "report_").
+ */
+function isCollectionKey(key: OnyxKey): key is CollectionKeyBase {
+ return collectionKeySet.has(key);
+}
+
+/**
+ * Checks if the given key is a member of the specified collection key.
+ * e.g. isCollectionMemberKey("report_", "report_123") → true
+ */
+function isCollectionMemberKey(collectionKey: TCollectionKey, key: string): key is `${TCollectionKey}${string}` {
+ return key.startsWith(collectionKey) && key.length > collectionKey.length;
+}
+
+/**
+ * Checks if a given key is a collection member key (not just a collection key).
+ */
+function isCollectionMember(key: OnyxKey): boolean {
+ const collectionKey = getCollectionKey(key);
+ return !!collectionKey && key.length > collectionKey.length;
+}
+
+/**
+ * Checks if the provided key matches the config key — either an exact match
+ * or a collection prefix match.
+ */
+function isKeyMatch(configKey: OnyxKey, key: OnyxKey): boolean {
+ return isCollectionKey(configKey) ? key.startsWith(configKey) : configKey === key;
+}
+
+/**
+ * Extracts the collection key from a collection member key.
+ *
+ * Uses a pre-computed Map for O(1) lookup. Falls back to string parsing
+ * for keys not yet in the map (e.g. before they're cached).
+ *
+ * Examples:
+ * - getCollectionKey("report_123") → "report_"
+ * - getCollectionKey("report_") → "report_"
+ * - getCollectionKey("sharedNVP_user_-1_something") → "sharedNVP_user_"
+ */
+function getCollectionKey(key: CollectionKey | OnyxKey): string | undefined {
+ // Fast path: O(1) Map lookup for known member keys
+ const cached = memberToCollectionKeyMap.get(key);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ // If the key itself is a collection key, return it directly
+ if (isCollectionKey(key)) {
+ return key;
+ }
+
+ // Slow path: string parsing — use a `string` variable to avoid
+ // TypeScript narrowing `key` to `never` after the isCollectionKey guard.
+ const keyStr: string = key;
+ let lastUnderscoreIndex = keyStr.lastIndexOf('_');
+ while (lastUnderscoreIndex > 0) {
+ const possibleKey = keyStr.slice(0, lastUnderscoreIndex + 1);
+ if (isCollectionKey(possibleKey)) {
+ // Cache for future O(1) lookups
+ memberToCollectionKeyMap.set(key, possibleKey);
+ return possibleKey;
+ }
+ lastUnderscoreIndex = keyStr.lastIndexOf('_', lastUnderscoreIndex - 1);
+ }
+
+ return undefined;
+}
+
+/**
+ * Pre-computes and caches the member → collection key mapping for a given key.
+ * Called from OnyxCache.addKey() to ensure the Map stays populated.
+ */
+function registerMemberKey(key: OnyxKey): void {
+ const existingCollectionKey = memberToCollectionKeyMap.get(key);
+ if (existingCollectionKey !== undefined) {
+ // Already in reverse map — ensure forward map is also populated.
+ // getCollectionKey() can populate memberToCollectionKeyMap without
+ // updating collectionToMembersMap, so we must sync here.
+ let members = collectionToMembersMap.get(existingCollectionKey);
+ if (!members) {
+ members = new Set();
+ collectionToMembersMap.set(existingCollectionKey, members);
+ }
+ members.add(key);
+ return;
+ }
+ 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;
+ }
+ }
+}
+
+/**
+ * Removes a member key from the reverse lookup map.
+ * Called when a key is dropped from cache.
+ */
+function deregisterMemberKey(key: OnyxKey): void {
+ const collectionKey = memberToCollectionKeyMap.get(key);
+ if (collectionKey) {
+ const members = collectionToMembersMap.get(collectionKey);
+ if (members) {
+ members.delete(key);
+ if (members.size === 0) {
+ collectionToMembersMap.delete(collectionKey);
+ }
+ }
+ }
+ memberToCollectionKeyMap.delete(key);
+}
+
+/**
+ * Returns the set of member keys for a given collection key.
+ * O(1) lookup using the forward index.
+ */
+function getMembersOfCollection(collectionKey: OnyxKey): Set | undefined {
+ return collectionToMembersMap.get(collectionKey);
+}
+
+/**
+ * Splits a collection member key into the collection key part and the ID part.
+ *
+ * @param key - The collection member key to split
+ * @param collectionKey - Optional pre-resolved collection key for optimization
+ * @returns A tuple of [collectionKey, memberId]
+ * @throws If the key is not a valid collection member key
+ */
+function splitCollectionMemberKey(
+ key: TKey,
+ collectionKey?: string,
+): [CollectionKeyType, string] {
+ if (collectionKey && !isCollectionMemberKey(collectionKey, key)) {
+ throw new Error(`Invalid '${collectionKey}' collection key provided, it isn't compatible with '${key}' key.`);
+ }
+
+ if (!collectionKey) {
+ const resolvedKey = getCollectionKey(key);
+ if (!resolvedKey) {
+ throw new Error(`Invalid '${key}' key provided, only collection keys are allowed.`);
+ }
+ // eslint-disable-next-line no-param-reassign
+ collectionKey = resolvedKey;
+ }
+
+ return [collectionKey as CollectionKeyType, key.slice(collectionKey.length)];
+}
+
+/**
+ * Initializes the RAM-only key set. Called once during Onyx.init().
+ */
+function setRamOnlyKeys(keys: Set): void {
+ ramOnlyKeySet = keys;
+}
+
+/**
+ * Checks if a given key is a RAM-only key, RAM-only collection key, or a RAM-only collection member.
+ *
+ * For example, given ramOnlyKeys: ["ramOnlyKey", "ramOnlyCollection_"]:
+ * - isRamOnlyKey("ramOnlyKey") → true
+ * - isRamOnlyKey("ramOnlyCollection_") → true
+ * - isRamOnlyKey("ramOnlyCollection_1") → true
+ * - isRamOnlyKey("someOtherKey") → false
+ */
+function isRamOnlyKey(key: OnyxKey): boolean {
+ 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,
+ getMembersOfCollection,
+ splitCollectionMemberKey,
+ setRamOnlyKeys,
+ isRamOnlyKey,
+};
diff --git a/lib/OnyxMerge/index.native.ts b/lib/OnyxMerge/index.native.ts
index 5e56bf49a..c01c1524a 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 ef92293d3..dd6c86b5e 100644
--- a/lib/OnyxMerge/index.ts
+++ b/lib/OnyxMerge/index.ts
@@ -1,4 +1,5 @@
import cache from '../OnyxCache';
+import OnyxKeys from '../OnyxKeys';
import OnyxUtils from '../OnyxUtils';
import Storage from '../storage';
import type {OnyxInput, OnyxKey, OnyxValue} from '../types';
@@ -20,7 +21,7 @@ const applyMerge: ApplyMerge = , hasChanged);
- const shouldSkipStorageOperations = !hasChanged || OnyxUtils.isRamOnlyKey(key);
+ const shouldSkipStorageOperations = !hasChanged || OnyxKeys.isRamOnlyKey(key);
// If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead.
// If the key is marked as RAM-only, it should not be saved nor updated in the storage.
diff --git a/lib/OnyxSnapshotCache.ts b/lib/OnyxSnapshotCache.ts
index 891a42460..49ce6a0c3 100644
--- a/lib/OnyxSnapshotCache.ts
+++ b/lib/OnyxSnapshotCache.ts
@@ -1,4 +1,4 @@
-import OnyxUtils from './OnyxUtils';
+import OnyxKeys from './OnyxKeys';
import type {OnyxKey, OnyxValue} from './types';
import type {UseOnyxOptions, UseOnyxResult, UseOnyxSelector} from './useOnyx';
@@ -130,7 +130,7 @@ class OnyxSnapshotCache {
this.snapshotCache.delete(keyToInvalidate);
// Check if the key is a collection member and invalidate the collection base key
- const collectionBaseKey = OnyxUtils.getCollectionKey(keyToInvalidate);
+ const collectionBaseKey = OnyxKeys.getCollectionKey(keyToInvalidate);
if (collectionBaseKey) {
this.snapshotCache.delete(collectionBaseKey);
}
diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts
index 3e884e2b3..9b6706015 100644
--- a/lib/OnyxUtils.ts
+++ b/lib/OnyxUtils.ts
@@ -1,4 +1,4 @@
-import {deepEqual, shallowEqual} from 'fast-equals';
+import {shallowEqual} from 'fast-equals';
import type {ValueOf} from 'type-fest';
import _ from 'underscore';
import DevTools from './DevTools';
@@ -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 = {
@@ -77,9 +77,6 @@ let mergeQueuePromise: Record> = {};
// 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();
@@ -186,9 +183,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());
@@ -201,8 +198,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;
@@ -265,7 +262,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);
}
@@ -282,7 +279,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
@@ -331,7 +328,7 @@ function multiGet(keys: CollectionKeyBase[]): Promise