-
Notifications
You must be signed in to change notification settings - Fork 97
perf: skip per-key clone when Onyx.init() hydrates the cache #821
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5e2a75a
4cca1cb
aeb2255
4a4a881
9c43dcf
51d14dd
25cf1cb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<NonUndefined<OnyxCollection<KeyValueMapping[O | |
| */ | ||
| const FROZEN_EMPTY_COLLECTION: Readonly<NonUndefined<OnyxCollection<KeyValueMapping[OnyxKey]>>> = 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', | ||
|
|
@@ -77,6 +88,7 @@ class OnyxCache { | |
| 'set', | ||
| 'drop', | ||
| 'merge', | ||
| 'hydrate', | ||
| 'hasPendingTask', | ||
| 'getTaskPromise', | ||
| 'captureTask', | ||
|
|
@@ -199,6 +211,79 @@ 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<OnyxKey, OnyxValue<OnyxKey>>): void { | ||
| 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'); | ||
| } | ||
|
|
||
| const affectedCollections = new Set<OnyxKey>(); | ||
|
|
||
| // 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)) { | ||
| 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 (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. | ||
|
Comment on lines
+256
to
+260
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This race fallback cannot detect a cross-tab deletion that landed before hydrate, so a stale row is resurrected. Suggested additional comment: |
||
| 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, CACHE_MERGE_OPTIONS).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 | ||
|
|
@@ -233,10 +318,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. | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.