react-native-onyx
Advanced tools
+17
-4
@@ -20,4 +20,2 @@ import type { ValueOf } from 'type-fest'; | ||
| private storageMap; | ||
| /** Cache of complete collection data objects for O(1) retrieval */ | ||
| private collectionData; | ||
| /** | ||
@@ -32,2 +30,6 @@ * Captured pending tasks for already running storage methods | ||
| private recentlyAccessedKeys; | ||
| /** Frozen collection snapshots for structural sharing */ | ||
| private collectionSnapshots; | ||
| /** Collections whose snapshots need rebuilding (lazy — rebuilt on next read) */ | ||
| private dirtyCollections; | ||
| constructor(); | ||
@@ -92,3 +94,3 @@ /** Get all the storage keys */ | ||
| captureTask(taskName: CacheTask, promise: Promise<OnyxValue<OnyxKey>>): Promise<OnyxValue<OnyxKey>>; | ||
| /** 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<OnyxKey>): boolean; | ||
@@ -133,4 +135,15 @@ /** | ||
| /** | ||
| * Get all data for a collection 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 | ||
| */ | ||
| private rebuildCollectionSnapshot; | ||
| /** | ||
| * 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<OnyxKey, OnyxValue<OnyxKey>> | undefined; | ||
@@ -137,0 +150,0 @@ } |
+125
-39
@@ -11,2 +11,8 @@ "use strict"; | ||
| const OnyxKeys_1 = __importDefault(require("./OnyxKeys")); | ||
| /** | ||
| * 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 = Object.freeze({}); | ||
| // Task constants | ||
@@ -32,6 +38,7 @@ const TASK = { | ||
| 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` | ||
| (0, bindAll_1.default)(this, 'getAllKeys', 'get', 'hasCacheForKey', 'addKey', 'addNullishStorageKey', 'hasNullishStorageKey', 'clearNullishStorageKeys', 'set', 'drop', 'merge', 'hasPendingTask', 'getTaskPromise', 'captureTask', 'setAllKeys', 'setEvictionAllowList', 'isEvictableKey', 'removeLastAccessedKey', 'addLastAccessedKey', 'addEvictableKeysToRecentlyAccessedList', 'getKeyForEviction', 'setCollectionKeys', 'getCollectionData', 'hasValueChanged'); | ||
| (0, bindAll_1.default)(this, 'getAllKeys', 'get', 'hasCacheForKey', 'addKey', 'addNullishStorageKey', 'hasNullishStorageKey', 'clearNullishStorageKeys', 'set', 'drop', 'merge', 'hasPendingTask', 'getTaskPromise', 'captureTask', 'setAllKeys', 'setEvictionAllowList', 'isEvictableKey', 'removeLastAccessedKey', 'addLastAccessedKey', 'addEvictableKeysToRecentlyAccessedList', 'getKeyForEviction', 'setCollectionKeys', 'hasValueChanged', 'getCollectionData'); | ||
| } | ||
@@ -96,7 +103,7 @@ /** Get all the storage keys */ | ||
| const collectionKey = OnyxKeys_1.default.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); | ||
| } | ||
@@ -106,8 +113,4 @@ 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); | ||
| } | ||
@@ -119,10 +122,9 @@ return value; | ||
| delete this.storageMap[key]; | ||
| // Remove from collection data cache if this is a collection member | ||
| const collectionKey = OnyxKeys_1.default.getCollectionKey(key); | ||
| if (collectionKey && this.collectionData[collectionKey]) { | ||
| delete this.collectionData[collectionKey][key]; | ||
| if (collectionKey) { | ||
| this.dirtyCollections.add(collectionKey); | ||
| } | ||
| // If this is a collection key, clear its data | ||
| // If this is a collection key, clear its snapshot | ||
| if (OnyxKeys_1.default.isCollectionKey(key)) { | ||
| delete this.collectionData[key]; | ||
| this.collectionSnapshots.delete(key); | ||
| } | ||
@@ -140,14 +142,16 @@ this.storageKeys.delete(key); | ||
| } | ||
| this.storageMap = Object.assign({}, utils_1.default.fastMerge(this.storageMap, data, { | ||
| shouldRemoveNestedNulls: true, | ||
| objectRemovalMode: 'replace', | ||
| }).result); | ||
| const affectedCollections = new Set(); | ||
| for (const [key, value] of Object.entries(data)) { | ||
| this.addKey(key); | ||
| const collectionKey = OnyxKeys_1.default.getCollectionKey(key); | ||
| if (value === null || value === undefined) { | ||
| if (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]; | ||
| // undefined means "no change" — skip storageMap modification | ||
| continue; | ||
| } | ||
| if (value === null) { | ||
| this.addNullishStorageKey(key); | ||
| delete this.storageMap[key]; | ||
| if (collectionKey) { | ||
| affectedCollections.add(collectionKey); | ||
| } | ||
@@ -157,11 +161,23 @@ } | ||
| 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_1.default.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] = {}; | ||
| } | ||
| this.collectionData[collectionKey][key] = this.storageMap[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); | ||
| } | ||
| } | ||
@@ -196,5 +212,8 @@ /** | ||
| } | ||
| /** 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, value) { | ||
| const currentValue = this.get(key); | ||
| const currentValue = this.storageMap[key]; | ||
| if (currentValue === value) { | ||
| return false; | ||
| } | ||
| return !(0, fast_equals_1.deepEqual)(currentValue, value); | ||
@@ -268,20 +287,87 @@ } | ||
| OnyxKeys_1.default.setCollectionKeys(collectionKeys); | ||
| // Initialize collection data for existing collection keys | ||
| // Initialize frozen snapshots for collection keys | ||
| for (const collectionKey of collectionKeys) { | ||
| if (this.collectionData[collectionKey]) { | ||
| if (!this.collectionSnapshots.has(collectionKey)) { | ||
| this.collectionSnapshots.set(collectionKey, Object.freeze({})); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * 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 | ||
| */ | ||
| rebuildCollectionSnapshot(collectionKey) { | ||
| const previousSnapshot = this.collectionSnapshots.get(collectionKey); | ||
| const members = {}; | ||
| let hasMemberChanges = false; | ||
| // 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_1.default.getMembersOfCollection(collectionKey); | ||
| const keysToScan = memberKeys !== null && memberKeys !== void 0 ? memberKeys : this.storageKeys; | ||
| const needsPrefixCheck = !memberKeys; | ||
| for (const key of keysToScan) { | ||
| // When using the fallback path (scanning all storageKeys instead of the indexed | ||
| // forward lookup), skip keys that don't belong to this collection. | ||
| if (needsPrefixCheck && OnyxKeys_1.default.getCollectionKey(key) !== collectionKey) { | ||
| continue; | ||
| } | ||
| this.collectionData[collectionKey] = {}; | ||
| const val = this.storageMap[key]; | ||
| // Skip null/undefined values — they represent deleted or unset keys | ||
| // and should not be included in the frozen collection snapshot. | ||
| if (val !== undefined && val !== null) { | ||
| members[key] = val; | ||
| // Check if this member's reference changed from the old snapshot | ||
| if (!hasMemberChanges && (!previousSnapshot || previousSnapshot[key] !== val)) { | ||
| hasMemberChanges = true; | ||
| } | ||
| } | ||
| } | ||
| // Check if any members were removed from the previous snapshot. | ||
| // We can't rely on count comparison alone — if one key is removed and another added, | ||
| // the counts match but the snapshot content is different. | ||
| if (!hasMemberChanges && previousSnapshot) { | ||
| // eslint-disable-next-line no-restricted-syntax | ||
| for (const key in previousSnapshot) { | ||
| if (!(key in members)) { | ||
| hasMemberChanges = true; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| // 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 (!hasMemberChanges && previousSnapshot) { | ||
| return; | ||
| } | ||
| Object.freeze(members); | ||
| this.collectionSnapshots.set(collectionKey, 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) { | ||
| const cachedCollection = this.collectionData[collectionKey]; | ||
| if (!cachedCollection || Object.keys(cachedCollection).length === 0) { | ||
| if (this.dirtyCollections.has(collectionKey)) { | ||
| this.rebuildCollectionSnapshot(collectionKey); | ||
| this.dirtyCollections.delete(collectionKey); | ||
| } | ||
| const snapshot = this.collectionSnapshots.get(collectionKey); | ||
| if (utils_1.default.isEmptyObject(snapshot)) { | ||
| // We check storageKeys.size (not collection-specific keys) to distinguish | ||
| // "init complete, this collection is genuinely empty" from "init not done yet." | ||
| // During init, setAllKeys loads ALL keys at once — so if any key exists, | ||
| // the full storage picture is loaded and an empty collection is truly empty. | ||
| // Returning undefined before init prevents subscribers from seeing a false empty state. | ||
| if (this.storageKeys.size > 0) { | ||
| return FROZEN_EMPTY_COLLECTION; | ||
| } | ||
| return undefined; | ||
| } | ||
| // Return a shallow copy to ensure React detects changes when items are added/removed | ||
| return Object.assign({}, cachedCollection); | ||
| return snapshot; | ||
| } | ||
@@ -288,0 +374,0 @@ } |
+11
-1
@@ -112,3 +112,13 @@ "use strict"; | ||
| function isEmptyObject(obj) { | ||
| return typeof obj === 'object' && Object.keys(obj || {}).length === 0; | ||
| if (typeof obj !== 'object') { | ||
| return false; | ||
| } | ||
| // Use for-in loop to avoid an unnecessary array allocation from Object.keys() | ||
| // eslint-disable-next-line no-restricted-syntax | ||
| for (const key in obj) { | ||
| if (Object.hasOwn(obj, key)) { | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
@@ -115,0 +125,0 @@ /** |
+1
-1
| { | ||
| "name": "react-native-onyx", | ||
| "version": "3.0.65", | ||
| "version": "3.0.66", | ||
| "author": "Expensify, Inc.", | ||
@@ -5,0 +5,0 @@ "homepage": "https://expensify.com", |
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
341470
1.62%6545
1.69%37
5.71%