diff --git a/lib/OnyxStore.ts b/lib/OnyxStore.ts new file mode 100644 index 000000000..e1cd33ceb --- /dev/null +++ b/lib/OnyxStore.ts @@ -0,0 +1,182 @@ +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 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. One index backs + * every subscription: + * + * 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`). + */ +class OnyxStore { + private keyListeners: Map>; + + constructor() { + this.keyListeners = new Map(); + } + + /** + * 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 { + if (OnyxKeys.isCollectionKey(key)) { + return cache.getCollectionData(key) as OnyxValue; + } + return cache.get(key) as OnyxValue; + } + + /** + * 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(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): collection + * listeners for the parent collection (unless suppressed). + * + * `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?: {suppressCollectionNotify?: 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 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?.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); + } + } + } + } + + /** + * 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 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, + partialCollection: OnyxCollection, + partialPreviousCollection?: OnyxCollection, + ): void { + const changedKeys = Object.keys(partialCollection ?? {}); + if (changedKeys.length === 0) { + return; + } + const previous = partialPreviousCollection ?? {}; + + // 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 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 = collectionData?.[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..be54297c1 --- /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 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); + + onyxStore.notifyKey(MEMBER_1, {id: 1}); + + expect(getCollectionData).toHaveBeenCalledWith(COLLECTION); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(collectionData, COLLECTION); + }); + + 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 collectionCallback = jest.fn(); + onyxStore.subscribe(MEMBER_1, memberCallback); + onyxStore.subscribe(COLLECTION, collectionCallback); + + onyxStore.notifyKey(MEMBER_1, {id: 1}); + + expect(memberCallback).toHaveBeenCalledWith({id: 1}, MEMBER_1); + expect(collectionCallback).toHaveBeenCalledWith(collectionData, COLLECTION); + }); + + 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 collectionCallback = jest.fn(); + onyxStore.subscribe(MEMBER_1, memberCallback); + onyxStore.subscribe(COLLECTION, collectionCallback); + + onyxStore.notifyKey(MEMBER_1, {id: 1}, {suppressCollectionNotify: true}); + + expect(memberCallback).toHaveBeenCalledTimes(1); + expect(collectionCallback).not.toHaveBeenCalled(); + // The collection object 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 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); + + onyxStore.notifyCollection(COLLECTION, {[MEMBER_1]: {id: 1}, [MEMBER_2]: {id: 2}}); + + expect(callback).toHaveBeenCalledTimes(1); + 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 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(); + 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(); + }); + }); +});