-
Notifications
You must be signed in to change notification settings - Fork 97
Add OnyxStore as a standalone, inert module #833
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
217e7c3
0af5c22
4f99ddf
d5aa81b
7a149f3
28179ab
844571b
46e135e
6033e58
457d3bf
435487c
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 |
|---|---|---|
| @@ -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; | ||
|
|
||
| /** | ||
|
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> { | ||
|
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. | ||
|
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. 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) {
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. 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 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}; | ||
Uh oh!
There was an error while loading. Please reload this page.