diff --git a/API-INTERNAL.md b/API-INTERNAL.md
index bc8c0b467..a56f5c6ad 100644
--- a/API-INTERNAL.md
+++ b/API-INTERNAL.md
@@ -111,10 +111,11 @@ provider) once so it's visible, then bounded retry without eviction.
broadcastUpdate()
Notifies subscribers and writes current value to cache
-prepareKeyValuePairsForStorage() ⇒
+prepareKeyValuePairsForStorage()
Storage expects array like: [["@MyApp_user", value_1], ["@MyApp_key", value_2]]
This method transforms an object like {'@MyApp_user': myUserValue, '@MyApp_key': myKeyValue}
-to an array of key-value pairs in the above format and removes key-value pairs that are being set to null
+to an array of key-value pairs in the above format, and collects the keys of null values into
+keysToRemove for the caller to delete as one batch (cache drop + notification + batched storage removal).
mergeChanges(changes, existingValue)
Merges an array of changes with an existing value or creates a single change.
@@ -376,13 +377,13 @@ Notifies subscribers and writes current value to cache
**Kind**: global function
-## prepareKeyValuePairsForStorage() ⇒
+## prepareKeyValuePairsForStorage()
Storage expects array like: [["@MyApp_user", value_1], ["@MyApp_key", value_2]]
This method transforms an object like {'@MyApp_user': myUserValue, '@MyApp_key': myKeyValue}
-to an array of key-value pairs in the above format and removes key-value pairs that are being set to null
+to an array of key-value pairs in the above format, and collects the keys of null values into
+`keysToRemove` for the caller to delete as one batch (cache drop + notification + batched storage removal).
**Kind**: global function
-**Returns**: an array of key - value pairs <[key, value]>
## mergeChanges(changes, existingValue)
diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts
index 31c50c38e..9cb8ebb5c 100644
--- a/lib/OnyxUtils.ts
+++ b/lib/OnyxUtils.ts
@@ -64,6 +64,12 @@ function resetDiskPressureLogThrottle(): void {
type OnyxMethod = ValueOf;
+/** Result of `prepareKeyValuePairsForStorage`: pairs to write and keys whose `null` value marks them for removal. */
+type PreparedKeyValuePairs = {
+ pairs: StorageKeyValuePair[];
+ keysToRemove: OnyxKey[];
+};
+
// Key/value store of Onyx key and arrays of values to merge
let mergeQueue: Record>> = {};
let mergeQueuePromise: Record> = {};
@@ -919,21 +925,20 @@ function hasPendingMergeForKey(key: OnyxKey): boolean {
/**
* Storage expects array like: [["@MyApp_user", value_1], ["@MyApp_key", value_2]]
* This method transforms an object like {'@MyApp_user': myUserValue, '@MyApp_key': myKeyValue}
- * to an array of key-value pairs in the above format and removes key-value pairs that are being set to null
- *
- * @return an array of key - value pairs <[key, value]>
+ * to an array of key-value pairs in the above format, and collects the keys of null values into
+ * `keysToRemove` for the caller to delete as one batch (cache drop + notification + batched storage removal).
*/
function prepareKeyValuePairsForStorage(
data: Record>,
shouldRemoveNestedNulls?: boolean,
replaceNullPatches?: MultiMergeReplaceNullPatches,
- isProcessingCollectionUpdate?: boolean,
-): StorageKeyValuePair[] {
+): PreparedKeyValuePairs {
const pairs: StorageKeyValuePair[] = [];
+ const keysToRemove: OnyxKey[] = [];
for (const [key, value] of Object.entries(data)) {
if (value === null) {
- remove(key, isProcessingCollectionUpdate);
+ keysToRemove.push(key);
continue;
}
@@ -944,7 +949,7 @@ function prepareKeyValuePairsForStorage(
}
}
- return pairs;
+ return {pairs, keysToRemove};
}
/**
@@ -1408,7 +1413,7 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom
}, {});
}
- const keyValuePairsToSet = OnyxUtils.prepareKeyValuePairsForStorage(newData, true);
+ const {pairs: keyValuePairsToSet, keysToRemove} = OnyxUtils.prepareKeyValuePairsForStorage(newData, true);
// Group collection members by their parent collection key so each collection can be notified
// via a single batched keysChanged() call instead of one keyChanged() per member. For each
@@ -1456,6 +1461,27 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom
}
}
+ // Null keys join the same per-collection batches (as undefined) and are deleted from storage
+ // in one batched call below, so cross-tab sync raises a single event instead of one per key.
+ for (const key of keysToRemove) {
+ const previousValue = cache.get(key);
+ cache.drop(key);
+
+ const collectionKey = OnyxKeys.getCollectionKey(key);
+ if (collectionKey && OnyxKeys.isCollectionMemberKey(collectionKey, key)) {
+ let batch = collectionBatches.get(collectionKey);
+ if (!batch) {
+ batch = {partial: {}, previous: {}};
+ collectionBatches.set(collectionKey, batch);
+ }
+ batch.partial[key] = undefined;
+ batch.previous[key] = previousValue;
+ } else if (!retryAttempt) {
+ // Skip subscriber notification on retry — already notified on attempt 0.
+ keyChanged(key, undefined);
+ }
+ }
+
// One keysChanged() per collection — fires each collection-level subscriber once and lets
// keysChanged() internally decide which individual member subscribers need notification.
// Skip on retry — already notified on attempt 0 (see same-reason comment above).
@@ -1470,10 +1496,16 @@ function multiSetWithRetry(data: OnyxMultiSetInput, retryAttempt?: number): Prom
// Filter out the RAM-only key value pairs, as they should not be saved to storage
return !OnyxKeys.isRamOnlyKey(key);
});
+ const keysToRemoveFromStorage = keysToRemove.filter((key) => !OnyxKeys.isRamOnlyKey(key));
const inFlightKeys = new Set(keyValuePairsToSet.map(([key]) => key));
- return Storage.multiSet(keyValuePairsToStore)
+ const storagePromises = [Storage.multiSet(keyValuePairsToStore)];
+ if (keysToRemoveFromStorage.length > 0) {
+ storagePromises.push(Storage.removeItems(keysToRemoveFromStorage));
+ }
+
+ return Promise.all(storagePromises)
.then(() => StorageCircuitBreaker.recordWriteSuccess())
.catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt, inFlightKeys))
.then(() => {
@@ -1534,10 +1566,14 @@ function setCollectionWithRetry({collectionKey,
mutableCollection[key] = null;
}
- const keyValuePairs = OnyxUtils.prepareKeyValuePairsForStorage(mutableCollection, true, undefined, true);
+ const {pairs: keyValuePairs, keysToRemove: removalCandidates} = OnyxUtils.prepareKeyValuePairsForStorage(mutableCollection, true);
+ // Removals of keys that are neither cached nor persisted are no-ops and skipped.
+ const keysToRemove = removalCandidates.filter((key) => cache.get(key) !== undefined || persistedKeys.has(key));
+ // Snapshot before cache mutations so keysChanged() can diff removed members.
const previousCollection = OnyxUtils.getCachedCollection(collectionKey);
for (const [key, value] of keyValuePairs) cache.set(key, value);
+ for (const key of keysToRemove) cache.drop(key);
// Skip subscriber notification on retry — already notified on attempt 0.
// Collection-root subscribers re-fire on every keysChanged by contract.
@@ -1553,7 +1589,13 @@ function setCollectionWithRetry({collectionKey,
const inFlightKeys = new Set(keyValuePairs.map(([key]) => key));
- return Storage.multiSet(keyValuePairs)
+ // One batched removal = one cross-tab sync event instead of one per key.
+ const storagePromises = [Storage.multiSet(keyValuePairs)];
+ if (keysToRemove.length > 0) {
+ storagePromises.push(Storage.removeItems(keysToRemove));
+ }
+
+ return Promise.all(storagePromises)
.then(() => StorageCircuitBreaker.recordWriteSuccess())
.catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, {collectionKey, collection}, retryAttempt, inFlightKeys))
.then(() => {
@@ -1612,10 +1654,14 @@ function mergeCollectionWithPatches(
return getAllKeys()
.then((persistedKeys) => {
- // Split to keys that exist in storage and keys that don't
+ // Split to keys that exist in storage and keys that don't. Null members are collected
+ // for one batched removal below; nulls that are neither cached nor persisted are no-ops and skipped.
+ const keysToRemove: OnyxKey[] = [];
const keys = resultCollectionKeys.filter((key) => {
if (resultCollection[key] === null) {
- remove(key, isProcessingCollectionUpdate);
+ if (cache.get(key) !== undefined || persistedKeys.has(key)) {
+ keysToRemove.push(key);
+ }
return false;
}
return true;
@@ -1658,11 +1704,11 @@ function mergeCollectionWithPatches(
// When (multi-)merging the values with the existing values in storage,
// we don't want to remove nested null values from the data that we pass to the storage layer,
// because the storage layer uses them to remove nested keys from storage natively.
- const keyValuePairsForExistingCollection = prepareKeyValuePairsForStorage(existingKeyCollection, false, mergeReplaceNullPatches);
+ const {pairs: keyValuePairsForExistingCollection} = prepareKeyValuePairsForStorage(existingKeyCollection, false, mergeReplaceNullPatches);
// We can safely remove nested null values when using (multi-)set,
// because we will simply overwrite the existing values in storage.
- const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage(newCollection, true);
+ const {pairs: keyValuePairsForNewCollection} = prepareKeyValuePairsForStorage(newCollection, true);
// finalMergedCollection contains all the keys that were merged, without the keys of incompatible updates
const finalMergedCollection = {
@@ -1688,15 +1734,33 @@ function mergeCollectionWithPatches(
// ensuring subscribers still reflect the merged data even if the subsequent storage
// write fails.
const previousCollection = getCachedCollection(collectionKey, existingKeys);
+
+ // Removed members join the same keysChanged() batch (as undefined); previous values
+ // are snapshotted before the cache drop so keysChanged() can diff them.
+ const removedPreviousValues: OnyxInputKeyValueMapping = {};
+ for (const key of keysToRemove) {
+ removedPreviousValues[key] = cache.get(key);
+ cache.drop(key);
+ }
+
cache.merge(finalMergedCollection);
// Skip subscriber notification on retry — already notified on attempt 0.
// Collection-root subscribers re-fire on every keysChanged by contract.
if (!retryAttempt) {
- keysChanged(collectionKey, finalMergedCollection, previousCollection);
+ const partialForNotify = keysToRemove.length > 0 ? {...finalMergedCollection, ...Object.fromEntries(keysToRemove.map((key) => [key, undefined]))} : finalMergedCollection;
+ const previousForNotify = keysToRemove.length > 0 ? {...previousCollection, ...removedPreviousValues} : previousCollection;
+ if (Object.keys(partialForNotify).length > 0) {
+ keysChanged(collectionKey, partialForNotify, previousForNotify);
+ }
}
const promises = [];
+ // One batched removal = one cross-tab sync event instead of one per key.
+ if (!OnyxKeys.isRamOnlyKey(collectionKey) && keysToRemove.length > 0) {
+ promises.push(Storage.removeItems(keysToRemove));
+ }
+
// New keys go through multiSet and existing keys through multiMerge. multiMerge on a
// missing key stores the value just like multiSet across all backends; splitting them lets
// multiSet strip nested nulls (the merge layer keeps them to delete nested storage keys).
@@ -1777,10 +1841,14 @@ function partialSetCollection({collectionKey, co
return getAllKeys().then((persistedKeys) => {
const mutableCollection: OnyxInputKeyValueMapping = {...resultCollection};
const existingKeys = resultCollectionKeys.filter((key) => persistedKeys.has(key));
+ const {pairs: keyValuePairs, keysToRemove: removalCandidates} = prepareKeyValuePairsForStorage(mutableCollection, true);
+ // Removals of keys that are neither cached nor persisted are no-ops and skipped.
+ const keysToRemove = removalCandidates.filter((key) => cache.get(key) !== undefined || persistedKeys.has(key));
+ // Snapshot before cache mutations so keysChanged() can diff removed members.
const previousCollection = getCachedCollection(collectionKey, existingKeys);
- const keyValuePairs = prepareKeyValuePairsForStorage(mutableCollection, true, undefined, true);
for (const [key, value] of keyValuePairs) cache.set(key, value);
+ for (const key of keysToRemove) cache.drop(key);
// Skip subscriber notification on retry — already notified on attempt 0.
// Collection-root subscribers re-fire on every keysChanged by contract.
@@ -1795,7 +1863,13 @@ function partialSetCollection({collectionKey, co
const inFlightKeys = new Set(keyValuePairs.map(([key]) => key));
- return Storage.multiSet(keyValuePairs)
+ // One batched removal = one cross-tab sync event instead of one per key.
+ const storagePromises = [Storage.multiSet(keyValuePairs)];
+ if (keysToRemove.length > 0) {
+ storagePromises.push(Storage.removeItems(keysToRemove));
+ }
+
+ return Promise.all(storagePromises)
.then(() => StorageCircuitBreaker.recordWriteSuccess())
.catch((error) => retryOperation(error, partialSetCollection, {collectionKey, collection}, retryAttempt, inFlightKeys))
.then(() => {
diff --git a/lib/storage/InstanceSync/index.web.ts b/lib/storage/InstanceSync/index.web.ts
index b79491cab..640adcf38 100644
--- a/lib/storage/InstanceSync/index.web.ts
+++ b/lib/storage/InstanceSync/index.web.ts
@@ -100,6 +100,10 @@ const InstanceSync = {
init: (onStorageKeysChanged: OnStorageKeysChanged, store: StorageProvider) => {
storage = store;
+ // Coalesce storage events into one dispatch per tick: a per-key sender would otherwise re-run
+ // the whole notification pipeline once per key and can flood the receiving tab into unresponsiveness.
+ let pendingSyncKeys: Set | null = null;
+
// This listener will only be triggered by events coming from other tabs
global.addEventListener('storage', (event) => {
// Ignore events that don't originate from the SYNC_ONYX logic
@@ -109,7 +113,19 @@ const InstanceSync = {
const onyxKeys = parseSyncOnyxStorageEventValue(event.newValue);
- storage.multiGet(onyxKeys).then((pairs) => onStorageKeysChanged(pairs));
+ if (pendingSyncKeys) {
+ for (const onyxKey of onyxKeys) {
+ pendingSyncKeys.add(onyxKey);
+ }
+ return;
+ }
+
+ pendingSyncKeys = new Set(onyxKeys);
+ setTimeout(() => {
+ const keys = Array.from(pendingSyncKeys ?? []);
+ pendingSyncKeys = null;
+ storage.multiGet(keys).then((pairs) => onStorageKeysChanged(pairs));
+ }, 0);
});
},
setItem: raiseStorageSyncEvent,
diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts
index 938a64038..ef914c9a8 100644
--- a/tests/unit/onyxTest.ts
+++ b/tests/unit/onyxTest.ts
@@ -2981,6 +2981,137 @@ describe('Onyx', () => {
});
});
+ describe('batched collection member removals', () => {
+ const routeA = `${ONYX_KEYS.COLLECTION.ROUTES}A`;
+ const routeB = `${ONYX_KEYS.COLLECTION.ROUTES}B`;
+
+ it('mergeCollection deletes null members from cache and storage via one batched removeItems call', async () => {
+ await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, {
+ [routeA]: {name: 'Route A'},
+ [routeB]: {name: 'Route B'},
+ } as GenericCollection);
+
+ (StorageMock.removeItem as jest.Mock).mockClear();
+ (StorageMock.removeItems as jest.Mock).mockClear();
+
+ await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, {
+ [routeA]: null,
+ [routeB]: {name: 'Route B v2'},
+ } as GenericCollection);
+
+ // Per-key removals would raise one cross-tab sync event per member; the batch must persist in one call.
+ expect(StorageMock.removeItem).not.toHaveBeenCalled();
+ expect(StorageMock.removeItems).toHaveBeenCalledTimes(1);
+ expect(StorageMock.removeItems).toHaveBeenCalledWith([routeA]);
+
+ expect(cache.get(routeA)).toBeUndefined();
+ const keys = await OnyxUtils.getAllKeys();
+ expect(keys.has(routeA)).toBe(false);
+ expect(keys.has(routeB)).toBe(true);
+ });
+
+ it('mergeCollection notifies member subscribers about batched removals', async () => {
+ await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, {
+ [routeA]: {name: 'Route A'},
+ } as GenericCollection);
+
+ let received: unknown = 'sentinel';
+ connection = Onyx.connect({
+ key: routeA,
+ callback: (value) => (received = value),
+ });
+ await waitForPromisesToResolve();
+ expect(received).toEqual({name: 'Route A'});
+
+ await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, {
+ [routeA]: null,
+ } as GenericCollection);
+
+ expect(received).toBeUndefined();
+ });
+
+ it('mergeCollection skips removals of members that are neither cached nor persisted', async () => {
+ const routeMissing = `${ONYX_KEYS.COLLECTION.ROUTES}Missing`;
+
+ await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, {
+ [routeA]: {name: 'Route A'},
+ } as GenericCollection);
+
+ (StorageMock.removeItem as jest.Mock).mockClear();
+ (StorageMock.removeItems as jest.Mock).mockClear();
+
+ // Nulling a member that was never stored must not raise any storage removal.
+ await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, {
+ [routeMissing]: null,
+ [routeA]: {name: 'Route A v2'},
+ } as GenericCollection);
+
+ expect(StorageMock.removeItem).not.toHaveBeenCalled();
+ expect(StorageMock.removeItems).not.toHaveBeenCalled();
+ });
+
+ it('setCollection deletes missing members via one batched removeItems call', async () => {
+ await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.ROUTES, {
+ [routeA]: {name: 'Route A'},
+ [routeB]: {name: 'Route B'},
+ } as GenericCollection);
+
+ (StorageMock.removeItem as jest.Mock).mockClear();
+ (StorageMock.removeItems as jest.Mock).mockClear();
+
+ await Onyx.setCollection(ONYX_KEYS.COLLECTION.ROUTES, {
+ [routeA]: {name: 'New Route A'},
+ } as GenericCollection);
+
+ expect(StorageMock.removeItem).not.toHaveBeenCalled();
+ expect(StorageMock.removeItems).toHaveBeenCalledTimes(1);
+ expect(StorageMock.removeItems).toHaveBeenCalledWith([routeB]);
+
+ const keys = await OnyxUtils.getAllKeys();
+ expect(keys.has(routeB)).toBe(false);
+ });
+
+ it('notifies member subscribers when a cached-only (RAM-only) member is removed via a batched set', async () => {
+ const ramKey = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}removal`;
+
+ await Onyx.mergeCollection(ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION, {
+ [ramKey]: {name: 'RAM member'},
+ } as GenericCollection);
+
+ let received: unknown = 'sentinel';
+ connection = Onyx.connect({
+ key: ramKey,
+ callback: (value) => (received = value),
+ });
+ await waitForPromisesToResolve();
+ expect(received).toEqual({name: 'RAM member'});
+
+ // Two set updates on members of the same collection are batched into partialSetCollection,
+ // where the removed member exists only in cache (RAM-only keys are never persisted).
+ const ramKeyOther = `${ONYX_KEYS.COLLECTION.RAM_ONLY_COLLECTION}other`;
+ await Onyx.update([
+ {onyxMethod: Onyx.METHOD.SET, key: ramKey, value: null},
+ {onyxMethod: Onyx.METHOD.SET, key: ramKeyOther, value: {name: 'other'}},
+ ]);
+
+ expect(received).toBeUndefined();
+ });
+
+ it('multiSet deletes null keys via one batched removeItems call', async () => {
+ await Onyx.multiSet({[ONYX_KEYS.OTHER_TEST]: 42});
+
+ (StorageMock.removeItem as jest.Mock).mockClear();
+ (StorageMock.removeItems as jest.Mock).mockClear();
+
+ await Onyx.multiSet({[ONYX_KEYS.OTHER_TEST]: null});
+
+ expect(StorageMock.removeItem).not.toHaveBeenCalled();
+ expect(StorageMock.removeItems).toHaveBeenCalledTimes(1);
+ expect(StorageMock.removeItems).toHaveBeenCalledWith([ONYX_KEYS.OTHER_TEST]);
+ expect(cache.get(ONYX_KEYS.OTHER_TEST)).toBeUndefined();
+ });
+ });
+
describe('clear', () => {
it('should handle RAM-only keys with defaults correctly during clear', async () => {
// Set a value for RAM-only key
diff --git a/tests/unit/storage/instanceSyncWebTest.ts b/tests/unit/storage/instanceSyncWebTest.ts
index c27d5a85f..0dc750494 100644
--- a/tests/unit/storage/instanceSyncWebTest.ts
+++ b/tests/unit/storage/instanceSyncWebTest.ts
@@ -109,6 +109,34 @@ describe('InstanceSync (web)', () => {
expect(multiGet).toHaveBeenCalledWith(['123']);
});
+ it('coalesces a burst of storage events into one multiGet and one dispatch', async () => {
+ // A tab running an older bundle emits one event per key; the burst must collapse into one batch.
+ storageEventHandler({key: SYNC_ONYX, newValue: 'test_1'});
+ storageEventHandler({key: SYNC_ONYX, newValue: JSON.stringify(['test_2', 'test_3'])});
+ storageEventHandler({key: SYNC_ONYX, newValue: 'test_2'});
+ await waitForPromisesToResolve();
+
+ expect(multiGet).toHaveBeenCalledTimes(1);
+ expect(multiGet).toHaveBeenCalledWith(['test_1', 'test_2', 'test_3']);
+ expect(onStorageKeysChanged).toHaveBeenCalledTimes(1);
+ expect(onStorageKeysChanged).toHaveBeenCalledWith([
+ ['test_1', 'value_of_test_1'],
+ ['test_2', 'value_of_test_2'],
+ ['test_3', 'value_of_test_3'],
+ ]);
+ });
+
+ it('dispatches separate batches for separate bursts', async () => {
+ storageEventHandler({key: SYNC_ONYX, newValue: 'test_1'});
+ await waitForPromisesToResolve();
+ storageEventHandler({key: SYNC_ONYX, newValue: 'test_2'});
+ await waitForPromisesToResolve();
+
+ expect(multiGet).toHaveBeenCalledTimes(2);
+ expect(multiGet).toHaveBeenNthCalledWith(1, ['test_1']);
+ expect(multiGet).toHaveBeenNthCalledWith(2, ['test_2']);
+ });
+
it('ignores storage events that are not SYNC_ONYX', async () => {
storageEventHandler({key: 'someOtherKey', newValue: 'test_1'});
storageEventHandler({key: SYNC_ONYX, newValue: null});