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 31c50c38e..d13caa89d 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -1044,9 +1044,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,