From 217e7c3aae9a7b00460bb5bc96d017fad0de2508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Thu, 11 Jun 2026 14:48:04 +0100 Subject: [PATCH 1/2] Add OnyxStore as a standalone subscription registry (inert) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce lib/OnyxStore.ts: a single listener registry (keyListeners Map>) with subscribe / notifyKey / notifyCollection / getState / hasListenersForKey / clearAll. Built on the existing structural-sharing cache (cache.getCollectionData frozen snapshots). This module is inert — nothing imports it yet. The subscription and notification paths (Onyx.connect, useOnyx, OnyxUtils.notify*) are wired onto it in a later change. Adding it alone has zero behavioral impact. Includes tests/unit/OnyxStoreTest.ts (20 tests) covering exact-key and collection-snapshot routing, ref-equality member skips, hasListenersForKey, clearAll, and listener error isolation. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/OnyxStore.ts | 186 +++++++++++++++++++++++++ tests/unit/OnyxStoreTest.ts | 270 ++++++++++++++++++++++++++++++++++++ 2 files changed, 456 insertions(+) create mode 100644 lib/OnyxStore.ts create mode 100644 tests/unit/OnyxStoreTest.ts diff --git a/lib/OnyxStore.ts b/lib/OnyxStore.ts new file mode 100644 index 000000000..449c0751f --- /dev/null +++ b/lib/OnyxStore.ts @@ -0,0 +1,186 @@ +import cache from './OnyxCache'; +import OnyxKeys from './OnyxKeys'; +import * as Logger from './Logger'; +import type {CollectionKeyBase, KeyValueMapping, OnyxCollection, OnyxKey, OnyxValue} from './types'; + +/** + * Listener fired when an exact key's value changes. For collection root keys this is the + * snapshot-mode listener: receives the frozen collection snapshot every time a member changes. + */ +type KeyListener = (value: OnyxValue, key: TKey) => void; + +/** + * `OnyxStore` is a single listener registry for Onyx reads/subscriptions. It replaces the + * connection manager's several per-subscription bookkeeping structures with one index: + * + * keyListeners — listeners on an exact key (a single key, a collection root in snapshot + * mode, or a specific collection member). + * + * Write paths call `notifyKey()` (single-key write) or `notifyCollection()` (batch collection + * update from `mergeCollection`/`setCollection`/`clear`). + * + * NOTE: This module is introduced inert — nothing calls it yet. The subscription/notification + * paths (`Onyx.connect`, `useOnyx`, `OnyxUtils.notify*`) are wired onto it in a later change. + */ +class OnyxStore { + private keyListeners: Map>; + + constructor() { + this.keyListeners = new Map(); + } + + /** + * Sync, cache-only read. Returns the frozen collection snapshot for collection + * keys, the cached value for single keys, or `undefined` if not in cache. + */ + getState(key: TKey): OnyxValue { + if (OnyxKeys.isCollectionKey(key)) { + return cache.getCollectionData(key) as OnyxValue; + } + return cache.get(key) as OnyxValue; + } + + /** + * Subscribe to an exact key. For collection root keys this is "snapshot mode" — + * the listener fires with the frozen collection snapshot whenever any member + * changes. For collection member keys or regular keys, the listener fires when + * that specific key's value changes. + * + * Returns an unsubscribe function. + */ + subscribe(key: TKey, listener: KeyListener): () => void { + let listeners = this.keyListeners.get(key); + if (!listeners) { + listeners = new Set(); + this.keyListeners.set(key, listeners); + } + listeners.add(listener as unknown as KeyListener); + return () => { + const set = this.keyListeners.get(key); + if (!set) { + return; + } + set.delete(listener as unknown as KeyListener); + 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) — snapshot + * subscribers for the parent collection (unless suppressed). + * + * `options.suppressCollectionSnapshot` skips step 2 — used by collection-batch + * write paths so each member-write doesn't re-trigger the collection-level + * snapshot listeners; the outer `notifyCollection()` fires those once. + */ + notifyKey(key: TKey, value: OnyxValue, options?: {suppressCollectionSnapshot?: boolean}): 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 as OnyxValue, key), key); + } + } + + // 2. Collection-level snapshot routing — only fires when the write is to a member key. + // 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 && !options?.suppressCollectionSnapshot) { + const snapshotListeners = this.keyListeners.get(collectionKey); + if (snapshotListeners && snapshotListeners.size > 0) { + const snapshot = cache.getCollectionData(collectionKey); + for (const listener of snapshotListeners) { + this.safeInvoke(() => listener(snapshot as OnyxValue, collectionKey), collectionKey); + } + } + } + } + + /** + * Notify of a collection-level batch update. Used by `mergeCollection`, + * `setCollection`, and `clear`'s collection path. + * + * Dispatch: + * 1. keyListeners.get(collectionKey) — fires ONCE with the new snapshot. + * 2. keyListeners.get(memberKey) — fires per changed member where the value + * differs from the previous (for ref-equality on unchanged members). + */ + notifyCollection( + collectionKey: TKey, + partialCollection: OnyxCollection, + partialPreviousCollection?: OnyxCollection, + ): void { + const changedKeys = Object.keys(partialCollection ?? {}); + if (changedKeys.length === 0) { + return; + } + const previous = partialPreviousCollection ?? {}; + + // Read the merged snapshot 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 snapshot = cache.getCollectionData(collectionKey); + + // 1. Snapshot subscribers fire once with the new snapshot. + const snapshotListeners = this.keyListeners.get(collectionKey); + if (snapshotListeners && snapshotListeners.size > 0) { + for (const listener of snapshotListeners) { + this.safeInvoke(() => listener(snapshot as OnyxValue, collectionKey), collectionKey); + } + } + + // 2. Exact-member subscribers fire per changed key (skip if ref unchanged vs previous). + for (const memberKey of changedKeys) { + const value = snapshot?.[memberKey]; + const prev = previous[memberKey]; + if (value === prev) { + continue; + } + const exact = this.keyListeners.get(memberKey); + if (!exact || exact.size === 0) { + continue; + } + for (const listener of exact) { + this.safeInvoke(() => listener(value as OnyxValue, 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; + } + + private safeInvoke(fn: () => void, contextKey: OnyxKey): void { + try { + fn(); + } catch (error) { + Logger.logAlert(`[OnyxStore] Listener threw an error for key '${contextKey}': ${error}`); + } + } +} + +const onyxStore = new OnyxStore(); + +export default onyxStore; +export type {KeyListener}; diff --git a/tests/unit/OnyxStoreTest.ts b/tests/unit/OnyxStoreTest.ts new file mode 100644 index 000000000..e1eb3d1b3 --- /dev/null +++ b/tests/unit/OnyxStoreTest.ts @@ -0,0 +1,270 @@ +import type {OnyxKey} from '../../lib'; +import Onyx from '../../lib'; +import onyxStore from '../../lib/OnyxStore'; +import cache from '../../lib/OnyxCache'; +import * as Logger from '../../lib/Logger'; + +// We need access to some internal properties of `onyxStore` during the tests but they are private, +// so this workaround allows us to have access to them. The maps are created once in the constructor +// and only ever `.clear()`ed (never reassigned), so capturing the references here stays valid. +// eslint-disable-next-line dot-notation +const keyListeners = onyxStore['keyListeners']; + +const ONYXKEYS = { + TEST_KEY: 'test', + OTHER_TEST: 'otherTest', + COLLECTION: { + TEST_KEY: 'test_', + }, +}; + +const COLLECTION = ONYXKEYS.COLLECTION.TEST_KEY; +const MEMBER_1 = `${COLLECTION}1`; +const MEMBER_2 = `${COLLECTION}2`; + +Onyx.init({ + keys: ONYXKEYS, +}); + +beforeEach(() => Onyx.clear()); + +describe('OnyxStore', () => { + // Always start from a clean registry. + beforeEach(() => { + onyxStore.clearAll(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('subscribe / notifyKey', () => { + it('should fire the listener with (value, key) on notifyKey', () => { + const callback = jest.fn(); + onyxStore.subscribe(ONYXKEYS.TEST_KEY, callback); + + onyxStore.notifyKey(ONYXKEYS.TEST_KEY, 'hello'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('hello', ONYXKEYS.TEST_KEY); + }); + + it('should fire all listeners registered on the same key', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + onyxStore.subscribe(ONYXKEYS.TEST_KEY, callback1); + onyxStore.subscribe(ONYXKEYS.TEST_KEY, callback2); + + onyxStore.notifyKey(ONYXKEYS.TEST_KEY, 1); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it('should not fire the listener after it unsubscribes', () => { + const callback = jest.fn(); + const unsubscribe = onyxStore.subscribe(ONYXKEYS.TEST_KEY, callback); + + onyxStore.notifyKey(ONYXKEYS.TEST_KEY, 'first'); + unsubscribe(); + onyxStore.notifyKey(ONYXKEYS.TEST_KEY, 'second'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenLastCalledWith('first', ONYXKEYS.TEST_KEY); + }); + + it('should only unsubscribe the specific listener, leaving others intact', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + const unsubscribe1 = onyxStore.subscribe(ONYXKEYS.TEST_KEY, callback1); + onyxStore.subscribe(ONYXKEYS.TEST_KEY, callback2); + + unsubscribe1(); + onyxStore.notifyKey(ONYXKEYS.TEST_KEY, 1); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it('should delete the key entry from the internal map once the last listener unsubscribes', () => { + const unsubscribe = onyxStore.subscribe(ONYXKEYS.TEST_KEY, jest.fn()); + expect(keyListeners.has(ONYXKEYS.TEST_KEY)).toBeTruthy(); + + unsubscribe(); + + expect(keyListeners.has(ONYXKEYS.TEST_KEY)).toBeFalsy(); + }); + + it('should be a no-op to notify a key with no listeners', () => { + expect(() => onyxStore.notifyKey('keyWithNoListeners' as OnyxKey, 'x')).not.toThrow(); + }); + + it('should be idempotent when unsubscribing more than once', () => { + const callback = jest.fn(); + const unsubscribe = onyxStore.subscribe(ONYXKEYS.TEST_KEY, callback); + + unsubscribe(); + expect(() => unsubscribe()).not.toThrow(); + + onyxStore.notifyKey(ONYXKEYS.TEST_KEY, 1); + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('collection-snapshot routing on notifyKey', () => { + it('should fire the collection-root snapshot listener with the cache snapshot when a member is written', () => { + const snapshot = {[MEMBER_1]: {id: 1}, [MEMBER_2]: {id: 2}}; + const getCollectionData = jest.spyOn(cache, 'getCollectionData').mockReturnValue(snapshot); + + const callback = jest.fn(); + onyxStore.subscribe(COLLECTION, callback); + + onyxStore.notifyKey(MEMBER_1, {id: 1}); + + expect(getCollectionData).toHaveBeenCalledWith(COLLECTION); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(snapshot, COLLECTION); + }); + + it('should fire both the exact-member listener and the collection-root snapshot listener', () => { + const snapshot = {[MEMBER_1]: {id: 1}}; + jest.spyOn(cache, 'getCollectionData').mockReturnValue(snapshot); + + const memberCallback = jest.fn(); + const snapshotCallback = jest.fn(); + onyxStore.subscribe(MEMBER_1, memberCallback); + onyxStore.subscribe(COLLECTION, snapshotCallback); + + onyxStore.notifyKey(MEMBER_1, {id: 1}); + + expect(memberCallback).toHaveBeenCalledWith({id: 1}, MEMBER_1); + expect(snapshotCallback).toHaveBeenCalledWith(snapshot, COLLECTION); + }); + + it('should skip the collection-root listener but still fire the exact-member listener when suppressCollectionSnapshot is set', () => { + const getCollectionData = jest.spyOn(cache, 'getCollectionData').mockReturnValue({}); + + const memberCallback = jest.fn(); + const snapshotCallback = jest.fn(); + onyxStore.subscribe(MEMBER_1, memberCallback); + onyxStore.subscribe(COLLECTION, snapshotCallback); + + onyxStore.notifyKey(MEMBER_1, {id: 1}, {suppressCollectionSnapshot: true}); + + expect(memberCallback).toHaveBeenCalledTimes(1); + expect(snapshotCallback).not.toHaveBeenCalled(); + // The snapshot is never read when suppressed. + expect(getCollectionData).not.toHaveBeenCalled(); + }); + + it('should not perform collection routing for a non-member single key', () => { + const getCollectionData = jest.spyOn(cache, 'getCollectionData'); + const callback = jest.fn(); + onyxStore.subscribe(ONYXKEYS.TEST_KEY, callback); + + onyxStore.notifyKey(ONYXKEYS.TEST_KEY, 'x'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(getCollectionData).not.toHaveBeenCalled(); + }); + }); + + describe('notifyCollection', () => { + it('should fire the snapshot listener once with the cache snapshot', () => { + const snapshot = {[MEMBER_1]: {id: 1}, [MEMBER_2]: {id: 2}}; + jest.spyOn(cache, 'getCollectionData').mockReturnValue(snapshot); + + const callback = jest.fn(); + onyxStore.subscribe(COLLECTION, callback); + + onyxStore.notifyCollection(COLLECTION, {[MEMBER_1]: {id: 1}, [MEMBER_2]: {id: 2}}); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(snapshot, COLLECTION); + }); + + it('should fire exact-member listeners only for members whose value reference changed', () => { + const shared = {id: 2}; // same reference in snapshot and previous → should be skipped + const snapshot = {[MEMBER_1]: {id: 1}, [MEMBER_2]: shared}; + jest.spyOn(cache, 'getCollectionData').mockReturnValue(snapshot); + + const member1Callback = jest.fn(); + const member2Callback = jest.fn(); + onyxStore.subscribe(MEMBER_1, member1Callback); + onyxStore.subscribe(MEMBER_2, member2Callback); + + onyxStore.notifyCollection( + COLLECTION, + {[MEMBER_1]: {id: 1}, [MEMBER_2]: shared}, + {[MEMBER_2]: shared}, // previous: member 2 unchanged by reference + ); + + expect(member1Callback).toHaveBeenCalledWith({id: 1}, MEMBER_1); + expect(member2Callback).not.toHaveBeenCalled(); + }); + + it('should be a no-op when the partial collection is empty', () => { + const callback = jest.fn(); + onyxStore.subscribe(COLLECTION, callback); + + onyxStore.notifyCollection(COLLECTION, {}); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('hasListenersForKey', () => { + it('should return true for an exact-key subscriber', () => { + onyxStore.subscribe(ONYXKEYS.TEST_KEY, jest.fn()); + expect(onyxStore.hasListenersForKey(ONYXKEYS.TEST_KEY)).toBeTruthy(); + }); + + it('should return true for a member key when its parent collection has a subscriber', () => { + onyxStore.subscribe(COLLECTION, jest.fn()); + expect(onyxStore.hasListenersForKey(MEMBER_1)).toBeTruthy(); + }); + + it('should return false when there are no relevant subscribers', () => { + expect(onyxStore.hasListenersForKey('someUnwatchedKey')).toBeFalsy(); + }); + + it('should return false after the last listener unsubscribes', () => { + const unsubscribe = onyxStore.subscribe(ONYXKEYS.TEST_KEY, jest.fn()); + unsubscribe(); + expect(onyxStore.hasListenersForKey(ONYXKEYS.TEST_KEY)).toBeFalsy(); + }); + }); + + describe('clearAll', () => { + it('should wipe key and collection subscriptions', () => { + const keyCallback = jest.fn(); + onyxStore.subscribe(ONYXKEYS.TEST_KEY, keyCallback); + + onyxStore.clearAll(); + onyxStore.notifyKey(ONYXKEYS.TEST_KEY, 'x'); + + expect(keyCallback).not.toHaveBeenCalled(); + expect(onyxStore.hasListenersForKey(ONYXKEYS.TEST_KEY)).toBeFalsy(); + }); + }); + + describe('listener error isolation', () => { + it('should log a throwing listener and still fire the other listeners', () => { + const logAlertSpy = jest.spyOn(Logger, 'logAlert').mockImplementation(() => { + /* empty */ + }); + const throwingCallback = jest.fn(() => { + throw new Error('boom'); + }); + const healthyCallback = jest.fn(); + onyxStore.subscribe(ONYXKEYS.TEST_KEY, throwingCallback); + onyxStore.subscribe(ONYXKEYS.TEST_KEY, healthyCallback); + + expect(() => onyxStore.notifyKey(ONYXKEYS.TEST_KEY, 'x')).not.toThrow(); + + expect(throwingCallback).toHaveBeenCalledTimes(1); + expect(healthyCallback).toHaveBeenCalledTimes(1); + expect(logAlertSpy).toHaveBeenCalled(); + }); + }); +}); From 4f99ddf0151e88a4603b8719a6ffb659afea88cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Fri, 28 Aug 2026 16:59:41 +0100 Subject: [PATCH 2/2] Simplify comments and remove snapshot wording --- lib/OnyxStore.ts | 78 ++++++++++++++++++------------------- tests/unit/OnyxStoreTest.ts | 48 +++++++++++------------ 2 files changed, 61 insertions(+), 65 deletions(-) diff --git a/lib/OnyxStore.ts b/lib/OnyxStore.ts index 449c0751f..e1cd33ceb 100644 --- a/lib/OnyxStore.ts +++ b/lib/OnyxStore.ts @@ -4,23 +4,20 @@ import * as Logger from './Logger'; import type {CollectionKeyBase, KeyValueMapping, OnyxCollection, OnyxKey, OnyxValue} from './types'; /** - * Listener fired when an exact key's value changes. For collection root keys this is the - * snapshot-mode listener: receives the frozen collection snapshot every time a member changes. + * Listener fired when an exact key's value changes. For a collection root key this is the + * collection listener: it receives the frozen collection object every time a member changes. */ type KeyListener = (value: OnyxValue, key: TKey) => void; /** - * `OnyxStore` is a single listener registry for Onyx reads/subscriptions. It replaces the - * connection manager's several per-subscription bookkeeping structures with one index: + * `OnyxStore` is a single listener registry for Onyx reads/subscriptions. One index backs + * every subscription: * - * keyListeners — listeners on an exact key (a single key, a collection root in snapshot - * mode, or a specific collection member). + * keyListeners: exact-key listeners (a single key, a collection root in collection mode, + * or a specific collection member). * * Write paths call `notifyKey()` (single-key write) or `notifyCollection()` (batch collection * update from `mergeCollection`/`setCollection`/`clear`). - * - * NOTE: This module is introduced inert — nothing calls it yet. The subscription/notification - * paths (`Onyx.connect`, `useOnyx`, `OnyxUtils.notify*`) are wired onto it in a later change. */ class OnyxStore { private keyListeners: Map>; @@ -30,7 +27,7 @@ class OnyxStore { } /** - * Sync, cache-only read. Returns the frozen collection snapshot for collection + * Sync, cache-only read. Returns the frozen collection object for collection * keys, the cached value for single keys, or `undefined` if not in cache. */ getState(key: TKey): OnyxValue { @@ -41,10 +38,9 @@ class OnyxStore { } /** - * Subscribe to an exact key. For collection root keys this is "snapshot mode" — - * the listener fires with the frozen collection snapshot whenever any member - * changes. For collection member keys or regular keys, the listener fires when - * that specific key's value changes. + * 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. */ @@ -71,15 +67,15 @@ class OnyxStore { * 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) — snapshot - * subscribers for the parent collection (unless suppressed). + * 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 (unless suppressed). * - * `options.suppressCollectionSnapshot` skips step 2 — used by collection-batch - * write paths so each member-write doesn't re-trigger the collection-level - * snapshot listeners; the outer `notifyCollection()` fires those once. + * `options.suppressCollectionNotify` skips step 2. Collection-batch write paths set + * it so each member write doesn't re-trigger the collection-level listeners; + * the outer `notifyCollection()` fires those once. */ - notifyKey(key: TKey, value: OnyxValue, options?: {suppressCollectionSnapshot?: boolean}): void { + notifyKey(key: TKey, value: OnyxValue, options?: {suppressCollectionNotify?: boolean}): void { // 1. Exact-key listeners const exact = this.keyListeners.get(key); if (exact && exact.size > 0) { @@ -88,17 +84,17 @@ class OnyxStore { } } - // 2. Collection-level snapshot routing — only fires when the write is to a member key. - // Direct writes to a collection root (e.g. `Onyx.merge(COLLECTION_KEY, ...)`) are - // an unsupported anti-pattern — treat them as opaque single-key writes. + // 2. Collection-level routing. Only fires when the write is to a member key. + // 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 && !options?.suppressCollectionSnapshot) { - const snapshotListeners = this.keyListeners.get(collectionKey); - if (snapshotListeners && snapshotListeners.size > 0) { - const snapshot = cache.getCollectionData(collectionKey); - for (const listener of snapshotListeners) { - this.safeInvoke(() => listener(snapshot as OnyxValue, collectionKey), collectionKey); + if (isCollectionMemberWrite && !options?.suppressCollectionNotify) { + 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 as OnyxValue, collectionKey), collectionKey); } } } @@ -109,9 +105,9 @@ class OnyxStore { * `setCollection`, and `clear`'s collection path. * * Dispatch: - * 1. keyListeners.get(collectionKey) — fires ONCE with the new snapshot. - * 2. keyListeners.get(memberKey) — fires per changed member where the value - * differs from the previous (for ref-equality on unchanged members). + * 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( collectionKey: TKey, @@ -124,22 +120,22 @@ class OnyxStore { } const previous = partialPreviousCollection ?? {}; - // Read the merged snapshot once. `cache.getCollectionData()` returns the post-merge + // 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 snapshot = cache.getCollectionData(collectionKey); + const collectionData = cache.getCollectionData(collectionKey); - // 1. Snapshot subscribers fire once with the new snapshot. - const snapshotListeners = this.keyListeners.get(collectionKey); - if (snapshotListeners && snapshotListeners.size > 0) { - for (const listener of snapshotListeners) { - this.safeInvoke(() => listener(snapshot as OnyxValue, collectionKey), 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 as OnyxValue, collectionKey), collectionKey); } } // 2. Exact-member subscribers fire per changed key (skip if ref unchanged vs previous). for (const memberKey of changedKeys) { - const value = snapshot?.[memberKey]; + const value = collectionData?.[memberKey]; const prev = previous[memberKey]; if (value === prev) { continue; diff --git a/tests/unit/OnyxStoreTest.ts b/tests/unit/OnyxStoreTest.ts index e1eb3d1b3..be54297c1 100644 --- a/tests/unit/OnyxStoreTest.ts +++ b/tests/unit/OnyxStoreTest.ts @@ -111,10 +111,10 @@ describe('OnyxStore', () => { }); }); - describe('collection-snapshot routing on notifyKey', () => { - it('should fire the collection-root snapshot listener with the cache snapshot when a member is written', () => { - const snapshot = {[MEMBER_1]: {id: 1}, [MEMBER_2]: {id: 2}}; - const getCollectionData = jest.spyOn(cache, 'getCollectionData').mockReturnValue(snapshot); + describe('collection routing on notifyKey', () => { + it('should fire the collection-root listener with the cache collection object when a member is written', () => { + const collectionData = {[MEMBER_1]: {id: 1}, [MEMBER_2]: {id: 2}}; + const getCollectionData = jest.spyOn(cache, 'getCollectionData').mockReturnValue(collectionData); const callback = jest.fn(); onyxStore.subscribe(COLLECTION, callback); @@ -123,37 +123,37 @@ describe('OnyxStore', () => { expect(getCollectionData).toHaveBeenCalledWith(COLLECTION); expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith(snapshot, COLLECTION); + expect(callback).toHaveBeenCalledWith(collectionData, COLLECTION); }); - it('should fire both the exact-member listener and the collection-root snapshot listener', () => { - const snapshot = {[MEMBER_1]: {id: 1}}; - jest.spyOn(cache, 'getCollectionData').mockReturnValue(snapshot); + it('should fire both the exact-member listener and the collection-root listener', () => { + const collectionData = {[MEMBER_1]: {id: 1}}; + jest.spyOn(cache, 'getCollectionData').mockReturnValue(collectionData); const memberCallback = jest.fn(); - const snapshotCallback = jest.fn(); + const collectionCallback = jest.fn(); onyxStore.subscribe(MEMBER_1, memberCallback); - onyxStore.subscribe(COLLECTION, snapshotCallback); + onyxStore.subscribe(COLLECTION, collectionCallback); onyxStore.notifyKey(MEMBER_1, {id: 1}); expect(memberCallback).toHaveBeenCalledWith({id: 1}, MEMBER_1); - expect(snapshotCallback).toHaveBeenCalledWith(snapshot, COLLECTION); + expect(collectionCallback).toHaveBeenCalledWith(collectionData, COLLECTION); }); - it('should skip the collection-root listener but still fire the exact-member listener when suppressCollectionSnapshot is set', () => { + it('should skip the collection-root listener but still fire the exact-member listener when suppressCollectionNotify is set', () => { const getCollectionData = jest.spyOn(cache, 'getCollectionData').mockReturnValue({}); const memberCallback = jest.fn(); - const snapshotCallback = jest.fn(); + const collectionCallback = jest.fn(); onyxStore.subscribe(MEMBER_1, memberCallback); - onyxStore.subscribe(COLLECTION, snapshotCallback); + onyxStore.subscribe(COLLECTION, collectionCallback); - onyxStore.notifyKey(MEMBER_1, {id: 1}, {suppressCollectionSnapshot: true}); + onyxStore.notifyKey(MEMBER_1, {id: 1}, {suppressCollectionNotify: true}); expect(memberCallback).toHaveBeenCalledTimes(1); - expect(snapshotCallback).not.toHaveBeenCalled(); - // The snapshot is never read when suppressed. + expect(collectionCallback).not.toHaveBeenCalled(); + // The collection object is never read when suppressed. expect(getCollectionData).not.toHaveBeenCalled(); }); @@ -170,9 +170,9 @@ describe('OnyxStore', () => { }); describe('notifyCollection', () => { - it('should fire the snapshot listener once with the cache snapshot', () => { - const snapshot = {[MEMBER_1]: {id: 1}, [MEMBER_2]: {id: 2}}; - jest.spyOn(cache, 'getCollectionData').mockReturnValue(snapshot); + it('should fire the collection listener once with the cache collection object', () => { + const collectionData = {[MEMBER_1]: {id: 1}, [MEMBER_2]: {id: 2}}; + jest.spyOn(cache, 'getCollectionData').mockReturnValue(collectionData); const callback = jest.fn(); onyxStore.subscribe(COLLECTION, callback); @@ -180,13 +180,13 @@ describe('OnyxStore', () => { onyxStore.notifyCollection(COLLECTION, {[MEMBER_1]: {id: 1}, [MEMBER_2]: {id: 2}}); expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith(snapshot, COLLECTION); + expect(callback).toHaveBeenCalledWith(collectionData, COLLECTION); }); it('should fire exact-member listeners only for members whose value reference changed', () => { - const shared = {id: 2}; // same reference in snapshot and previous → should be skipped - const snapshot = {[MEMBER_1]: {id: 1}, [MEMBER_2]: shared}; - jest.spyOn(cache, 'getCollectionData').mockReturnValue(snapshot); + const shared = {id: 2}; // same reference in collection and previous, should be skipped + const collectionData = {[MEMBER_1]: {id: 1}, [MEMBER_2]: shared}; + jest.spyOn(cache, 'getCollectionData').mockReturnValue(collectionData); const member1Callback = jest.fn(); const member2Callback = jest.fn();