Skip to content
69 changes: 53 additions & 16 deletions lib/Onyx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,21 @@ import type {
ConnectOptions,
InitOptions,
KeyValueMapping,
OnyxInputKeyValueMapping,
MixedOperationsQueue,
NonUndefined,
OnyxCollection,
OnyxEntry,
OnyxInput,
OnyxInputKeyValueMapping,
OnyxKey,
OnyxMergeCollectionInput,
OnyxSetCollectionInput,
OnyxMergeInput,
OnyxMethodMap,
OnyxMultiSetInput,
OnyxSetCollectionInput,
OnyxSetInput,
OnyxUpdate,
OnyxValue,
OnyxInput,
OnyxMethodMap,
SetOptions,
} from './types';
import OnyxUtils from './OnyxUtils';
Expand Down Expand Up @@ -50,21 +53,55 @@ function init({
OnyxKeys.setRamOnlyKeys(new Set<OnyxKey>(ramOnlyKeys));

if (shouldSyncMultipleInstances) {
Storage.keepInstancesSync?.((key, value) => {
// RAM-only keys should never sync from storage as they may have stale persisted data
// from before the key was migrated to RAM-only.
if (OnyxKeys.isRamOnlyKey(key)) {
return;
}
// Cross-tab sync (InstanceSync) hands us the full batch of key/value pairs that changed together in
// a single write. We process it synchronously, grouping collection members so each affected
// collection is notified once (mirroring the local mergeCollection batching) instead of
// re-delivering the whole collection per member.
Storage.keepInstancesSync?.((pairs) => {
const individual: Array<[OnyxKey, OnyxEntry<KeyValueMapping[OnyxKey]>]> = [];
const collectionBatches = new Map<string, {partial: NonUndefined<OnyxCollection<KeyValueMapping[OnyxKey]>>; previous: NonUndefined<OnyxCollection<KeyValueMapping[OnyxKey]>>}>();

for (const [key, value] of pairs) {
// RAM-only keys should never sync from storage as they may have stale persisted data
// from before the key was migrated to RAM-only.
if (OnyxKeys.isRamOnlyKey(key)) {
continue;
}

cache.set(key, value);
const collectionKey = OnyxKeys.getCollectionKey(key);
const isCollectionMember = !!collectionKey && OnyxKeys.isCollectionMemberKey(collectionKey, key);

// Check if this is a collection member key to prevent duplicate callbacks
// When a collection is updated, individual members sync separately to other tabs
// Setting isProcessingCollectionUpdate=true prevents triggering collection callbacks for each individual update
const isKeyCollectionMember = OnyxKeys.isCollectionMember(key);
// Capture the previous cached value BEFORE cache.set() so keysChanged() can diff old vs new per member.
const previousValue = isCollectionMember ? cache.get(key) : undefined;
cache.set(key, value);

OnyxUtils.keyChanged(key, value as OnyxValue<typeof key>, undefined, isKeyCollectionMember);
if (isCollectionMember && collectionKey) {
let batch = collectionBatches.get(collectionKey);
if (!batch) {
batch = {partial: {}, previous: {}};
collectionBatches.set(collectionKey, batch);
}
batch.partial[key] = value;

// Keep the earliest previous value in case the same member appears twice in one batch.
Comment thread
Beamanator marked this conversation as resolved.
if (!(key in batch.previous)) {
batch.previous[key] = previousValue;
}
} else {
individual.push([key, value]);
}
}

// Non-collection keys: notify individually, matching keyChanged() semantics for exact keys.
for (const [key, value] of individual) {
OnyxUtils.keyChanged(key, value);
}

// One keysChanged() per collection notifies the collection-root subscriber once and lets
// keysChanged() decide which individual member subscribers actually changed.
for (const [collectionKey, {partial, previous}] of collectionBatches) {
OnyxUtils.keysChanged(collectionKey, partial, previous);
}
});
}

Expand Down
87 changes: 76 additions & 11 deletions lib/storage/InstanceSync/index.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,101 @@
* when using LocalStorage APIs in the browser. These events are great because multiple tabs can listen for when
* data changes and then stay up-to-date with everything happening in Onyx.
*/
import * as Logger from '../../Logger';
import type {OnyxKey} from '../../types';
import NoopProvider from '../providers/NoopProvider';
import type {StorageKeyList, OnStorageKeyChanged} from '../providers/types';
import type {StorageKeyList, OnStorageKeysChanged} from '../providers/types';
import type StorageProvider from '../providers/types';

const SYNC_ONYX = 'SYNC_ONYX';

// localStorage stores values as UTF-16 (~2 bytes/char). The per-origin quota isn't fixed by the spec —
// it's user-agent dependent and commonly ~5MB — so we keep each SYNC_ONYX payload conservatively small
// (and pair it with a try/catch in emitSyncEvent). This way a large key batch (e.g. Onyx.clear() on a
// heavy account, or a bulk import) is split across several events instead of throwing QuotaExceededError.
// See https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API
const MAX_SYNC_PAYLOAD_LENGTH = 1_000_000;

/**
* Raise an event through `localStorage` to let other tabs know a value changed
* @param {String} onyxKey
* Parses the SYNC_ONYX storage event value.
* The payload is a JSON array of the changed keys (a batch). It falls back to treating the raw
* value as a single key for backwards compatibility with the previous one-key-per-event format.
*/
function raiseStorageSyncEvent(onyxKey: OnyxKey) {
global.localStorage.setItem(SYNC_ONYX, onyxKey);
global.localStorage.removeItem(SYNC_ONYX);
function parseSyncOnyxStorageEventValue(value: string): StorageKeyList {
let onyxKeys: StorageKeyList;
try {
const parsed = JSON.parse(value) as StorageKeyList | string;
onyxKeys = Array.isArray(parsed) ? parsed : [value];
} catch {
onyxKeys = [value];
}

return onyxKeys;
}

/**
* Emit a single SYNC_ONYX storage event. Wrapped so a failed cross-tab signal
* degrades gracefully — other tabs simply miss this update until their next organic sync/reload — instead
* of throwing an uncaught rejection in the writing tab.
*/
function emitSyncEvent(value: string) {
try {
global.localStorage.setItem(SYNC_ONYX, value);
global.localStorage.removeItem(SYNC_ONYX);
} catch (error) {
Logger.logAlert(`[InstanceSync] Failed to raise storage sync event: ${error}`);
}
}

/**
* Raise cross-tab event(s) for a batch of changed keys. Sending keys together (instead of one event per
* key) preserves the write's batch boundary across tabs, so the receiving tab notifies collection
* subscribers once for the whole batch — matching the local mergeCollection behavior — instead of
* re-delivering the whole collection once per member (O(N^2), which can crash the tab). Large batches are
* chunked so no single payload approaches the localStorage quota.
*/
function raiseStorageSyncManyKeysEvent(onyxKeys: StorageKeyList) {
if (onyxKeys.length === 0) {
return;
}

let chunk: StorageKeyList = [];
let chunkLength = 2; // accounts for the surrounding `[]`
for (const onyxKey of onyxKeys) {
raiseStorageSyncEvent(onyxKey);
const keyLength = onyxKey.length + 3; // quotes + comma separator
if (chunk.length > 0 && chunkLength + keyLength > MAX_SYNC_PAYLOAD_LENGTH) {
emitSyncEvent(JSON.stringify(chunk));
chunk = [];
chunkLength = 2;
}
chunk.push(onyxKey);
chunkLength += keyLength;
}
if (chunk.length > 0) {
emitSyncEvent(JSON.stringify(chunk));
Comment on lines 22 to +77

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chunking can split a collection across two events.

Chunks are cut purely by payload size, so if one collection's members land in two different chunks, the receiver sees two storage events → two keysChanged calls → two collection notifications instead of one.
This partially undoes the O(N²)→O(1) win exactly in the large-batch case chunking exists for.
Grouping by collection before chunking would avoid it.

Not blocker though

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think its an edge case , maybe not worth to fix for now.
what do you think @fabioh8010 ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I don't think it's harmful to keep current way, the split is supposed to happen only with really big payloads in specific situations.

}
}

/**
* Raise an event through `localStorage` to let other tabs know a single key changed.
*
* This intentionally emits the raw key (the legacy, pre-batching format) rather than a JSON array, so a
* tab still running the previous bundle during a deploy keeps receiving single-key updates (a new message,
* a pin, a rename, etc.). Only multi-key writes use the batched JSON-array format; the receiver here
* understands both. The mixed-version gap is therefore limited to bulk collection writes, which resolve on reload.
*/
function raiseStorageSyncEvent(onyxKey: OnyxKey) {
emitSyncEvent(onyxKey);
}

let storage = NoopProvider;

const InstanceSync = {
shouldBeUsed: true,
/**
* @param {Function} onStorageKeyChanged Storage synchronization mechanism keeping all opened tabs in sync
* @param {Function} onStorageKeysChanged Storage synchronization mechanism keeping all opened tabs in sync
*/
init: (onStorageKeyChanged: OnStorageKeyChanged, store: StorageProvider<unknown>) => {
init: (onStorageKeysChanged: OnStorageKeysChanged, store: StorageProvider<unknown>) => {
storage = store;

// This listener will only be triggered by events coming from other tabs
Expand All @@ -42,9 +107,9 @@ const InstanceSync = {
return;
}

const onyxKey = event.newValue;
const onyxKeys = parseSyncOnyxStorageEventValue(event.newValue);

storage.getItem(onyxKey).then((value) => onStorageKeyChanged(onyxKey, value));
storage.multiGet(onyxKeys).then((pairs) => onStorageKeysChanged(pairs));
});
},
setItem: raiseStorageSyncEvent,
Expand Down
6 changes: 3 additions & 3 deletions lib/storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,14 +188,14 @@ const storage: Storage = {
getDatabaseSize: () => tryOrDegradePerformance(() => provider.getDatabaseSize()),

/**
* @param onStorageKeyChanged - Storage synchronization mechanism keeping all opened tabs in sync (web only)
* @param onStorageKeysChanged - Storage synchronization mechanism keeping all opened tabs in sync (web only)
*/
keepInstancesSync(onStorageKeyChanged) {
keepInstancesSync(onStorageKeysChanged) {
// If InstanceSync shouldn't be used, it means we're on a native platform and we don't need to keep instances in sync
if (!InstanceSync.shouldBeUsed) return;

shouldKeepInstancesSync = true;
InstanceSync.init(onStorageKeyChanged, this);
InstanceSync.init(onStorageKeysChanged, this);
},
};

Expand Down
9 changes: 5 additions & 4 deletions lib/storage/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ type DatabaseSize = {
usageDetails?: Record<string, number>;
};

type OnStorageKeyChanged = <TKey extends OnyxKey>(key: TKey, value: OnyxValue<TKey>) => void;
/** Called with the full batch of key/value pairs that changed together in a single cross-tab sync event. */
type OnStorageKeysChanged = (pairs: StorageKeyValuePair[]) => void;

type StorageProvider<TStore> = {
store: TStore;
Expand Down Expand Up @@ -97,10 +98,10 @@ type StorageProvider<TStore> = {
classifyError: (error: unknown) => ValueOf<typeof StorageErrorClass>;

/**
* @param onStorageKeyChanged Storage synchronization mechanism keeping all opened tabs in sync
* @param onStorageKeysChanged Storage synchronization mechanism keeping all opened tabs in sync
*/
keepInstancesSync?: (onStorageKeyChanged: OnStorageKeyChanged) => void;
keepInstancesSync?: (onStorageKeysChanged: OnStorageKeysChanged) => void;
};

export default StorageProvider;
export type {StorageKeyList, StorageKeyValuePair, OnStorageKeyChanged};
export type {StorageKeyList, StorageKeyValuePair, OnStorageKeysChanged};
Loading
Loading