From 5e2a75a6e8aa96af5eb6c2ebe8da802bfc56904a Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Tue, 11 Aug 2026 13:00:10 +0200 Subject: [PATCH 1/7] perf: skip per-key clone when Onyx.init() hydrates the cache --- lib/OnyxCache.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++ lib/OnyxUtils.ts | 6 +++-- lib/utils.ts | 30 +++++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index e105a0ab6..7d68b4409 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -77,6 +77,7 @@ class OnyxCache { 'set', 'drop', 'merge', + 'hydrate', 'hasPendingTask', 'getTaskPromise', 'captureTask', @@ -199,6 +200,73 @@ class OnyxCache { OnyxKeys.deregisterMemberKey(key); } + /** + * Bulk-loads values into a cache that's expected to be empty, skipping merge()'s per-key clone when + * safe. Falls back to a real merge for any key that already has a value, in case the cache wasn't + * empty after all. Used only by `Onyx.init()`. + * @param data - a map of (cache) key - values + */ + hydrate(data: Record>): void { + if (typeof data !== 'object' || Array.isArray(data)) { + throw new Error('data passed to cache.hydrate() must be an Object of onyx key/value pairs'); + } + + const affectedCollections = new Set(); + + // eslint-disable-next-line no-restricted-syntax, guard-for-in + for (const key in data) { + if (!Object.hasOwn(data, key)) { + continue; + } + + const value = data[key]; + this.addKey(key); + + if (value === undefined) { + this.addNullishStorageKey(key); + continue; + } + + const collectionKey = OnyxKeys.getCollectionKey(key); + + if (value === null) { + this.addNullishStorageKey(key); + delete this.storageMap[key]; + + if (collectionKey) { + affectedCollections.add(collectionKey); + } + } else { + this.nullishStorageKeys.delete(key); + + const existing = this.storageMap[key]; + + if (existing !== undefined) { + // Key already has a value, so the empty-cache assumption doesn't hold here - merge instead of clobbering. + this.storageMap[key] = utils.fastMerge(existing, value, { + shouldRemoveNestedNulls: true, + objectRemovalMode: 'replace', + }).result; + } else if (utils.needsNormalization(value)) { + this.storageMap[key] = utils.fastMerge(undefined, value, { + shouldRemoveNestedNulls: true, + objectRemovalMode: 'replace', + }).result; + } else { + this.storageMap[key] = value; + } + + if (collectionKey) { + affectedCollections.add(collectionKey); + } + } + } + + for (const collectionKey of affectedCollections) { + this.dirtyCollections.add(collectionKey); + } + } + /** * Deep merge data to cache, any non existing keys will be created * @param data - a map of (cache) key - values diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 400cfbb2b..6b8c5b6d9 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1039,9 +1039,11 @@ function initializeWithDefaultKeyStates(): Promise { allDataFromStorage[key] = value; } - // Load all storage data into cache silently (no subscriber notifications) + // Load all storage data into cache silently (no subscriber notifications). + // hydrate() rather than merge(): the cache is empty at this point, so a per-key fastMerge + // would only deep-clone every row it was handed. cache.setAllKeys(Object.keys(allDataFromStorage)); - cache.merge(allDataFromStorage); + cache.hydrate(allDataFromStorage); // For keys that have a developer-defined default (via `initialKeyStates`), merge the // persisted value with the default so new properties added in code updates are applied diff --git a/lib/utils.ts b/lib/utils.ts index 287f4cf26..45895be1a 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -196,6 +196,35 @@ function isMergeableObject>(value: unkno return isNonNullObject && !(value instanceof RegExp) && !(value instanceof Date) && !Array.isArray(value); } +/** + * Reports whether a value needs cleaning (nested null/undefined, or the replace-object mark) before it's + * safe to store by reference. Read-only, non-allocating. + */ +function needsNormalization(value: unknown): boolean { + if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + // eslint-disable-next-line no-restricted-syntax, guard-for-in + for (const key in value) { + if (key === ONYX_INTERNALS__REPLACE_OBJECT_MARK) { + return true; + } + + const propertyValue = (value as Record)[key]; + + if (propertyValue === null || propertyValue === undefined) { + return true; + } + + if (typeof propertyValue === 'object' && !Array.isArray(propertyValue) && needsNormalization(propertyValue)) { + return true; + } + } + + return false; +} + /** 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' || Array.isArray(value)) { @@ -346,6 +375,7 @@ export default { isEmptyObject, formatActionName, removeNestedNullValues, + needsNormalization, checkCompatibilityWithExistingValue, pick, omit, From 4cca1cb9e1348d43b0494b158bab5c8a8aa74933 Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Thu, 13 Aug 2026 12:30:10 +0200 Subject: [PATCH 2/7] refactor: align hydrate() fallback with merge() and share merge options --- lib/OnyxCache.ts | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index 7d68b4409..4e1c43e79 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -2,6 +2,7 @@ import {deepEqual} from 'fast-equals'; import bindAll from 'lodash/bindAll'; import type {ValueOf} from 'type-fest'; import utils from './utils'; +import type {FastMergeOptions} from './utils'; import type {CollectionKeyBase, KeyValueMapping, NonUndefined, OnyxCollection, OnyxKey, OnyxValue} from './types'; import OnyxKeys from './OnyxKeys'; @@ -15,6 +16,16 @@ type CollectionSnapshot = Readonly>> = Object.freeze({}); +/** + * Merge options shared by every cache write path (`merge()` and `hydrate()`'s fallback), so the + * three call sites can't drift apart. Cached values must never hold nested nulls, and a source + * object carrying the replace mark must replace the target object rather than merge into it. + */ +const CACHE_MERGE_OPTIONS: FastMergeOptions = { + shouldRemoveNestedNulls: true, + objectRemovalMode: 'replace', +}; + // Task constants const TASK = { GET: 'get', @@ -242,16 +253,21 @@ class OnyxCache { const existing = this.storageMap[key]; if (existing !== undefined) { - // Key already has a value, so the empty-cache assumption doesn't hold here - merge instead of clobbering. - this.storageMap[key] = utils.fastMerge(existing, value, { - shouldRemoveNestedNulls: true, - objectRemovalMode: 'replace', - }).result; + // Key already has a value, so the empty-cache assumption doesn't hold here (e.g. a write + // landed while storage was still being read). Fall back to a real merge, which has exactly + // the same semantics as the old `cache.merge(allDataFromStorage)` init path: the value + // loaded from disk is the merge source, so it wins on any overlapping leaf key. + const merged = utils.fastMerge(existing, value, CACHE_MERGE_OPTIONS).result; + + // fastMerge is reference-stable: returns the original target when nothing changed, so a + // simple === check detects no-ops and avoids dirtying the collection for nothing. + if (merged === existing) { + continue; + } + + this.storageMap[key] = merged; } else if (utils.needsNormalization(value)) { - this.storageMap[key] = utils.fastMerge(undefined, value, { - shouldRemoveNestedNulls: true, - objectRemovalMode: 'replace', - }).result; + this.storageMap[key] = utils.fastMerge(undefined, value, CACHE_MERGE_OPTIONS).result; } else { this.storageMap[key] = value; } @@ -301,10 +317,7 @@ class OnyxCache { // 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; + const merged = utils.fastMerge(existing, value, CACHE_MERGE_OPTIONS).result; // fastMerge is reference-stable: returns the original target when // nothing changed, so a simple === check detects no-ops. From aeb22556bc532b8cee96ee4ca746189509106f32 Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Thu, 13 Aug 2026 13:23:11 +0200 Subject: [PATCH 3/7] test: cover cache.hydrate() and assert parity with merge() --- tests/unit/onyxCacheTest.tsx | 267 +++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) diff --git a/tests/unit/onyxCacheTest.tsx b/tests/unit/onyxCacheTest.tsx index e7a3bc7af..8f3dc86fc 100644 --- a/tests/unit/onyxCacheTest.tsx +++ b/tests/unit/onyxCacheTest.tsx @@ -8,6 +8,31 @@ import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; const MOCK_TASK = 'mockTask' as CacheTask; +/** + * Builds an extra cache instance in its own module registry, so a `hydrate()` result can be compared + * against the `merge()` result for the same input without the two sharing any state. + */ +function createIsolatedCache(): typeof OnyxCache { + let isolated!: typeof OnyxCache; + + jest.isolateModules(() => { + isolated = require('../../lib/OnyxCache').default; + }); + + return isolated; +} + +/** Asserts that two cache instances are indistinguishable through the cache's public read surface. */ +function expectSameCacheState(actual: typeof OnyxCache, expected: typeof OnyxCache, keys: string[]) { + expect(actual.getAllKeys()).toEqual(expected.getAllKeys()); + + for (const key of keys) { + expect(actual.get(key)).toEqual(expected.get(key)); + expect(actual.hasCacheForKey(key)).toBe(expected.hasCacheForKey(key)); + expect(actual.hasNullishStorageKey(key)).toBe(expected.hasNullishStorageKey(key)); + } +} + describe('Onyx', () => { describe('Cache Service', () => { /** @type OnyxCache */ @@ -362,6 +387,176 @@ describe('Onyx', () => { }); }); + describe('hydrate', () => { + // Every value shape init can hand the cache. Built fresh per call because `hydrate()` stores + // values by reference, so the two caches under comparison must never share source objects. + const buildStorageData = (): Record => ({ + stringKey: 'mockValue', + numberKey: 0, + booleanKey: false, + emptyObjectKey: {}, + plainObjectKey: {value: 'mockValue'}, + nestedObjectKey: {a: {b: {c: 1}}}, + nestedNullsKey: {keep: 1, drop: null, nested: {keep: 2, drop: null}}, + arrayKey: [{ID: 1}, {ID: 2}], + objectWithArrayKey: {ID: [1, 2]}, + nullKey: null, + undefinedKey: undefined, + }); + + const ALL_KEYS = Object.keys(buildStorageData()); + + it('Should produce the same cache state as merge() for an empty cache', () => { + // Given two empty caches + const mergeCache = createIsolatedCache(); + + // When one is hydrated and the other merged with the same data + cache.hydrate(buildStorageData()); + mergeCache.merge(buildStorageData()); + + // Then both caches are indistinguishable through their public read surface + expectSameCacheState(cache, mergeCache, ALL_KEYS); + }); + + it('Should produce the same cache state as merge() when keys already have values', () => { + // Given two caches holding the same pre-existing values (e.g. a write landed while + // storage was still being read). Both existing values overlap the storage value on a + // leaf, so the merge direction is actually exercised. + const mergeCache = createIsolatedCache(); + const buildExistingData = () => ({ + plainObjectKey: {value: 'fromWrite', fromWrite: true}, + nestedObjectKey: {a: {b: {c: 99, fromWrite: true}}}, + stringKey: 'fromWrite', + }); + + for (const [key, value] of Object.entries(buildExistingData())) { + cache.set(key, value); + mergeCache.set(key, value); + } + + // When one is hydrated and the other merged with the same data + cache.hydrate(buildStorageData()); + mergeCache.merge(buildStorageData()); + + // Then the fallback merge inside hydrate() matches merge() exactly + expectSameCacheState(cache, mergeCache, ALL_KEYS); + + // And the value loaded from storage wins on every overlapping leaf, as merge() does + expect(cache.get('plainObjectKey')).toEqual({value: 'mockValue', fromWrite: true}); + expect(cache.get('nestedObjectKey')).toEqual({a: {b: {c: 1, fromWrite: true}}}); + expect(cache.get('stringKey')).toBe('mockValue'); + }); + + it('Should register every key, including keys with nullish values', () => { + // When hydrate is called with nullish and non-nullish values + cache.hydrate(buildStorageData()); + + // Then all of them are reported by getAllKeys, so nothing is invisible to reads + expect(cache.getAllKeys()).toEqual(new Set(ALL_KEYS)); + expect(cache.getAllKeys().has('nullKey')).toBe(true); + expect(cache.getAllKeys().has('undefinedKey')).toBe(true); + }); + + it('Should store values by reference when no normalization is needed', () => { + // Given a value with no nested nullish properties + const value = {id: 1, nested: {deep: true}}; + + // When hydrate is called on an empty cache + cache.hydrate({mockKey: value}); + + // Then the value is stored as is - this is the clone that hydrate() exists to skip + expect(cache.get('mockKey')).toBe(value); + }); + + it('Should normalize values that carry nested nullish properties', () => { + // Given a value with nested nulls + const value = {keep: 1, drop: null, nested: {keep: 2, drop: null}}; + + // When hydrate is called + cache.hydrate({mockKey: value}); + + // Then the cached value is a cleaned copy + expect(cache.get('mockKey')).not.toBe(value); + expect(cache.get('mockKey')).toEqual({keep: 1, nested: {keep: 2}}); + + // And the source object handed to hydrate is left untouched + expect(value).toEqual({keep: 1, drop: null, nested: {keep: 2, drop: null}}); + }); + + it('Should keep the existing reference when the hydrated value changes nothing', () => { + // Given a cache that already holds a deep-equal value + const existing = {id: 1, nested: {deep: true}}; + cache.set('mockKey', existing); + + // When hydrate is called with an equal but distinct value + cache.hydrate({mockKey: {id: 1, nested: {deep: true}}}); + + // Then the reference is preserved, so subscribers don't see a spurious change + expect(cache.get('mockKey')).toBe(existing); + }); + + it('Should remove `null` values from the storage map and mark them nullish', () => { + // Given a cache with an existing value + cache.set('mockKey', {ID: 5}); + + // When hydrate is called with null + cache.hydrate({mockKey: null, mockNullKey: null}); + + // Then the values are dropped but the keys stay known + expect(cache.get('mockKey')).toBeUndefined(); + expect(cache.get('mockNullKey')).toBeUndefined(); + expect(cache.hasNullishStorageKey('mockKey')).toBe(true); + expect(cache.hasNullishStorageKey('mockNullKey')).toBe(true); + expect(cache.getAllKeys()).toEqual(new Set(['mockKey', 'mockNullKey'])); + }); + + it('Should leave the existing value untouched for `undefined` values', () => { + // Given a cache with an existing value + cache.set('mockKey', {ID: 5}); + + // When hydrate is called with undefined, which means "no change" + cache.hydrate({mockKey: undefined}); + + // Then the value is unchanged + expect(cache.get('mockKey')).toEqual({ID: 5}); + }); + + it('Should expose hydrated collection members through collection reads, same as merge()', () => { + // Given two caches that know about the same collection key + const mergeCache = createIsolatedCache(); + const collectionKey = 'mock_collection_'; + const collectionData = { + [`${collectionKey}1`]: {id: 1}, + [`${collectionKey}2`]: {id: 2}, + [`${collectionKey}3`]: null, + }; + + cache.setCollectionKeys(new Set([collectionKey])); + mergeCache.setCollectionKeys(new Set([collectionKey])); + + // When one is hydrated and the other merged with the same collection members + cache.hydrate({...collectionData}); + mergeCache.merge({...collectionData}); + + // Then the collection snapshot is rebuilt and reports the same members + expect(cache.getCollectionData(collectionKey)).toEqual({ + [`${collectionKey}1`]: {id: 1}, + [`${collectionKey}2`]: {id: 2}, + }); + expect(cache.getCollectionData(collectionKey)).toEqual(mergeCache.getCollectionData(collectionKey)); + }); + + it('Should throw if called with anything that is not an object', () => { + // @ts-expect-error -- intentionally testing invalid input + expect(() => cache.hydrate([])).toThrow(); + // @ts-expect-error -- intentionally testing invalid input + expect(() => cache.hydrate('')).toThrow(); + // @ts-expect-error -- intentionally testing invalid input + expect(() => cache.hydrate(0)).toThrow(); + expect(() => cache.hydrate({})).not.toThrow(); + }); + }); + describe('hasPendingTask', () => { it('Should return false when there is no started task', () => { // Given empty cache with no started tasks @@ -538,6 +733,78 @@ describe('Onyx', () => { expect(allKeys.has(ONYX_KEYS.OTHER_TEST)).toBe(true); expect(allKeys.has(`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}1`)).toBe(true); }); + + it('should register keys whose stored value is nullish', async () => { + // Given storage holding a null value alongside a normal one + await StorageMock.setItem(ONYX_KEYS.TEST_KEY, null); + await StorageMock.setItem(ONYX_KEYS.OTHER_TEST, 'value'); + await initOnyx(); + + // Then the nullish key is still part of the key index, so reads don't treat it as unloaded + expect(cache.getAllKeys()).toEqual(new Set([ONYX_KEYS.TEST_KEY, ONYX_KEYS.OTHER_TEST])); + expect(cache.get(ONYX_KEYS.TEST_KEY)).toBeUndefined(); + expect(cache.hasNullishStorageKey(ONYX_KEYS.TEST_KEY)).toBe(true); + expect(cache.hasCacheForKey(ONYX_KEYS.TEST_KEY)).toBe(true); + }); + + it('should expose collection members loaded from storage through collection reads', async () => { + // Given storage holding collection members + await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}1`, {id: 1, name: 'Item 1'}); + await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}2`, {id: 2, name: 'Item 2'}); + await initOnyx(); + + // Then the collection index is populated, so the snapshot contains both members + expect(cache.getCollectionData(ONYX_KEYS.COLLECTION.MOCK_COLLECTION)).toEqual({ + [`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}1`]: {id: 1, name: 'Item 1'}, + [`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}2`]: {id: 2, name: 'Item 2'}, + }); + expect(OnyxKeys.getCollectionKey(`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}1`)).toBe(ONYX_KEYS.COLLECTION.MOCK_COLLECTION); + }); + + it('should merge, not overwrite, keys written while storage was still being read', async () => { + // Given a storage read that has not resolved yet + let releaseGetAll: (() => void) | undefined; + const gate = new Promise((resolve) => { + releaseGetAll = resolve; + }); + (StorageMock.getAll as jest.Mock).mockImplementationOnce(() => gate.then(() => [[ONYX_KEYS.TEST_KEY, {fromStorage: true, shared: 'fromStorage'}]])); + + Onyx.init({keys: ONYX_KEYS}); + + // When a write lands on that key before the read completes + cache.set(ONYX_KEYS.TEST_KEY, {fromWrite: true, shared: 'fromWrite'}); + releaseGetAll?.(); + await waitForPromisesToResolve(); + + // Then hydrate falls back to a merge instead of dropping the write, and the value from + // storage wins on the overlapping leaf - exactly what the old merge-based init did + expect(cache.get(ONYX_KEYS.TEST_KEY)).toEqual({fromStorage: true, fromWrite: true, shared: 'fromStorage'}); + }); + + it('should leave the cache in the same state a merge-based init would produce', async () => { + // Given storage holding every value shape init has to deal with + const storageData: Record = { + [ONYX_KEYS.TEST_KEY]: 'storageValue', + [ONYX_KEYS.OTHER_TEST]: {nested: {value: 1}, keep: true, drop: null}, + [`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}1`]: {id: 1, name: 'Item 1'}, + [`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}2`]: [{ID: 1}, {ID: 2}], + [`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}3`]: null, + }; + const allKeys = Object.keys(storageData); + + await StorageMock.multiSet(Object.entries(storageData)); + await initOnyx(); + + // When the previous init path (setAllKeys + merge) is replayed on a separate cache + const mergeCache = createIsolatedCache(); + mergeCache.setCollectionKeys(new Set([ONYX_KEYS.COLLECTION.MOCK_COLLECTION])); + mergeCache.setAllKeys(allKeys); + mergeCache.merge({...storageData}); + + // Then the hydrated cache is indistinguishable from it + expectSameCacheState(cache, mergeCache, allKeys); + expect(cache.getCollectionData(ONYX_KEYS.COLLECTION.MOCK_COLLECTION)).toEqual(mergeCache.getCollectionData(ONYX_KEYS.COLLECTION.MOCK_COLLECTION)); + }); }); describe('getCollectionData', () => { From 4a4a8812c29103d0e694878929b4172746061d09 Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Thu, 13 Aug 2026 13:24:33 +0200 Subject: [PATCH 4/7] style: remove unused guard-for-in eslint-disable in hydrate() --- lib/OnyxCache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index 4e1c43e79..dba477e2b 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -224,7 +224,7 @@ class OnyxCache { const affectedCollections = new Set(); - // eslint-disable-next-line no-restricted-syntax, guard-for-in + // eslint-disable-next-line no-restricted-syntax for (const key in data) { if (!Object.hasOwn(data, key)) { continue; From 9c43dcffcbbc255b23e6817127fb14cbdd9040be Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Thu, 13 Aug 2026 14:27:37 +0200 Subject: [PATCH 5/7] docs: explain for-in eslint disables in cache hydrate path --- lib/OnyxCache.ts | 1 + lib/utils.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index dba477e2b..7cbb13513 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -224,6 +224,7 @@ class OnyxCache { const affectedCollections = new Set(); + // Use for-in loop to avoid an unnecessary array allocation from Object.keys() // eslint-disable-next-line no-restricted-syntax for (const key in data) { if (!Object.hasOwn(data, key)) { diff --git a/lib/utils.ts b/lib/utils.ts index 45895be1a..9992f577a 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -205,6 +205,7 @@ function needsNormalization(value: unknown): boolean { return false; } + // Use for-in loop to avoid an unnecessary array allocation from Object.keys() // eslint-disable-next-line no-restricted-syntax, guard-for-in for (const key in value) { if (key === ONYX_INTERNALS__REPLACE_OBJECT_MARK) { From 51d14dd882d212b9c0259c656b7c700c0e3d44f5 Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Fri, 14 Aug 2026 14:28:10 +0200 Subject: [PATCH 6/7] fix(cache): reject null data in cache.hydrate() --- lib/OnyxCache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index 7cbb13513..f23117703 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -218,7 +218,7 @@ class OnyxCache { * @param data - a map of (cache) key - values */ hydrate(data: Record>): void { - if (typeof data !== 'object' || Array.isArray(data)) { + if (data === null || typeof data !== 'object' || Array.isArray(data)) { throw new Error('data passed to cache.hydrate() must be an Object of onyx key/value pairs'); } From 25cf1cb277bfdb12e4b2f8ee301229753d955999 Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Fri, 14 Aug 2026 14:42:34 +0200 Subject: [PATCH 7/7] perf(init): drop redundant setAllKeys() before cache.hydrate() --- lib/Onyx.ts | 2 +- lib/OnyxUtils.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 0a0d8abc9..4356ec36d 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -109,7 +109,7 @@ function init({ // Initialize all of our keys with data provided then give green light to any pending connections. // addEvictableKeysToRecentlyAccessedList must run after initializeWithDefaultKeyStates because - // eager cache loading populates the key index (cache.setAllKeys) inside initializeWithDefaultKeyStates, + // eager cache loading populates the key index (cache.hydrate) inside initializeWithDefaultKeyStates, // and the evictable keys list depends on that index being populated. OnyxUtils.initializeWithDefaultKeyStates() .then(() => cache.addEvictableKeysToRecentlyAccessedList(OnyxKeys.isCollectionKey, OnyxUtils.getAllKeys)) diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 6b8c5b6d9..252a9fa27 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1042,7 +1042,8 @@ function initializeWithDefaultKeyStates(): Promise { // Load all storage data into cache silently (no subscriber notifications). // hydrate() rather than merge(): the cache is empty at this point, so a per-key fastMerge // would only deep-clone every row it was handed. - cache.setAllKeys(Object.keys(allDataFromStorage)); + // No setAllKeys() call is needed: hydrate() calls addKey() for every key, which populates the + // key index and registers collection member keys itself. cache.hydrate(allDataFromStorage); // For keys that have a developer-defined default (via `initialKeyStates`), merge the