-
Notifications
You must be signed in to change notification settings - Fork 97
Fix cross-tab sync for collection-root subscribers #815
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
Changes from all commits
e75f5f4
05130ea
57a1564
60c627e
f4587d6
6c0cf38
303e1b6
cbfe683
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 |
|---|---|---|
|
|
@@ -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
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. 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 Not blocker though
Contributor
Author
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. I think its an edge case , maybe not worth to fix for now.
Contributor
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. 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 | ||
|
|
@@ -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, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.