Skip to content
Merged
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
196 changes: 196 additions & 0 deletions lib/OnyxSubscriptionManager.ts
Comment thread
fabioh8010 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import type {CollectionKeyBase, KeyValueMapping, OnyxCollection, OnyxKey, OnyxValue} from './types';

import * as Logger from './Logger';
import cache from './OnyxCache';
import OnyxKeys from './OnyxKeys';

/**
* Listener fired when an exact key's value changes.
*/
type Listener<TKey extends OnyxKey = OnyxKey> = (value: OnyxValue<TKey>, key: TKey) => void;

/**
* Generic listener, without specific types for the keys or values.
* This way a single Map can hold listeners for every key type.
*/
type GenericListener = (value: unknown, key: OnyxKey) => void;

/**
Comment thread
fabioh8010 marked this conversation as resolved.
* OnyxSubscriptionManager is a registry for Onyx subscriptions.
* Subscriptions are stored in `keyListeners`, a flat map keyed by OnyxKey.
* Subscribers are notified on a per-key basis:
* - `notifyKey` for individual keys or collection members
* - `notifyCollection` for batch updates to collections
*/
class OnyxSubscriptionManager {
private keyListeners: Map<OnyxKey, Set<GenericListener>>;

constructor() {
this.keyListeners = new Map();
}

/**
* Returns the frozen collection object for collection
* keys, the cached value for single keys, or `undefined` if not in cache.
*/
getState<TKey extends OnyxKey>(key: TKey): OnyxValue<TKey> {
Comment thread
fabioh8010 marked this conversation as resolved.
if (OnyxKeys.isCollectionKey(key)) {
return cache.getCollectionData(key) as OnyxValue<TKey>;
}
return cache.get(key) as OnyxValue<TKey>;
}

/**
* Subscribe to an exact key. For a collection root key this is "collection mode": the
* listener fires with the frozen collection object whenever any member changes. For a
* collection member key or a regular key, the listener fires when that key's value changes.
*
* Returns an unsubscribe function.
*/
subscribe<TKey extends OnyxKey>(key: TKey, listener: Listener<TKey>): () => void {
let listeners = this.keyListeners.get(key);
if (!listeners) {
listeners = new Set();
this.keyListeners.set(key, listeners);
}

listeners.add(listener as GenericListener);

return () => {
const set = this.keyListeners.get(key);
if (!set) {
return;
}

set.delete(listener as GenericListener);

if (set.size === 0) {
this.keyListeners.delete(key);
}
};
}

/**
* Notify of a single-key write.
*
* Dispatch:
* 1. keyListeners.get(key): exact-key subscribers (always fires).
* 2. If key is a collection member, keyListeners.get(collectionKey): collection
* listeners for the parent collection.
*/
notifyKey<TKey extends OnyxKey>(key: TKey, value: OnyxValue<TKey>): void {
// 1. Exact-key listeners
const exact = this.keyListeners.get(key);
if (exact && exact.size > 0) {
for (const listener of [...exact]) {
this.safeInvoke(() => listener(value, key), key);
}
}

// 2. Collection-level routing. Only fires when the write is to a member key.

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.

Not sure if you missed this from my last review. if direct writes to a collection root are an unsupported anti-pattern, why not prevent them at compile time?

diff --git a/lib/OnyxSubscriptionManager.ts b/lib/OnyxSubscriptionManager.ts
index 7e61ec7e..8bc43472 100644
--- a/lib/OnyxSubscriptionManager.ts
+++ b/lib/OnyxSubscriptionManager.ts
@@ -78,7 +78,7 @@ class OnyxSubscriptionManager {
      *   2. If key is a collection member, keyListeners.get(collectionKey): collection
      *      listeners for the parent collection.
      */
-    notifyKey<TKey extends OnyxKey>(key: TKey, value: OnyxValue<TKey>): void {
+    notifyKey<TKey extends Exclude<OnyxKey, CollectionKeyBase>>(key: TKey, value: OnyxValue<TKey>): void {
         // 1. Exact-key listeners
         const exact = this.keyListeners.get(key);
         if (exact && exact.size > 0) {

@fabioh8010 fabioh8010 Sep 8, 2026

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.

Sorry, forgot to answer about this one. While it makes sense for external consumers like E/App (if we exported this module), it breaks completely internally because OnyxKey and CollectionKeyBase are both string from library's own perspective – external consumers will augment Onyx types with the correct keys but internally we set them to string, see TypeOptions type. Since they are both string, Exclude<string, string> will resolve to never and any call of this function inside the repo will break.

We shouldn't worry about this though, this module isn't supposed to be exported or used externally by consumers.

// Direct writes to a collection root (e.g. `Onyx.merge(COLLECTION_KEY, ...)`) are an
// unsupported anti-pattern; treat them as opaque single-key writes.
const collectionKey = OnyxKeys.getCollectionKey(key);
const isCollectionMemberWrite = collectionKey !== undefined && collectionKey !== key;
if (isCollectionMemberWrite) {
const collectionListeners = this.keyListeners.get(collectionKey);
if (collectionListeners && collectionListeners.size > 0) {
const collectionData = cache.getCollectionData(collectionKey);
for (const listener of [...collectionListeners]) {
this.safeInvoke(() => listener(collectionData, collectionKey), collectionKey);
}
}
}
}

/**
* Notify of a collection-level batch update.
*
* Dispatch:
* 1. keyListeners.get(collectionKey): fires once with the new collection object.
* 2. keyListeners.get(memberKey): fires per changed member whose value differs from
* the previous, preserving ref-equality on unchanged members.
*/
notifyCollection<TKey extends CollectionKeyBase>(
collectionKey: TKey,
partialCollection: OnyxCollection<KeyValueMapping[TKey]>,
partialPreviousCollection?: OnyxCollection<KeyValueMapping[TKey]>,
): void {
const changedKeys = Object.keys(partialCollection ?? {});
if (changedKeys.length === 0) {
return;
}
// Read the merged collection once. `cache.getCollectionData()` returns the post-merge
// frozen object, which is what listeners should see (not the raw `partialCollection`
// input, which is just the delta and lacks fields preserved during merge).
const collectionData = cache.getCollectionData(collectionKey);

// 1. Collection listeners fire once with the new collection object.
const collectionListeners = this.keyListeners.get(collectionKey);
if (collectionListeners && collectionListeners.size > 0) {
for (const listener of [...collectionListeners]) {
this.safeInvoke(() => listener(collectionData, collectionKey), collectionKey);
}
}

// 2. Exact-member subscribers fire per changed key (skip if ref unchanged vs previous).
// Only treat a member as unchanged when `previous` actually carries it: when the
// previous collection is omitted, a removed member reads `undefined` on both sides
// and would otherwise be skipped even though it changed.
for (const memberKey of changedKeys) {
const value = collectionData?.[memberKey];
const prev = partialPreviousCollection?.[memberKey];
if (partialPreviousCollection && Object.prototype.hasOwnProperty.call(partialPreviousCollection, memberKey) && value === prev) {
continue;
}

const exact = this.keyListeners.get(memberKey);
if (!exact || exact.size === 0) {
continue;
}

for (const listener of [...exact]) {
this.safeInvoke(() => listener(value, memberKey), memberKey);
}
}
}

/**
* Wipe all subscriptions. Used by tests and `Onyx.clear()` follow-on.
*/
clearAll(): void {
this.keyListeners.clear();
}

/**
* True if there are any subscribers for the given key (exact or parent collection).
*/
hasListenersForKey(key: OnyxKey): boolean {
if ((this.keyListeners.get(key)?.size ?? 0) > 0) {
return true;
}

const collectionKey = OnyxKeys.getCollectionKey(key);
if (collectionKey && collectionKey !== key && (this.keyListeners.get(collectionKey)?.size ?? 0) > 0) {
return true;
}

return false;
}

/**
* Runs a listener, catching and logging any throw so one failing listener can't stop the rest.
*/
private safeInvoke(fn: () => void, contextKey: OnyxKey): void {
try {
fn();
} catch (error) {
Logger.logAlert(`[OnyxSubscriptionManager] Listener threw an error for key '${contextKey}': ${error}`);
}
}
}

const onyxSubscriptionManager = new OnyxSubscriptionManager();

export default onyxSubscriptionManager;
export type {Listener};
Loading
Loading