diff --git a/lib/OnyxSubscriptionManager.ts b/lib/OnyxSubscriptionManager.ts index d2bb3cc7a..da50ed156 100644 --- a/lib/OnyxSubscriptionManager.ts +++ b/lib/OnyxSubscriptionManager.ts @@ -16,6 +16,14 @@ type Listener = (value: OnyxValue, key: TK */ type GenericListener = (value: unknown, key: OnyxKey) => void; +/** Listener fired when any of a state listener's declared dependency keys changes. Used by `useOnyxState`. */ +type StateListenerCallback = () => void; + +type StateListenerEntry = { + listener: StateListenerCallback; + deps: Set; +}; + const reportedCacheMisses = new Set(); function reportCacheMissIfStorageHasValue(key: OnyxKey): void { @@ -43,17 +51,20 @@ function reportCacheMissIfStorageHasValue(key: OnyxKey): void { } /** - * 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 + * OnyxSubscriptionManager is a registry for Onyx subscriptions, backed by two indexes: + * - keyListeners: exact-key listeners, keyed by OnyxKey (a single key, a collection object, + * or a collection member). Notified via `notifyKey`/`notifyCollection`. + * - stateListenersByDep: listeners that re-evaluate when any of their declared dependency keys + * change, indexed by dep key. Used by `useOnyxState`. */ class OnyxSubscriptionManager { private keyListeners: Map>; + private stateListenersByDep: Map>; + constructor() { this.keyListeners = new Map(); + this.stateListenersByDep = new Map(); } /** @@ -103,6 +114,37 @@ class OnyxSubscriptionManager { }; } + /** + * Subscribe to state-tree changes. The listener fires when any of the declared dependency keys + * changes. A collection key counts as a single dep: any member change fires it. Used by `useOnyxState`. + * + * Returns an unsubscribe function. + */ + subscribeState(listener: StateListenerCallback, deps: readonly OnyxKey[]): () => void { + const entry: StateListenerEntry = {listener, deps: new Set(deps)}; + for (const dep of entry.deps) { + let set = this.stateListenersByDep.get(dep); + if (!set) { + set = new Set(); + this.stateListenersByDep.set(dep, set); + } + set.add(entry); + } + + return () => { + for (const dep of entry.deps) { + const set = this.stateListenersByDep.get(dep); + if (!set) { + continue; + } + set.delete(entry); + if (set.size === 0) { + this.stateListenersByDep.delete(dep); + } + } + }; + } + /** * Notify of a single-key write. * @@ -110,6 +152,7 @@ class OnyxSubscriptionManager { * 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. + * 3. State listeners whose deps include `key` or its collection key. */ notifyKey(key: TKey, value: OnyxValue): void { // 1. Exact-key listeners @@ -134,6 +177,13 @@ class OnyxSubscriptionManager { } } } + + // 3. State listeners, fired at most once even when both the member and its collection key match. + const fired = new Set(); + this.fireStateListenersForDep(key, fired); + if (isCollectionMemberWrite) { + this.fireStateListenersForDep(collectionKey, fired); + } } /** @@ -143,6 +193,7 @@ class OnyxSubscriptionManager { * 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. + * 3. State listeners affected by the collection key or any changed member key, each fired at most once. */ notifyCollection( collectionKey: TKey, @@ -186,6 +237,13 @@ class OnyxSubscriptionManager { this.safeInvoke(() => listener(value, memberKey), memberKey); } } + + // 3. State listeners, each affected entry fired at most once across the collection and its members. + const fired = new Set(); + this.fireStateListenersForDep(collectionKey, fired); + for (const memberKey of changedKeys) { + this.fireStateListenersForDep(memberKey, fired); + } } /** @@ -193,6 +251,7 @@ class OnyxSubscriptionManager { */ clearAll(): void { this.keyListeners.clear(); + this.stateListenersByDep.clear(); this.resetCacheMissReports(); } @@ -216,6 +275,22 @@ class OnyxSubscriptionManager { return false; } + /** Fires each state listener registered on `depKey`, skipping any already fired this dispatch. */ + private fireStateListenersForDep(depKey: OnyxKey, alreadyFired: Set): void { + const set = this.stateListenersByDep.get(depKey); + if (!set || set.size === 0) { + return; + } + + for (const entry of [...set]) { + if (alreadyFired.has(entry)) { + continue; + } + alreadyFired.add(entry); + this.safeInvoke(entry.listener, depKey); + } + } + /** * Runs a listener, catching and logging any throw so one failing listener can't stop the rest. */ @@ -231,4 +306,4 @@ class OnyxSubscriptionManager { const onyxSubscriptionManager = new OnyxSubscriptionManager(); export default onyxSubscriptionManager; -export type {Listener}; +export type {Listener, StateListenerCallback}; diff --git a/lib/index.ts b/lib/index.ts index 671b28a70..2c7e60a3b 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -20,10 +20,12 @@ import type { } from './types'; import type {FetchStatus, ResultMetadata, UseOnyxResult, UseOnyxOptions} from './useOnyx'; import useOnyx from './useOnyx'; +import type {OnyxStateView, UseOnyxStateOptions, UseOnyxStateSelector} from './useOnyxState'; +import useOnyxState from './useOnyxState'; import type {OnyxSQLiteKeyValuePair} from './storage/providers/SQLiteProvider'; export default Onyx; -export {useOnyx}; +export {useOnyx, useOnyxState}; export type { ConnectOptions, CustomTypeOptions, @@ -48,5 +50,8 @@ export type { UseOnyxResult, Connection, UseOnyxOptions, + OnyxStateView, + UseOnyxStateOptions, + UseOnyxStateSelector, OnyxSQLiteKeyValuePair, }; diff --git a/lib/useOnyxState.ts b/lib/useOnyxState.ts new file mode 100644 index 000000000..604198775 --- /dev/null +++ b/lib/useOnyxState.ts @@ -0,0 +1,253 @@ +import {deepEqual} from 'fast-equals'; +import {useCallback, useEffect, useMemo, useRef, useSyncExternalStore} from 'react'; + +import type {CollectionKeyBase, KeyValueMapping, OnyxCollection, OnyxKey, OnyxValue} from './types'; + +import onyxSubscriptionManager from './OnyxSubscriptionManager'; +import OnyxUtils from './OnyxUtils'; + +/** + * Collection roots, mapped to the whole collection. + * + * Mapping over `OnyxKey` alone cannot type them: collection keys reach it as the template + * `${CollectionKeyBase}${string}`, which becomes one index signature typed off that template rather + * than off the key you index with. The template does not extend `CollectionKeyBase`, so `OnyxValue` + * resolves it to a member, and a root such as `report_` matches the signature and comes back typed + * as a single report. Spelling the roots out separately gives them a declared property, which wins + * over the index signature. Resolves to `unknown` without `CustomTypeOptions`, leaving the view as it + * was, since every key there is already `unknown`. + */ +type OnyxCollectionRootsView = string extends CollectionKeyBase + ? unknown + : { + readonly [K in CollectionKeyBase]: OnyxCollection; + }; + +/** + * Read-only view over the Onyx state. Reading a key returns what `getState` returns: the frozen + * snapshot for a collection key, the cached value for a single key, `undefined` when absent. + * Nothing is materialized — every read routes through the store. + */ +type OnyxStateView = { + readonly [K in OnyxKey]: OnyxValue; +} & OnyxCollectionRootsView; + +/** Declaring a collection root also grants its members: any member write fires the root. */ +type DeclaredKeys = TDependency extends CollectionKeyBase ? TDependency | `${TDependency}${string}` : TDependency; + +/** + * The state view a selector gets, holding only the keys its `dependencies` declared. Reading anything + * else is the bug this prevents: the key has no listener, and the snapshot cache skips the selector + * while the declared deps keep their references, so the value would sit there stale. + * + * Collection roots are spelled out a second time for the reason `OnyxCollectionRootsView` exists, and + * the whole thing falls back to the full view when the dependency list is not a literal, since there + * is then nothing to narrow to. + */ +type OnyxStateViewFor = OnyxKey extends TDependencies[number] + ? OnyxStateView + : { + readonly [K in DeclaredKeys]: OnyxValue; + } & { + readonly [K in Extract]: OnyxCollection; + }; + +type UseOnyxStateSelector = ( + state: OnyxStateViewFor, + previousState: OnyxStateViewFor | undefined, +) => T; + +type UseOnyxStateOptions = { + /** + * Keys the selector reads. It re-runs when one of them changes; a collection key counts as a + * single dep, so any member change triggers a re-run. The selector's `state` only carries these + * keys, so reading anything else is a type error rather than a value that silently goes stale. + */ + dependencies: TDependencies; + + /** + * Equality for the selector's output. Defaults to `===` with a deep-equal fallback for objects. + * When it returns true, the React update is skipped. + */ + selectorEquality?: (a: T, b: T) => boolean; +}; + +/** Builds a lazy, sealed Proxy over `snapshotProvider`, used for both `state` and `previousState`. */ +function createStateView(snapshotProvider: (key: OnyxKey) => OnyxValue): OnyxStateView { + const handler: ProxyHandler> = { + get(_target, prop) { + if (typeof prop !== 'string') { + return undefined; + } + return snapshotProvider(prop); + }, + set() { + // Read-only: the selector must not mutate the view. + return false; + }, + deleteProperty() { + return false; + }, + has(_target, prop) { + if (typeof prop !== 'string') { + return false; + } + return snapshotProvider(prop) !== undefined; + }, + }; + return new Proxy>({}, handler) as unknown as OnyxStateView; +} + +const liveStateView = createStateView((key) => onyxSubscriptionManager.getState(key as OnyxKey)); + +type SnapshotCache = { + value: T; + selector: UseOnyxStateSelector; + previousView: OnyxStateView | undefined; + /** Whether the run that produced `value` read `previousState`. A run that ignored it can't care that it moved. */ + readPreviousState: boolean; + depValues: Array>; +}; + +/** True when every dep still reads as the same reference it had when `depValues` was taken. */ +function areDepValuesUnchanged(deps: readonly OnyxKey[], depValues: Array>): boolean { + if (deps.length !== depValues.length) { + return false; + } + for (let i = 0; i < deps.length; i++) { + if (!Object.is(onyxSubscriptionManager.getState(deps[i]), depValues[i])) { + return false; + } + } + return true; +} + +/** + * `===` first, which settles primitives and frozen snapshot references, then deepEqual for an object + * the selector freshly allocated with unchanged content. + */ +function defaultSelectorEquality(a: T, b: T): boolean { + if (Object.is(a, b)) { + return true; + } + if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') { + return false; + } + return deepEqual(a, b); +} + +/** + * Subscribes a component to a value derived from several Onyx keys. The selector gets a `state` view + * and a `previousState` view holding the declared deps as of the last committed render. It can use + * `previousState` to walk only what changed (structural sharing keeps unchanged collection members + * reference-equal, so each step is O(1)) or as a real input, say to emit a delta. + * + * Re-runs when any declared dep changes, and skips the React update when the output is equal to the + * previous one, per `selectorEquality`. + * + * Mounting before `Onyx.init` has hydrated runs the selector against an empty state and re-runs it + * once init lands. There is no status channel, so a consumer that must tell "not hydrated yet" from + * "absent" has to carry that in its own output. + */ +function useOnyxState(selector: UseOnyxStateSelector, options: UseOnyxStateOptions): T { + const {dependencies, selectorEquality = defaultSelectorEquality} = options; + + // Consumers pass an inline array literal, so key the memo on the contents: `depsArray` only gets + // a new reference when the set of keys changes, which keeps `subscribe` from re-subscribing on + // every render. The space separator is safe, Onyx keys never contain spaces. React Compiler + // can't preserve content-keying, so this hook opts out of it and memoizes by hand. + const depsKey = dependencies.join(' '); + const depsArray = useMemo(() => [...dependencies], [depsKey]); // eslint-disable-line react-hooks/exhaustive-deps, react-hooks/preserve-manual-memoization + + // Only `subscribe` has to be referentially stable, since it controls re-subscription. + // `getSnapshot` may change identity freely, so it closes over the latest selector directly. + const subscribe = useCallback( + (onStoreChange: () => void) => { + const unsubscribe = onyxSubscriptionManager.subscribeState(onStoreChange, depsArray); + + // `Onyx.init` hydrates the cache without notifying anyone, so a selector that ran before it + // finished saw an empty state and would never hear the stored values arrive. Re-read once + // init lands. + const initTask = OnyxUtils.getDeferredInitTask(); + let isActive = true; + if (!initTask.isResolved) { + initTask.promise.then(() => { + if (!isActive) { + return; + } + onStoreChange(); + }); + } + + return () => { + isActive = false; + unsubscribe(); + }; + }, + [depsArray], + ); + + // Mutated only in the post-commit effect below, so it stays frozen across a render's + // `getSnapshot` calls and every one of them computes the same output. + const previousViewRef = useRef(undefined); + const lastSnapshotRef = useRef | null>(null); + + // Flipped by the `previousState` view when the selector reads a key off it. Read straight after the + // selector returns, so it describes that run only. + const previousStateReadRef = useRef(false); + + const getSnapshot = useCallback((): T => { + const lastSnapshot = lastSnapshotRef.current; + const previousView = previousViewRef.current; + + // React calls `getSnapshot` on every render, not only after a store change. When the selector, + // `previousState` and every dep are the same references as last time, the output can't differ, + // so skip the selector and its reads through the state view. A parent re-rendering a list of rows + // otherwise re-runs every row's selector. A selector that ignored `previousState` last time is + // not affected by it moving, so it stays cached when only the view changed. + if ( + lastSnapshot !== null && + lastSnapshot.selector === selector && + (!lastSnapshot.readPreviousState || lastSnapshot.previousView === previousView) && + areDepValuesUnchanged(depsArray, lastSnapshot.depValues) + ) { + return lastSnapshot.value; + } + + const depValues = depsArray.map((dep) => onyxSubscriptionManager.getState(dep)); + previousStateReadRef.current = false; + // The views carry every key at runtime; the narrowing is a constraint on what a selector may + // read, so hand them over as the declared shape. + const output = selector(liveStateView as OnyxStateViewFor, previousView as OnyxStateViewFor | undefined); + const readPreviousState = previousStateReadRef.current; + + // Hand back the previous reference when the output is equal, so React bails out via `===` + // and an unchanged store keeps returning the same value even as `getSnapshot`'s identity moves. + const value = lastSnapshot !== null && selectorEquality(lastSnapshot.value, output) ? lastSnapshot.value : output; + lastSnapshotRef.current = {value, selector, previousView, readPreviousState, depValues}; + return value; + }, [selector, selectorEquality, depsArray]); + + const value = useSyncExternalStore(subscribe, getSnapshot); + + // Snapshot the deps after a commit that changed the output, to serve as `previousState` on the next + // selector run. Doing it here rather than in `getSnapshot` keeps that function pure and gives + // `previousState` its meaning, the deps behind the last committed output: a change whose output + // the equality check swallowed never commits, so it never advances past a value nobody saw. Running + // only on output changes also keeps a re-render from a parent from allocating a new view. + useEffect(() => { + const currentDepValues: Record> = {}; + for (const dep of depsArray) { + currentDepValues[dep] = onyxSubscriptionManager.getState(dep); + } + previousViewRef.current = createStateView((key) => { + previousStateReadRef.current = true; + return currentDepValues[key]; + }); + }, [value, depsArray]); + + return value; +} + +export default useOnyxState; +export type {OnyxStateView, UseOnyxStateOptions, UseOnyxStateSelector}; diff --git a/tests/types/useOnyxState.ts b/tests/types/useOnyxState.ts new file mode 100644 index 000000000..b6a32eafe --- /dev/null +++ b/tests/types/useOnyxState.ts @@ -0,0 +1,123 @@ +import useOnyxState from '../../dist/useOnyxState'; +import ONYX_KEYS from './setup'; + +function readCollectionRoot() { + return useOnyxState( + (state) => { + const collection = state[ONYX_KEYS.COLLECTION.TEST_KEY]; + + if (!collection) { + return undefined; + } + + // A root reads back as the whole collection, so it is indexable by member key. + const draft: typeof collection = {...collection}; + draft.test_1 = {str: 'mutated'}; + + return draft; + }, + {dependencies: [ONYX_KEYS.COLLECTION.TEST_KEY]}, + ); +} + +function readCollectionMember() { + return useOnyxState( + (state) => { + const member = state[`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]; + + if (!member) { + return undefined; + } + + // Annotation pins the draft to the read type because a spread alone drops readonly modifiers. + const draft: typeof member = {...member, list: member.list ? [...member.list] : undefined}; + draft.str = 'mutated'; + draft.list?.push('appended'); + + return draft; + }, + {dependencies: [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]}, + ); +} + +function readSingleKey() { + return useOnyxState((state) => state[ONYX_KEYS.TEST_KEY]?.toUpperCase(), {dependencies: [ONYX_KEYS.TEST_KEY]}); +} + +function readPreviousState() { + return useOnyxState( + (state, previousState) => { + const current = state[ONYX_KEYS.COLLECTION.TEST_KEY]; + const previous = previousState?.[ONYX_KEYS.COLLECTION.TEST_KEY]; + + // Both sides are collections, so changed members can be found by reference. + return Object.keys(current ?? {}).filter((key) => current?.[key] !== previous?.[key]); + }, + {dependencies: [ONYX_KEYS.COLLECTION.TEST_KEY]}, + ); +} + +function rootIsNotAMember() { + return useOnyxState( + (state) => { + // @ts-expect-error a root reads as the whole collection, so it is not one member + const member: {str: string} | undefined = state[ONYX_KEYS.COLLECTION.TEST_KEY]; + + return member; + }, + {dependencies: [ONYX_KEYS.COLLECTION.TEST_KEY]}, + ); +} + +function memberIsNotACollection() { + return useOnyxState( + (state) => { + const member = state[`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]; + + // @ts-expect-error a member is one entry, not a collection keyed by member key + return member?.test_1; + }, + {dependencies: [`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]}, + ); +} + +function cannotReadAnUndeclaredSingleKey() { + return useOnyxState( + // @ts-expect-error the selector only gets the keys its dependencies declared + (state) => state[ONYX_KEYS.TEST_KEY], + {dependencies: [ONYX_KEYS.COLLECTION.TEST_KEY]}, + ); +} + +function cannotReadAnUndeclaredCollection() { + return useOnyxState( + // @ts-expect-error the selector only gets the keys its dependencies declared + (state) => state[ONYX_KEYS.COLLECTION.TEST_KEY], + {dependencies: [ONYX_KEYS.TEST_KEY]}, + ); +} + +function cannotReadAnUndeclaredKeyFromPreviousState() { + return useOnyxState( + // @ts-expect-error previousState is narrowed to the declared keys too + (state, previousState) => previousState?.[ONYX_KEYS.COLLECTION.TEST_KEY], + {dependencies: [ONYX_KEYS.TEST_KEY]}, + ); +} + +function aDeclaredRootGrantsItsMembers() { + return useOnyxState((state) => state[`${ONYX_KEYS.COLLECTION.TEST_KEY}1`]?.str, {dependencies: [ONYX_KEYS.COLLECTION.TEST_KEY]}); +} + +export { + readCollectionRoot, + readCollectionMember, + readSingleKey, + readPreviousState, + rootIsNotAMember, + memberIsNotACollection, + cannotReadAnUndeclaredSingleKey, + cannotReadAnUndeclaredCollection, + cannotReadAnUndeclaredKeyFromPreviousState, + aDeclaredRootGrantsItsMembers, +}; diff --git a/tests/unit/OnyxSubscriptionManagerTest.ts b/tests/unit/OnyxSubscriptionManagerTest.ts index e892e412b..08f4bc58e 100644 --- a/tests/unit/OnyxSubscriptionManagerTest.ts +++ b/tests/unit/OnyxSubscriptionManagerTest.ts @@ -12,6 +12,8 @@ import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; // and only ever `.clear()`ed (never reassigned), so capturing the references here stays valid. // eslint-disable-next-line dot-notation const keyListeners = onyxSubscriptionManager['keyListeners']; +// eslint-disable-next-line dot-notation +const stateListenersByDep = onyxSubscriptionManager['stateListenersByDep']; const ONYXKEYS = { TEST_KEY: 'test', @@ -233,6 +235,101 @@ describe('OnyxSubscriptionManager', () => { }); }); + describe('subscribeState', () => { + it('should fire a state listener when one of its declared deps changes', () => { + const callback = jest.fn(); + onyxSubscriptionManager.subscribeState(callback, [ONYXKEYS.TEST_KEY]); + + onyxSubscriptionManager.notifyKey(ONYXKEYS.TEST_KEY, 'x'); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not fire when a non-dependency key changes', () => { + const callback = jest.fn(); + onyxSubscriptionManager.subscribeState(callback, [ONYXKEYS.TEST_KEY]); + + onyxSubscriptionManager.notifyKey(ONYXKEYS.OTHER_TEST, 'x'); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should fire a listener that depends on a collection key when a member is written', () => { + const callback = jest.fn(); + onyxSubscriptionManager.subscribeState(callback, [COLLECTION]); + + onyxSubscriptionManager.notifyKey(MEMBER_1, {id: 1}); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should fire only once when a listener depends on both a member and its collection key', () => { + const callback = jest.fn(); + onyxSubscriptionManager.subscribeState(callback, [MEMBER_1, COLLECTION]); + + onyxSubscriptionManager.notifyKey(MEMBER_1, {id: 1}); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should fire collection and member state listeners once each on notifyCollection', () => { + jest.spyOn(cache, 'getCollectionData').mockReturnValue({[MEMBER_1]: {id: 1}, [MEMBER_2]: {id: 2}}); + + const collectionCallback = jest.fn(); + const member1Callback = jest.fn(); + const bothCallback = jest.fn(); + onyxSubscriptionManager.subscribeState(collectionCallback, [COLLECTION]); + onyxSubscriptionManager.subscribeState(member1Callback, [MEMBER_1]); + onyxSubscriptionManager.subscribeState(bothCallback, [COLLECTION, MEMBER_1]); + + onyxSubscriptionManager.notifyCollection(COLLECTION, {[MEMBER_1]: {id: 1}, [MEMBER_2]: {id: 2}}); + + expect(collectionCallback).toHaveBeenCalledTimes(1); + expect(member1Callback).toHaveBeenCalledTimes(1); + expect(bothCallback).toHaveBeenCalledTimes(1); + }); + + it('should fire on a change to any of several declared deps', () => { + const callback = jest.fn(); + onyxSubscriptionManager.subscribeState(callback, [ONYXKEYS.TEST_KEY, ONYXKEYS.OTHER_TEST]); + + onyxSubscriptionManager.notifyKey(ONYXKEYS.TEST_KEY, 'a'); + onyxSubscriptionManager.notifyKey(ONYXKEYS.OTHER_TEST, 'b'); + + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should stop firing after unsubscribe and clean up the dep index', () => { + const callback = jest.fn(); + const unsubscribe = onyxSubscriptionManager.subscribeState(callback, [ONYXKEYS.TEST_KEY]); + expect(stateListenersByDep.has(ONYXKEYS.TEST_KEY)).toBeTruthy(); + + unsubscribe(); + expect(stateListenersByDep.has(ONYXKEYS.TEST_KEY)).toBeFalsy(); + + onyxSubscriptionManager.notifyKey(ONYXKEYS.TEST_KEY, 'x'); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should isolate a throwing state listener and still fire the others', () => { + const logAlertSpy = jest.spyOn(Logger, 'logAlert').mockImplementation(() => { + /* empty */ + }); + const throwingCallback = jest.fn(() => { + throw new Error('boom'); + }); + const healthyCallback = jest.fn(); + onyxSubscriptionManager.subscribeState(throwingCallback, [ONYXKEYS.TEST_KEY]); + onyxSubscriptionManager.subscribeState(healthyCallback, [ONYXKEYS.TEST_KEY]); + + expect(() => onyxSubscriptionManager.notifyKey(ONYXKEYS.TEST_KEY, 'x')).not.toThrow(); + + expect(throwingCallback).toHaveBeenCalledTimes(1); + expect(healthyCallback).toHaveBeenCalledTimes(1); + expect(logAlertSpy).toHaveBeenCalled(); + }); + }); + describe('hasListenersForKey', () => { it('should return true for an exact-key subscriber', () => { onyxSubscriptionManager.subscribe(ONYXKEYS.TEST_KEY, jest.fn()); diff --git a/tests/unit/useOnyxStateTest.tsx b/tests/unit/useOnyxStateTest.tsx new file mode 100644 index 000000000..38f54bfaf --- /dev/null +++ b/tests/unit/useOnyxStateTest.tsx @@ -0,0 +1,367 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import type {OnyxStateView} from '../../lib/useOnyxState'; +import type GenericCollection from '../utils/GenericCollection'; + +import Onyx from '../../lib'; +import {resetDeferredInitTask} from '../../lib/OnyxUtils'; +import StorageMock from '../../lib/storage'; +import useOnyxState from '../../lib/useOnyxState'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; + +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('useOnyxState', () => { + describe('basic subscription', () => { + it('should return the derived value from a single dependency', async () => { + await Onyx.set(ONYXKEYS.TEST_KEY, 'hello'); + + const {result} = renderHook(() => useOnyxState((state) => state[ONYXKEYS.TEST_KEY], {dependencies: [ONYXKEYS.TEST_KEY]})); + await act(async () => waitForPromisesToResolve()); + + expect(result.current).toEqual('hello'); + }); + + it('should update when the dependency changes', async () => { + const {result} = renderHook(() => useOnyxState((state) => state[ONYXKEYS.TEST_KEY], {dependencies: [ONYXKEYS.TEST_KEY]})); + await act(async () => waitForPromisesToResolve()); + + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, 'updated')); + + expect(result.current).toEqual('updated'); + }); + + it('should NOT re-render when a non-dependency key changes', async () => { + let renderCount = 0; + const {result} = renderHook(() => { + renderCount += 1; + return useOnyxState((state) => state[ONYXKEYS.TEST_KEY], {dependencies: [ONYXKEYS.TEST_KEY]}); + }); + await act(async () => waitForPromisesToResolve()); + + const renderCountAfterMount = renderCount; + + await act(async () => Onyx.set(ONYXKEYS.OTHER_TEST, 'irrelevant')); + + expect(renderCount).toEqual(renderCountAfterMount); + expect(result.current).toBeUndefined(); + }); + + it('should re-run when any of multiple dependencies change', async () => { + const {result} = renderHook(() => + useOnyxState((state) => `${state[ONYXKEYS.TEST_KEY] ?? ''}-${state[ONYXKEYS.OTHER_TEST] ?? ''}`, {dependencies: [ONYXKEYS.TEST_KEY, ONYXKEYS.OTHER_TEST]}), + ); + await act(async () => waitForPromisesToResolve()); + + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, 'a')); + expect(result.current).toEqual('a-'); + + await act(async () => Onyx.set(ONYXKEYS.OTHER_TEST, 'b')); + expect(result.current).toEqual('a-b'); + }); + }); + + describe('collection dependency', () => { + it('should re-run when any member of a collection dependency changes', async () => { + const {result} = renderHook(() => useOnyxState((state) => Object.keys(state[COLLECTION] ?? {}).length, {dependencies: [COLLECTION]})); + await act(async () => waitForPromisesToResolve()); + + expect(result.current).toEqual(0); + + await act(async () => Onyx.merge(MEMBER_1, {id: 1})); + expect(result.current).toEqual(1); + + await act(async () => Onyx.merge(MEMBER_2, {id: 2})); + expect(result.current).toEqual(2); + }); + + it('should re-run on a mergeCollection write', async () => { + const {result} = renderHook(() => useOnyxState((state) => Object.keys(state[COLLECTION] ?? {}).length, {dependencies: [COLLECTION]})); + await act(async () => waitForPromisesToResolve()); + + await act(async () => Onyx.mergeCollection(COLLECTION, {[MEMBER_1]: {id: 1}, [MEMBER_2]: {id: 2}} as GenericCollection)); + + expect(result.current).toEqual(2); + }); + }); + + describe('previousState', () => { + it('should pass undefined as previousState on the first selector run', async () => { + const observed: Array = []; + renderHook(() => + useOnyxState( + (state, previousState) => { + observed.push(previousState); + return state[ONYXKEYS.TEST_KEY]; + }, + {dependencies: [ONYXKEYS.TEST_KEY]}, + ), + ); + await act(async () => waitForPromisesToResolve()); + + // The very first invocation always runs before any output has been captured. + expect(observed[0]).toBeUndefined(); + }); + + it('should expose the prior dependency value through previousState after a change', async () => { + await Onyx.set(ONYXKEYS.TEST_KEY, 'a'); + + let sawCurrentBWithPreviousA = false; + renderHook(() => + useOnyxState( + (state, previousState) => { + if (state[ONYXKEYS.TEST_KEY] === 'b' && previousState?.[ONYXKEYS.TEST_KEY] === 'a') { + sawCurrentBWithPreviousA = true; + } + return state[ONYXKEYS.TEST_KEY]; + }, + {dependencies: [ONYXKEYS.TEST_KEY]}, + ), + ); + await act(async () => waitForPromisesToResolve()); + + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, 'b')); + + expect(sawCurrentBWithPreviousA).toBeTruthy(); + }); + }); + + describe('previousState-dependent output (delta selectors)', () => { + // `previousState` is advanced post-commit, so a selector whose OUTPUT depends on it + // is stable: within a render the previous view is frozen, so repeated getSnapshot + // calls produce the same delta. + it('should return a stable delta of which collection members changed since the last render', async () => { + await Onyx.mergeCollection(COLLECTION, {[MEMBER_1]: {v: 1}, [MEMBER_2]: {v: 1}} as GenericCollection); + + const {result} = renderHook(() => + useOnyxState( + (state, previousState) => { + const current = (state[COLLECTION] ?? {}) as Record; + const previous = (previousState?.[COLLECTION] ?? {}) as Record; + return Object.keys(current) + .filter((memberKey) => current[memberKey] !== previous[memberKey]) + .sort(); + }, + {dependencies: [COLLECTION]}, + ), + ); + await act(async () => waitForPromisesToResolve()); + + // Change only MEMBER_2 — the delta should contain exactly MEMBER_2. + await act(async () => Onyx.merge(MEMBER_2, {v: 2})); + + expect(result.current).toEqual([MEMBER_2]); + }); + + it('should recompute the delta on each subsequent change (previousState advances per commit)', async () => { + await Onyx.mergeCollection(COLLECTION, {[MEMBER_1]: {v: 1}, [MEMBER_2]: {v: 1}} as GenericCollection); + + const {result} = renderHook(() => + useOnyxState( + (state, previousState) => { + const current = (state[COLLECTION] ?? {}) as Record; + const previous = (previousState?.[COLLECTION] ?? {}) as Record; + return Object.keys(current) + .filter((memberKey) => current[memberKey] !== previous[memberKey]) + .sort(); + }, + {dependencies: [COLLECTION]}, + ), + ); + await act(async () => waitForPromisesToResolve()); + + await act(async () => Onyx.merge(MEMBER_1, {v: 9})); + expect(result.current).toEqual([MEMBER_1]); + + // A second, independent change must report only MEMBER_2 — proving `previousState` + // advanced to the post-first-change snapshot rather than staying at the original. + await act(async () => Onyx.merge(MEMBER_2, {v: 9})); + expect(result.current).toEqual([MEMBER_2]); + }); + }); + + describe('selectorEquality', () => { + it('should preserve the output reference when the new output is deep-equal (default equality)', async () => { + const {result} = renderHook(() => useOnyxState((state) => ({length: ((state[ONYXKEYS.TEST_KEY] as string) ?? '').length}), {dependencies: [ONYXKEYS.TEST_KEY]})); + await act(async () => waitForPromisesToResolve()); + + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, 'aa')); + const referenceAfterFirstChange = result.current; + expect(referenceAfterFirstChange).toEqual({length: 2}); + + // New input, same-length string → deep-equal output → the previous reference is kept. + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, 'bb')); + + expect(result.current).toBe(referenceAfterFirstChange); + }); + + it('should never update after the first value when a custom equality always returns true', async () => { + const {result} = renderHook(() => + useOnyxState((state) => state[ONYXKEYS.TEST_KEY] ?? 'none', { + dependencies: [ONYXKEYS.TEST_KEY], + selectorEquality: () => true, + }), + ); + await act(async () => waitForPromisesToResolve()); + + expect(result.current).toEqual('none'); + + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, 'changed')); + + // Custom equality reports "unchanged", so the React update is skipped. + expect(result.current).toEqual('none'); + }); + + it('should update when a custom equality reports the output changed', async () => { + const {result} = renderHook(() => + useOnyxState((state) => state[ONYXKEYS.TEST_KEY] ?? 'none', { + dependencies: [ONYXKEYS.TEST_KEY], + selectorEquality: (a, b) => a === b, + }), + ); + await act(async () => waitForPromisesToResolve()); + + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, 'changed')); + + expect(result.current).toEqual('changed'); + }); + }); + + describe('re-render without a store change', () => { + it('should not run the selector again when the component re-renders and no dependency changed', async () => { + await Onyx.set(ONYXKEYS.TEST_KEY, 'hello'); + const selector = jest.fn((state: OnyxStateView) => state[ONYXKEYS.TEST_KEY]); + const {result, rerender} = renderHook((_props: {renderID: number}) => useOnyxState(selector, {dependencies: [ONYXKEYS.TEST_KEY]}), {initialProps: {renderID: 0}}); + await act(async () => waitForPromisesToResolve()); + const callCountAfterMount = selector.mock.calls.length; + + rerender({renderID: 1}); + rerender({renderID: 2}); + + expect(selector).toHaveBeenCalledTimes(callCountAfterMount); + expect(result.current).toEqual('hello'); + }); + + it('should run the selector again when a re-render passes a new selector', async () => { + await Onyx.set(ONYXKEYS.TEST_KEY, 'hello'); + const {result, rerender} = renderHook( + ({suffix}: {suffix: string}) => useOnyxState((state) => `${state[ONYXKEYS.TEST_KEY] as string}${suffix}`, {dependencies: [ONYXKEYS.TEST_KEY]}), + { + initialProps: {suffix: '!'}, + }, + ); + await act(async () => waitForPromisesToResolve()); + + rerender({suffix: '?'}); + + expect(result.current).toEqual('hello?'); + }); + + it('should run the selector again on a re-render after a dependency changed', async () => { + await Onyx.set(ONYXKEYS.TEST_KEY, 'hello'); + const selector = jest.fn((state: OnyxStateView) => state[ONYXKEYS.TEST_KEY]); + const {result} = renderHook(() => useOnyxState(selector, {dependencies: [ONYXKEYS.TEST_KEY]})); + await act(async () => waitForPromisesToResolve()); + const callCountAfterMount = selector.mock.calls.length; + + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, 'updated')); + + expect(selector.mock.calls.length).toBeGreaterThan(callCountAfterMount); + expect(result.current).toEqual('updated'); + }); + + it('should still pass previousState to a selector that declares it with a default value', async () => { + await Onyx.set(ONYXKEYS.TEST_KEY, 'first'); + // `Function.length` stops counting at the first defaulted parameter, so this selector reports + // an arity of 1 even though it reads `previousState`. + const {result} = renderHook(() => + useOnyxState((state, previousState = undefined) => `${state[ONYXKEYS.TEST_KEY] as string}/${(previousState?.[ONYXKEYS.TEST_KEY] as string) ?? 'none'}`, { + dependencies: [ONYXKEYS.TEST_KEY], + }), + ); + await act(async () => waitForPromisesToResolve()); + + await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, 'second')); + + expect(result.current).toEqual('second/first'); + }); + }); + + describe('before Onyx.init has finished', () => { + // Providers above the app's migration gate subscribe during a cold start, before the cache has + // been hydrated, so put Onyx back to "not initialised" for these. + beforeEach(async () => { + // `Onyx.clear()` waits for init, so clear storage directly before taking init away. + await StorageMock.clear(); + resetDeferredInitTask(); + }); + + afterEach(async () => { + Onyx.init({keys: ONYXKEYS}); + await act(async () => waitForPromisesToResolve()); + }); + + it('should re-run the selector once init hydrates the cache', async () => { + // Given values already in storage and a subscriber mounted before init runs + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'from storage'); + await StorageMock.setItem(ONYXKEYS.OTHER_TEST, 'other from storage'); + + const {result} = renderHook(() => + useOnyxState((state) => [state[ONYXKEYS.TEST_KEY], state[ONYXKEYS.OTHER_TEST]].join('|'), { + dependencies: [ONYXKEYS.TEST_KEY, ONYXKEYS.OTHER_TEST], + }), + ); + + // Then the selector has only an empty state to work with, because `cache.hydrate()` + // notifies nobody + expect(result.current).toEqual('|'); + + // When init hydrates the cache + Onyx.init({keys: ONYXKEYS}); + await act(async () => waitForPromisesToResolve()); + + // Then the selector re-runs against what was in storage, rather than being stuck on the + // empty-state output forever + expect(result.current).toEqual('from storage|other from storage'); + }); + + it('should report the post-init values as a change against the pre-hydration previousState', async () => { + // Given a value in storage and a selector that reports what changed since its last output + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'from storage'); + + const {result} = renderHook(() => + useOnyxState( + (state, previousState) => ({ + previous: previousState?.[ONYXKEYS.TEST_KEY], + current: state[ONYXKEYS.TEST_KEY], + }), + {dependencies: [ONYXKEYS.TEST_KEY]}, + ), + ); + + // When init hydrates the cache after the first committed render + Onyx.init({keys: ONYXKEYS}); + await act(async () => waitForPromisesToResolve()); + + // Then `previousState` holds the pre-hydration value, so hydration reads as the key + // appearing rather than as no change at all + expect(result.current).toEqual({previous: undefined, current: 'from storage'}); + }); + }); +});