Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 142 additions & 67 deletions lib/Onyx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import Storage from './storage';
import utils from './utils';
import DevTools, {initDevTools} from './DevTools';
import type {
CollectionConnectCallback,
CollectionKeyBase,
ConnectOptions,
DefaultConnectCallback,
InitOptions,
KeyValueMapping,
OnyxCollection,
OnyxInputKeyValueMapping,
MixedOperationsQueue,
OnyxKey,
Expand All @@ -25,10 +28,18 @@ import type {
import OnyxUtils from './OnyxUtils';
import OnyxKeys from './OnyxKeys';
import logMessages from './logMessages';
import type {Connection} from './OnyxConnectionManager';
import connectionManager from './OnyxConnectionManager';
import onyxStore from './OnyxStore';
import OnyxMerge from './OnyxMerge';

/**
* Opaque handle returned by `Onyx.connect()` / `Onyx.connectWithoutView()`.
* Pass it to `Onyx.disconnect()` to stop receiving callbacks for this subscription.
*/
type Connection = {
/** Unsubscribe this connection. Idempotent. */
unsubscribe: () => void;
};

/** Initialize the store with actions and listening for storage events */
function init({
keys = {},
Expand Down Expand Up @@ -58,13 +69,7 @@ function init({
}

cache.set(key, value);

// 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);

OnyxUtils.keyChanged(key, value as OnyxValue<typeof key>, undefined, isKeyCollectionMember);
OnyxUtils.notifyKey(key, value as OnyxValue<typeof key>);
});
}

Expand All @@ -80,73 +85,144 @@ function init({
}

/**
* Connects to an Onyx key given the options passed and listens to its changes.
* This method will be deprecated soon. Please use `Onyx.connectWithoutView()` instead.
* Sync, cache-only read of an Onyx key. Returns the frozen collection snapshot for
* collection keys, the cached value for single keys, or `undefined` if the key isn't
* in cache (no storage fallback).
*
* @example
* ```ts
* const connection = Onyx.connectWithoutView({
* key: ONYXKEYS.SESSION,
* callback: onSessionChange,
* });
* ```
*
* @param connectOptions The options object that will define the behavior of the connection.
* @param connectOptions.key The Onyx key to subscribe to.
* @param connectOptions.callback A function that will be called when the Onyx data we are subscribed changes.
* @param connectOptions.waitForCollectionCallback If set to `true`, it will return the entire collection to the callback as a single object.
* @param connectOptions.selector This will be used to subscribe to a subset of an Onyx key's data. **Only used inside `useOnyx()` hook.**
* Using this setting on `useOnyx()` can have very positive performance benefits because the component will only re-render
* when the subset of data changes. Otherwise, any change of data on any property would normally
* cause the component to re-render (and that can be expensive from a performance standpoint).
* @returns The connection object to use when calling `Onyx.disconnect()`.
* Use this for one-off reads outside React. Inside React, prefer `useOnyx`.
*/
function connect<TKey extends OnyxKey>(connectOptions: ConnectOptions<TKey>): Connection {
return connectionManager.connect(connectOptions);
function getState<TKey extends OnyxKey>(key: TKey): OnyxValue<TKey> {
return onyxStore.getState(key);
}

/**
* Connects to an Onyx key given the options passed and listens to its changes.
* Defer initial-fire of `Onyx.connect` callbacks far enough that any Onyx writes
* scheduled in the same synchronous tick have applied before the callback reads cache.
*
* @example
* ```ts
* const connection = Onyx.connectWithoutView({
* key: ONYXKEYS.SESSION,
* callback: onSessionChange,
* });
* ```
* The legacy `subscribeToKey` chain (`deferredInitTask.then(getAllKeys).then(multiGet)
* .then(sendDataToConnection)`) reached this depth incidentally via storage I/O. The
* new store-based wrapper has no storage chain, so we have to introduce the depth
* explicitly. The three nested `.then()`s match the legacy effective depth — enough
* to outpace the longest in-flight write chain: `Onyx.update` -> `clearPromise.then`
* -> per-item `Onyx.merge` -> `OnyxUtils.get(key).then(applyMerge)` is two hops to
* apply, so the third hop guarantees initial-fire reads the post-write cache.
*
* @param connectOptions The options object that will define the behavior of the connection.
* @param connectOptions.key The Onyx key to subscribe to.
* @param connectOptions.callback A function that will be called when the Onyx data we are subscribed changes.
* @param connectOptions.waitForCollectionCallback If set to `true`, it will return the entire collection to the callback as a single object.
* @param connectOptions.selector This will be used to subscribe to a subset of an Onyx key's data. **Only used inside `useOnyx()` hook.**
* Using this setting on `useOnyx()` can have very positive performance benefits because the component will only re-render
* when the subset of data changes. Otherwise, any change of data on any property would normally
* cause the component to re-render (and that can be expensive from a performance standpoint).
* @returns The connection object to use when calling `Onyx.disconnect()`.
* Microtask depth (not `setTimeout(0)`) is required because Jest test bodies run
* entirely in microtask land via chained `.then()`s; a macrotask-deferred initial
* fire would not run until the chain returns to the event loop, which can be after
* the test's assertions execute — leaving module-level Onyx subscribers stale.
*/
function connectWithoutView<TKey extends OnyxKey>(connectOptions: ConnectOptions<TKey>): Connection {
return connectionManager.connect(connectOptions);
function scheduleInitialFire(fn: () => void): void {
Promise.resolve()
.then(() => Promise.resolve())
.then(() => Promise.resolve())
.then(fn);
}

/**
* Disconnects and removes the listener from the Onyx key.
* Subscribe to changes for `key`.
*
* @example
* ```ts
* const connection = Onyx.connectWithoutView({
* key: ONYXKEYS.SESSION,
* callback: onSessionChange,
* });
*
* Onyx.disconnect(connection);
* ```
* For a collection root key, the callback fires with the entire frozen collection
* snapshot whenever any member changes; signature `(collection, collectionKey)`.
* For any other key, the callback fires with the value at that key; signature
* `(value, key)`. Initial fire is deferred via `scheduleInitialFire` so it reads
* cache after any same-tick writes have applied.
*
* @param connection Connection object returned by calling `Onyx.connect()` or `Onyx.connectWithoutView()`.
* Returns synchronously with a `Connection` handle. Disconnecting is idempotent.
*/
function connect<TKey extends OnyxKey>(connectOptions: ConnectOptions<TKey>): Connection {
const {key, callback} = connectOptions;

let active = true;
let unsubscribeFn: (() => void) | null = null;

const wireUp = () => {
if (!active) {
return;
}

if (OnyxKeys.isCollectionKey(key)) {
// Collection-root snapshot mode — listener fires with the whole snapshot per
// collection change. Callback shape is `(snapshot, key)`. Dedup: skip identical
// snapshot refs. Initial fire always delivers the current snapshot (frozen `{}`
// for an empty-but-known collection, `undefined` only if the collection key has
// not been seen yet).
const NOT_DELIVERED = Symbol('NOT_DELIVERED');
let lastDeliveredSnapshot: unknown = NOT_DELIVERED;
const deliverSnapshot = (rawSnapshot: OnyxValue<TKey> | undefined, k: TKey) => {
if (Object.is(lastDeliveredSnapshot, rawSnapshot)) {
return;
}
lastDeliveredSnapshot = rawSnapshot;
(callback as CollectionConnectCallback<TKey> | undefined)?.(rawSnapshot as NonNullable<OnyxCollection<KeyValueMapping[TKey]>>, k);
};
unsubscribeFn = onyxStore.subscribe(key, (value, k) => {
deliverSnapshot(value as unknown as OnyxValue<TKey>, k as TKey);
});
scheduleInitialFire(() => {
if (!active) {
return;
}
deliverSnapshot(onyxStore.getState(key) as unknown as OnyxValue<TKey>, key as TKey);
});
return;
}

// Non-collection key (or a specific collection member) — single-value subscription.
const NOT_DELIVERED = Symbol('NOT_DELIVERED');
let lastDelivered: unknown = NOT_DELIVERED;
const deliverValue = (value: OnyxValue<TKey>, k: TKey | undefined) => {
if (Object.is(lastDelivered, value)) {
return;
}
lastDelivered = value;
(callback as DefaultConnectCallback<TKey> | undefined)?.(value, k as TKey);
};
unsubscribeFn = onyxStore.subscribe(key, (value, k) => {
deliverValue(value, k as TKey);
});
scheduleInitialFire(() => {
if (!active) {
return;
}
deliverValue(onyxStore.getState(key), key);
});
};

OnyxUtils.afterInit(() => {
wireUp();
return Promise.resolve();
});

return {
unsubscribe: () => {
if (!active) {
return;
}
active = false;
if (unsubscribeFn) {
unsubscribeFn();
unsubscribeFn = null;
}
},
};
}

/**
* Identical to `connect()` — kept for naming consistency with existing call sites.
*/
function connectWithoutView<TKey extends OnyxKey>(connectOptions: ConnectOptions<TKey>): Connection {
return connect(connectOptions);
}

/**
* Disconnects a subscription previously returned by `connect()` / `connectWithoutView()`.
*/
function disconnect(connection: Connection): void {
connectionManager.disconnect(connection);
if (!connection) {
return;
}
connection.unsubscribe();
}

/**
Expand Down Expand Up @@ -284,7 +360,7 @@ function merge<TKey extends OnyxKey>(key: TKey, changes: OnyxMergeInput<TKey>):
* @param collection Object collection keyed by individual collection member keys and values
*/
function mergeCollection<TKey extends CollectionKeyBase>(collectionKey: TKey, collection: OnyxMergeCollectionInput<TKey>): Promise<void> {
return OnyxUtils.afterInit(() => OnyxUtils.mergeCollectionWithPatches({collectionKey, collection, isProcessingCollectionUpdate: true}));
return OnyxUtils.afterInit(() => OnyxUtils.mergeCollectionWithPatches({collectionKey, collection}));
}

/**
Expand Down Expand Up @@ -384,17 +460,16 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise<void> {
// Remove only the items that we want cleared from storage, and reset others to default
for (const key of keysToBeClearedFromStorage) cache.drop(key);
return Storage.removeItems(keysToBeClearedFromStorage)
.then(() => connectionManager.refreshSessionID())
.then(() => Storage.multiSet(defaultKeyValuePairs))
.then(() => {
DevTools.clearState(keysToPreserve);

// Notify the subscribers for each key/value group so they can receive the new values
for (const [key, value] of Object.entries(keyValuesToResetIndividually)) {
OnyxUtils.keyChanged(key, value);
OnyxUtils.notifyKey(key, value);
}
for (const [key, value] of Object.entries(keyValuesToResetAsCollection)) {
OnyxUtils.keysChanged(key, value.newValues, value.oldValues);
OnyxUtils.notifyCollection(key, value.newValues, value.oldValues);
}
});
})
Expand Down Expand Up @@ -525,7 +600,6 @@ function update<TKey extends OnyxKey>(data: Array<OnyxUpdate<TKey>>): Promise<vo
collectionKey,
collection: batchedCollectionUpdates.merge as OnyxMergeCollectionInput<OnyxKey>,
mergeReplaceNullPatches: batchedCollectionUpdates.mergeReplaceNullPatches,
isProcessingCollectionUpdate: true,
}),
);
}
Expand Down Expand Up @@ -574,6 +648,7 @@ function setCollection<TKey extends CollectionKeyBase>(collectionKey: TKey, coll

const Onyx = {
METHOD: OnyxUtils.METHOD,
getState,
connect,
connectWithoutView,
disconnect,
Expand All @@ -589,4 +664,4 @@ const Onyx = {
};

export default Onyx;
export type {OnyxUpdate, ConnectOptions, SetOptions};
export type {OnyxUpdate, ConnectOptions, SetOptions, Connection};
12 changes: 6 additions & 6 deletions lib/OnyxCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,12 +460,12 @@ class OnyxCache {

const snapshot = this.collectionSnapshots.get(collectionKey);
if (utils.isEmptyObject(snapshot)) {
// We check storageKeys.size (not collection-specific keys) to distinguish
// "init complete, this collection is genuinely empty" from "init not done yet."
// During init, setAllKeys loads ALL keys at once — so if any key exists,
// the full storage picture is loaded and an empty collection is truly empty.
// Returning undefined before init prevents subscribers from seeing a false empty state.
if (this.storageKeys.size > 0) {
// Distinguish "init complete, collection genuinely empty" from "init not done yet."
// `setCollectionKeys()` (called inside `Onyx.init`) seeds every known collection
// with a frozen `{}` entry in `collectionSnapshots`, so the presence of the entry
// is a reliable post-init signal — and unlike `storageKeys.size > 0`, it doesn't
// flip back to "not done" after `Onyx.clear()` wipes the storage-keys index.
if (this.collectionSnapshots.has(collectionKey)) {
return FROZEN_EMPTY_COLLECTION;
}
return undefined;
Expand Down
Loading
Loading