From 0e973fc921a378e89318c483d425bcb709944f95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Thu, 22 Oct 2026 08:19:39 +0100 Subject: [PATCH 1/7] feat: add useOnyxState multi-key derived hook on the store registry Adds a state-listener index to OnyxSubscriptionManager (subscribeState, dep-keyed, once-per-dispatch fire) and useOnyxState, a hook that derives a value from multiple Onyx keys with explicit dependencies and a previousState view. Co-Authored-By: Claude Opus 4.8 --- lib/OnyxSubscriptionManager.ts | 87 +++++++- lib/index.ts | 7 +- lib/useOnyxState.ts | 160 ++++++++++++++ tests/unit/OnyxSubscriptionManagerTest.ts | 97 +++++++++ tests/unit/useOnyxStateTest.tsx | 241 ++++++++++++++++++++++ 5 files changed, 585 insertions(+), 7 deletions(-) create mode 100644 lib/useOnyxState.ts create mode 100644 tests/unit/useOnyxStateTest.tsx 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..7842a9525 --- /dev/null +++ b/lib/useOnyxState.ts @@ -0,0 +1,160 @@ +import {deepEqual} from 'fast-equals'; +import {useCallback, useEffect, useMemo, useRef, useSyncExternalStore} from 'react'; +import onyxSubscriptionManager from './OnyxSubscriptionManager'; +import type {OnyxKey, OnyxValue} from './types'; + +/** + * Read-only virtual view over the Onyx state. Property access on a key returns + * the same value as `Onyx.getState(key)` — for collection keys the frozen snapshot, + * for single keys the cached value, or `undefined` if not present. + * + * No tree is materialized. Each property access routes through the store directly. + */ +type OnyxStateView = { + readonly [K in OnyxKey]: OnyxValue; +}; + +type UseOnyxStateSelector = (state: OnyxStateView, previousState: OnyxStateView | undefined) => T; + +type UseOnyxStateOptions = { + /** + * Onyx keys this selector depends on. The selector re-runs only when one of + * these keys' values changes. Collection keys count as a single dep — any + * member change triggers a re-run. + */ + dependencies: readonly OnyxKey[]; + + /** + * Custom equality function for the selector's output. Defaults to `===` with + * a deep-equal fallback for object outputs. When the comparison returns true, + * the React update is skipped. + */ + selectorEquality?: (a: T, b: T) => boolean; +}; + +/** + * Lazy, sealed Proxy whose getters route to `onyxSubscriptionManager.getState`. Used as both + * `state` and `previousState`. The previousState variant captures the dep values + * frozen at the previous selector invocation. + */ +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 — 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)); + +/** + * Default equality: reference equality with a deepEqual fallback for object outputs. + * Primitives and frozen-snapshot references compare correctly with `===`. Objects that + * the selector freshly allocates with the same content fall back to deepEqual. + */ +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); +} + +/** + * Subscribe to a derived value computed from multiple Onyx keys. The selector + * receives a virtual `state` view and a `previousState` view holding the values + * of declared deps as of the last committed render. `previousState` may be used + * both as a performance aid (ref-equality walks for incremental compute — structural + * sharing makes unchanged collection members share references with the previous + * snapshot, so the walk is O(collection_size) with O(1) per-step cost) AND as a + * semantic input (the output is allowed to depend on it, e.g. a delta of what changed). + * + * Re-runs when any declared dep changes; skips React updates when the selector + * output is equal to the previous output (per `selectorEquality`, default + * `===` with deepEqual fallback). + * + * `previousState` is advanced in a post-commit effect, NOT during `getSnapshot`. + * This keeps `getSnapshot` pure (a `useSyncExternalStore` requirement): within a + * single render `previousViewRef` is frozen, so every `getSnapshot` call computes + * the same output regardless of how many times React invokes it. It also gives + * `previousState` the correct meaning — "deps at the last *committed* output": a + * store change whose output is equality-gated away never commits, so it never + * advances `previousState` past a value the consumer never saw. + */ +function useOnyxState(selector: UseOnyxStateSelector, options: UseOnyxStateOptions): T { + const {dependencies, selectorEquality = defaultSelectorEquality} = options; + + // Consumers usually pass an inline array literal (new reference every render), so we key + // the memo on the joined contents — `depsArray` only gets a new reference when the actual + // set of dependency keys changes, which keeps `subscribe` from re-subscribing every render. + // The space separator is safe because Onyx keys are identifiers/collection prefixes that + // never contain spaces. + // + // Keying on `depsKey` (content) rather than `dependencies` (identity) is something React + // Compiler can't preserve, so this hook opts out of compiler memoization and manages its + // own — deliberate, as it's hand-tuned for `useSyncExternalStore`. + const depsKey = dependencies.join(' '); + const depsArray = useMemo(() => [...dependencies], [depsKey]); // eslint-disable-line react-hooks/exhaustive-deps, react-hooks/preserve-manual-memoization + + // Only `subscribe` must be referentially stable for `useSyncExternalStore` (it controls + // re-subscription). `getSnapshot` may change identity freely — React just re-reads it — + // so it closes over the latest `selector`/`selectorEquality` directly instead of via refs. + const subscribe = useCallback((onStoreChange: () => void) => onyxSubscriptionManager.subscribeState(onStoreChange, depsArray), [depsArray]); + + // `previousViewRef` is a Proxy over the dep values captured at the last committed + // render. It is ONLY mutated in the effect below (post-commit), never in getSnapshot, + // so it stays constant while a render's getSnapshot calls run. + const previousViewRef = useRef(undefined); + const lastOutputRef = useRef<{value: T} | null>(null); + + const getSnapshot = useCallback((): T => { + const output = selector(liveStateView, previousViewRef.current); + + // Ref-preservation: when the new output is equal to the last one, return the previous + // reference so React can bail out via `===`. This also keeps getSnapshot returning a + // stable value for an unchanged store, despite its identity changing between renders. + if (lastOutputRef.current !== null && selectorEquality(lastOutputRef.current.value, output)) { + return lastOutputRef.current.value; + } + + lastOutputRef.current = {value: output}; + return output; + }, [selector, selectorEquality]); + + const value = useSyncExternalStore(subscribe, getSnapshot); + + // After each committed render, snapshot the current dep values and expose them as + // `previousState` for the next selector run. Running on commit (not inside getSnapshot) + // keeps getSnapshot pure and makes `previousState` reflect the last delivered output. + useEffect(() => { + const currentDepValues: Record> = {}; + for (const dep of depsArray) { + currentDepValues[dep] = onyxSubscriptionManager.getState(dep); + } + previousViewRef.current = createStateView((key) => currentDepValues[key]); + }); + + return value; +} + +export default useOnyxState; +export type {OnyxStateView, UseOnyxStateOptions, UseOnyxStateSelector}; 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..4538064ea --- /dev/null +++ b/tests/unit/useOnyxStateTest.tsx @@ -0,0 +1,241 @@ +import {act, renderHook} from '@testing-library/react-native'; +import Onyx from '../../lib'; +import useOnyxState from '../../lib/useOnyxState'; +import type {OnyxStateView} from '../../lib/useOnyxState'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; +import type GenericCollection from '../utils/GenericCollection'; + +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'); + }); + }); +}); From eed81f0f0c62b0a56ecb714f05c1968f34103892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Thu, 22 Oct 2026 08:19:40 +0100 Subject: [PATCH 2/7] fix: keep useOnyxState subscribed until Onyx.init has hydrated the cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cache.hydrate()` fills the cache without notifying anyone, so a `useOnyxState` subscriber that mounted during a cold start ran its selector against an empty state and never heard the stored values arrive. It bites above the app's migration gate — the providers wrapped around it, and `HybridAppHandler` — where components render before Onyx has finished initialising. `useOnyx` grew the same fix, but needed more than this: its `loading` flag lives outside the `useSyncExternalStore` snapshot, so it also needed an availability value in the snapshot to force a re-render for a key that init resolves to no value at all. `useOnyxState` has no status channel, so React bailing out on an unchanged selector output is the right outcome and the subscribe side is enough. The flip side is that a consumer cannot tell "not hydrated yet" from "absent" unless it encodes that in its own output. Said so in the hook's docblock rather than inventing a status channel here. Co-Authored-By: Claude Opus 5 (1M context) --- lib/useOnyxState.ts | 33 ++++++++++++++++- tests/unit/useOnyxStateTest.tsx | 64 +++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/lib/useOnyxState.ts b/lib/useOnyxState.ts index 7842a9525..1efa6272a 100644 --- a/lib/useOnyxState.ts +++ b/lib/useOnyxState.ts @@ -1,6 +1,7 @@ import {deepEqual} from 'fast-equals'; import {useCallback, useEffect, useMemo, useRef, useSyncExternalStore} from 'react'; import onyxSubscriptionManager from './OnyxSubscriptionManager'; +import OnyxUtils from './OnyxUtils'; import type {OnyxKey, OnyxValue} from './types'; /** @@ -92,6 +93,10 @@ function defaultSelectorEquality(a: T, b: T): boolean { * output is equal to the previous output (per `selectorEquality`, default * `===` with deepEqual fallback). * + * Mounting before `Onyx.init` has hydrated the cache runs the selector against an empty + * state — there is no status channel to say so — and re-runs it once init lands. A consumer + * that must distinguish "not hydrated yet" from "absent" has to carry that in its own output. + * * `previousState` is advanced in a post-commit effect, NOT during `getSnapshot`. * This keeps `getSnapshot` pure (a `useSyncExternalStore` requirement): within a * single render `previousViewRef` is frozen, so every `getSnapshot` call computes @@ -118,7 +123,33 @@ function useOnyxState(selector: UseOnyxStateSelector, options: UseOnyxStat // Only `subscribe` must be referentially stable for `useSyncExternalStore` (it controls // re-subscription). `getSnapshot` may change identity freely — React just re-reads it — // so it closes over the latest `selector`/`selectorEquality` directly instead of via refs. - const subscribe = useCallback((onStoreChange: () => void) => onyxSubscriptionManager.subscribeState(onStoreChange, depsArray), [depsArray]); + 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. Unlike `useOnyx` this needs nothing on the render side: there is no `loading` + // flag outside the snapshot to unstick, so React bailing out on an unchanged selector output + // is the right outcome. + const initTask = OnyxUtils.getDeferredInitTask(); + let isActive = true; + if (!initTask.isResolved) { + initTask.promise.then(() => { + if (!isActive) { + return; + } + onStoreChange(); + }); + } + + return () => { + isActive = false; + unsubscribe(); + }; + }, + [depsArray], + ); // `previousViewRef` is a Proxy over the dep values captured at the last committed // render. It is ONLY mutated in the effect below (post-commit), never in getSnapshot, diff --git a/tests/unit/useOnyxStateTest.tsx b/tests/unit/useOnyxStateTest.tsx index 4538064ea..051d79083 100644 --- a/tests/unit/useOnyxStateTest.tsx +++ b/tests/unit/useOnyxStateTest.tsx @@ -1,5 +1,7 @@ import {act, renderHook} from '@testing-library/react-native'; import Onyx from '../../lib'; +import {resetDeferredInitTask} from '../../lib/OnyxUtils'; +import StorageMock from '../../lib/storage'; import useOnyxState from '../../lib/useOnyxState'; import type {OnyxStateView} from '../../lib/useOnyxState'; import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; @@ -238,4 +240,66 @@ describe('useOnyxState', () => { expect(result.current).toEqual('changed'); }); }); + + 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'}); + }); + }); }); From 04033aaaeda7188e6e182fa3e83886279b8d3279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Thu, 22 Oct 2026 11:12:56 +0100 Subject: [PATCH 3/7] Trim useOnyxState comments The file sat at 41% comments against 23% in useOnyx.ts and OnyxSubscriptionManager.ts, and explained why `previousState` advances post-commit in three separate places. That rationale now lives once, on the effect that does it. Comments only, no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) --- lib/useOnyxState.ts | 100 ++++++++++++++++---------------------------- 1 file changed, 35 insertions(+), 65 deletions(-) diff --git a/lib/useOnyxState.ts b/lib/useOnyxState.ts index 1efa6272a..5338d9b48 100644 --- a/lib/useOnyxState.ts +++ b/lib/useOnyxState.ts @@ -5,11 +5,9 @@ import OnyxUtils from './OnyxUtils'; import type {OnyxKey, OnyxValue} from './types'; /** - * Read-only virtual view over the Onyx state. Property access on a key returns - * the same value as `Onyx.getState(key)` — for collection keys the frozen snapshot, - * for single keys the cached value, or `undefined` if not present. - * - * No tree is materialized. Each property access routes through the store directly. + * 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; @@ -19,25 +17,19 @@ type UseOnyxStateSelector = (state: OnyxStateView, previousState: OnyxStateVi type UseOnyxStateOptions = { /** - * Onyx keys this selector depends on. The selector re-runs only when one of - * these keys' values changes. Collection keys count as a single dep — any - * member change triggers a re-run. + * 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. */ dependencies: readonly OnyxKey[]; /** - * Custom equality function for the selector's output. Defaults to `===` with - * a deep-equal fallback for object outputs. When the comparison returns true, - * the React update is skipped. + * 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; }; -/** - * Lazy, sealed Proxy whose getters route to `onyxSubscriptionManager.getState`. Used as both - * `state` and `previousState`. The previousState variant captures the dep values - * frozen at the previous selector invocation. - */ +/** 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) { @@ -47,7 +39,7 @@ function createStateView(snapshotProvider: (key: OnyxKey) => OnyxValue) return snapshotProvider(prop); }, set() { - // Read-only — selector must not mutate the view. + // Read-only: the selector must not mutate the view. return false; }, deleteProperty() { @@ -66,9 +58,8 @@ function createStateView(snapshotProvider: (key: OnyxKey) => OnyxValue) const liveStateView = createStateView((key) => onyxSubscriptionManager.getState(key as OnyxKey)); /** - * Default equality: reference equality with a deepEqual fallback for object outputs. - * Primitives and frozen-snapshot references compare correctly with `===`. Objects that - * the selector freshly allocates with the same content fall back to deepEqual. + * `===` 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)) { @@ -81,57 +72,37 @@ function defaultSelectorEquality(a: T, b: T): boolean { } /** - * Subscribe to a derived value computed from multiple Onyx keys. The selector - * receives a virtual `state` view and a `previousState` view holding the values - * of declared deps as of the last committed render. `previousState` may be used - * both as a performance aid (ref-equality walks for incremental compute — structural - * sharing makes unchanged collection members share references with the previous - * snapshot, so the walk is O(collection_size) with O(1) per-step cost) AND as a - * semantic input (the output is allowed to depend on it, e.g. a delta of what changed). - * - * Re-runs when any declared dep changes; skips React updates when the selector - * output is equal to the previous output (per `selectorEquality`, default - * `===` with deepEqual fallback). + * 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. * - * Mounting before `Onyx.init` has hydrated the cache runs the selector against an empty - * state — there is no status channel to say so — and re-runs it once init lands. A consumer - * that must distinguish "not hydrated yet" from "absent" has to carry that in its own output. + * Re-runs when any declared dep changes, and skips the React update when the output is equal to the + * previous one, per `selectorEquality`. * - * `previousState` is advanced in a post-commit effect, NOT during `getSnapshot`. - * This keeps `getSnapshot` pure (a `useSyncExternalStore` requirement): within a - * single render `previousViewRef` is frozen, so every `getSnapshot` call computes - * the same output regardless of how many times React invokes it. It also gives - * `previousState` the correct meaning — "deps at the last *committed* output": a - * store change whose output is equality-gated away never commits, so it never - * advances `previousState` past a value the consumer never saw. + * 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 usually pass an inline array literal (new reference every render), so we key - // the memo on the joined contents — `depsArray` only gets a new reference when the actual - // set of dependency keys changes, which keeps `subscribe` from re-subscribing every render. - // The space separator is safe because Onyx keys are identifiers/collection prefixes that - // never contain spaces. - // - // Keying on `depsKey` (content) rather than `dependencies` (identity) is something React - // Compiler can't preserve, so this hook opts out of compiler memoization and manages its - // own — deliberate, as it's hand-tuned for `useSyncExternalStore`. + // 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` must be referentially stable for `useSyncExternalStore` (it controls - // re-subscription). `getSnapshot` may change identity freely — React just re-reads it — - // so it closes over the latest `selector`/`selectorEquality` directly instead of via refs. + // 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. Unlike `useOnyx` this needs nothing on the render side: there is no `loading` - // flag outside the snapshot to unstick, so React bailing out on an unchanged selector output - // is the right outcome. + // init lands. const initTask = OnyxUtils.getDeferredInitTask(); let isActive = true; if (!initTask.isResolved) { @@ -151,18 +122,16 @@ function useOnyxState(selector: UseOnyxStateSelector, options: UseOnyxStat [depsArray], ); - // `previousViewRef` is a Proxy over the dep values captured at the last committed - // render. It is ONLY mutated in the effect below (post-commit), never in getSnapshot, - // so it stays constant while a render's getSnapshot calls run. + // 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 lastOutputRef = useRef<{value: T} | null>(null); const getSnapshot = useCallback((): T => { const output = selector(liveStateView, previousViewRef.current); - // Ref-preservation: when the new output is equal to the last one, return the previous - // reference so React can bail out via `===`. This also keeps getSnapshot returning a - // stable value for an unchanged store, despite its identity changing between renders. + // 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. if (lastOutputRef.current !== null && selectorEquality(lastOutputRef.current.value, output)) { return lastOutputRef.current.value; } @@ -173,9 +142,10 @@ function useOnyxState(selector: UseOnyxStateSelector, options: UseOnyxStat const value = useSyncExternalStore(subscribe, getSnapshot); - // After each committed render, snapshot the current dep values and expose them as - // `previousState` for the next selector run. Running on commit (not inside getSnapshot) - // keeps getSnapshot pure and makes `previousState` reflect the last delivered output. + // Snapshot the deps after each committed render, 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. useEffect(() => { const currentDepValues: Record> = {}; for (const dep of depsArray) { From 9774f9ac9280cad5e89679980bd3a35131ed1936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Fri, 23 Oct 2026 12:02:36 +0100 Subject: [PATCH 4/7] Type a collection root as the whole collection in the state view Reading a collection root off the state view came back typed as a single member, while at runtime it is the whole collection, so consumers had to cast. Collection keys reach `OnyxKey` as the template `${CollectionKeyBase}${string}`, and a mapped type turns that into one index signature typed off the template rather than off the key being indexed. The template itself does not extend `CollectionKeyBase`, so `OnyxValue` picks the member branch, and a root such as `report_` matches the signature and inherits that member type. Spelling the roots out in their own mapped type gives them a declared property, which wins over the index signature. Without `CustomTypeOptions` the guard resolves to `unknown` and the view is unchanged, since every key there is already `unknown`. Co-Authored-By: Claude Opus 5 (1M context) --- lib/useOnyxState.ts | 23 +++++++++- tests/types/useOnyxState.ts | 84 +++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 tests/types/useOnyxState.ts diff --git a/lib/useOnyxState.ts b/lib/useOnyxState.ts index 5338d9b48..dc62b6629 100644 --- a/lib/useOnyxState.ts +++ b/lib/useOnyxState.ts @@ -1,8 +1,27 @@ 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'; -import type {OnyxKey, OnyxValue} from './types'; + +/** + * 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 @@ -11,7 +30,7 @@ import type {OnyxKey, OnyxValue} from './types'; */ type OnyxStateView = { readonly [K in OnyxKey]: OnyxValue; -}; +} & OnyxCollectionRootsView; type UseOnyxStateSelector = (state: OnyxStateView, previousState: OnyxStateView | undefined) => T; diff --git a/tests/types/useOnyxState.ts b/tests/types/useOnyxState.ts new file mode 100644 index 000000000..4d099ad59 --- /dev/null +++ b/tests/types/useOnyxState.ts @@ -0,0 +1,84 @@ +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`]}, + ); +} + +export {readCollectionRoot, readCollectionMember, readSingleKey, readPreviousState, rootIsNotAMember, memberIsNotACollection}; From e55b8ea20aecdab2802b0404bfb7777edc2de146 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 29 Oct 2026 12:45:37 +0100 Subject: [PATCH 5/7] Skip the useOnyxState selector when its inputs are unchanged --- lib/useOnyxState.ts | 66 ++++++++++++++++++++++++++------- tests/unit/useOnyxStateTest.tsx | 50 ++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/lib/useOnyxState.ts b/lib/useOnyxState.ts index dc62b6629..a3778e0c6 100644 --- a/lib/useOnyxState.ts +++ b/lib/useOnyxState.ts @@ -37,7 +37,8 @@ type UseOnyxStateSelector = (state: OnyxStateView, previousState: OnyxStateVi 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. + * single dep, so any member change triggers a re-run. A key read but not listed can go stale, + * since the selector is skipped while the listed deps keep their references. */ dependencies: readonly OnyxKey[]; @@ -76,6 +77,26 @@ function createStateView(snapshotProvider: (key: OnyxKey) => OnyxValue) const liveStateView = createStateView((key) => onyxSubscriptionManager.getState(key as OnyxKey)); +type SnapshotCache = { + value: T; + selector: UseOnyxStateSelector; + previousView: OnyxStateView | undefined; + 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. @@ -144,34 +165,51 @@ function useOnyxState(selector: UseOnyxStateSelector, options: UseOnyxStat // 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 lastOutputRef = useRef<{value: T} | null>(null); + const lastSnapshotRef = useRef | null>(null); + + // A selector declared with only `state` never reads `previousState`, so a new view must not count as + // an input change for it. Length 0 (rest or unknown params) is treated as reading it. + const readsPreviousState = selector.length !== 1; const getSnapshot = useCallback((): T => { - const output = selector(liveStateView, previousViewRef.current); + const lastSnapshot = lastSnapshotRef.current; + const previousView = readsPreviousState ? previousViewRef.current : undefined; + + // 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. + if (lastSnapshot !== null && lastSnapshot.selector === selector && lastSnapshot.previousView === previousView && areDepValuesUnchanged(depsArray, lastSnapshot.depValues)) { + return lastSnapshot.value; + } + + const depValues = depsArray.map((dep) => onyxSubscriptionManager.getState(dep)); + const output = selector(liveStateView, previousView); // 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. - if (lastOutputRef.current !== null && selectorEquality(lastOutputRef.current.value, output)) { - return lastOutputRef.current.value; - } - - lastOutputRef.current = {value: output}; - return output; - }, [selector, selectorEquality]); + const value = lastSnapshot !== null && selectorEquality(lastSnapshot.value, output) ? lastSnapshot.value : output; + lastSnapshotRef.current = {value, selector, previousView, depValues}; + return value; + }, [selector, selectorEquality, depsArray, readsPreviousState]); const value = useSyncExternalStore(subscribe, getSnapshot); - // Snapshot the deps after each committed render, to serve as `previousState` on the next selector - // run. Doing it here rather than in `getSnapshot` keeps that function pure and gives + // 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. + // 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(() => { + if (!readsPreviousState) { + return; + } const currentDepValues: Record> = {}; for (const dep of depsArray) { currentDepValues[dep] = onyxSubscriptionManager.getState(dep); } previousViewRef.current = createStateView((key) => currentDepValues[key]); - }); + }, [value, depsArray, readsPreviousState]); return value; } diff --git a/tests/unit/useOnyxStateTest.tsx b/tests/unit/useOnyxStateTest.tsx index 051d79083..17180fdaf 100644 --- a/tests/unit/useOnyxStateTest.tsx +++ b/tests/unit/useOnyxStateTest.tsx @@ -1,11 +1,13 @@ 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 type {OnyxStateView} from '../../lib/useOnyxState'; import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; -import type GenericCollection from '../utils/GenericCollection'; const ONYXKEYS = { TEST_KEY: 'test', @@ -241,6 +243,50 @@ describe('useOnyxState', () => { }); }); + 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'); + }); + }); + 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. From bc647a210b9777b61310c6ed347d3d494e86a557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Sun, 1 Nov 2026 11:38:37 +0000 Subject: [PATCH 6/7] Detect previousState reads instead of guessing from selector arity The snapshot cache skipped `previousState` for any selector whose `Function.length` was 1. That misreads a selector written `(state, previousState = undefined) => ...`, because `length` stops counting at the first defaulted parameter: it reads `previousState` but would have been handed `undefined` on every run, with no error. The view itself now reports when the selector reads a key off it, and the cache records whether the run that produced the value did so. A run that ignored `previousState` can't be affected by it moving, so it still stays cached when only the view changed. That keeps the saving without inferring anything from the signature. Selector runs, measured on 20 parent re-renders with no store change: a `(state)` selector stays at 0, and a selector reading `previousState` goes from 2 to 0. Store writes and mount are unchanged at 10 and 1. Co-Authored-By: Claude Opus 5 (1M context) --- lib/useOnyxState.ts | 36 +++++++++++++++++++++------------ tests/unit/useOnyxStateTest.tsx | 16 +++++++++++++++ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/lib/useOnyxState.ts b/lib/useOnyxState.ts index a3778e0c6..0ee530f62 100644 --- a/lib/useOnyxState.ts +++ b/lib/useOnyxState.ts @@ -81,6 +81,8 @@ 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>; }; @@ -167,31 +169,39 @@ function useOnyxState(selector: UseOnyxStateSelector, options: UseOnyxStat const previousViewRef = useRef(undefined); const lastSnapshotRef = useRef | null>(null); - // A selector declared with only `state` never reads `previousState`, so a new view must not count as - // an input change for it. Length 0 (rest or unknown params) is treated as reading it. - const readsPreviousState = selector.length !== 1; + // 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 = readsPreviousState ? previousViewRef.current : undefined; + 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. - if (lastSnapshot !== null && lastSnapshot.selector === selector && lastSnapshot.previousView === previousView && areDepValuesUnchanged(depsArray, lastSnapshot.depValues)) { + // 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; const output = selector(liveStateView, previousView); + 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, depValues}; + lastSnapshotRef.current = {value, selector, previousView, readPreviousState, depValues}; return value; - }, [selector, selectorEquality, depsArray, readsPreviousState]); + }, [selector, selectorEquality, depsArray]); const value = useSyncExternalStore(subscribe, getSnapshot); @@ -201,15 +211,15 @@ function useOnyxState(selector: UseOnyxStateSelector, options: UseOnyxStat // 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(() => { - if (!readsPreviousState) { - return; - } const currentDepValues: Record> = {}; for (const dep of depsArray) { currentDepValues[dep] = onyxSubscriptionManager.getState(dep); } - previousViewRef.current = createStateView((key) => currentDepValues[key]); - }, [value, depsArray, readsPreviousState]); + previousViewRef.current = createStateView((key) => { + previousStateReadRef.current = true; + return currentDepValues[key]; + }); + }, [value, depsArray]); return value; } diff --git a/tests/unit/useOnyxStateTest.tsx b/tests/unit/useOnyxStateTest.tsx index 17180fdaf..38f54bfaf 100644 --- a/tests/unit/useOnyxStateTest.tsx +++ b/tests/unit/useOnyxStateTest.tsx @@ -285,6 +285,22 @@ describe('useOnyxState', () => { 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', () => { From 3025c82123e3a82e1b5469c4a3e8ec805c4ca5c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Sun, 1 Nov 2026 15:50:16 +0000 Subject: [PATCH 7/7] Give the selector only the keys its dependencies declared Dependencies were declared by hand and nothing checked them, and the snapshot cache made that worse: it skips the selector while every declared dep holds its reference, so a key read but not declared stays stale instead of being picked up by the next render. The selector's state and previousState are now typed from the dependency list, so reading anything else does not compile. Declaring a collection root still grants its members, since any member write fires the root. A dependency list that is not a literal, so there is nothing to narrow to, falls back to the full view. Co-Authored-By: Claude Opus 5 (1M context) --- lib/useOnyxState.ts | 45 ++++++++++++++++++++++++++++--------- tests/types/useOnyxState.ts | 41 ++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/lib/useOnyxState.ts b/lib/useOnyxState.ts index 0ee530f62..604198775 100644 --- a/lib/useOnyxState.ts +++ b/lib/useOnyxState.ts @@ -32,15 +32,38 @@ type OnyxStateView = { readonly [K in OnyxKey]: OnyxValue; } & OnyxCollectionRootsView; -type UseOnyxStateSelector = (state: OnyxStateView, previousState: OnyxStateView | undefined) => T; +/** Declaring a collection root also grants its members: any member write fires the root. */ +type DeclaredKeys = TDependency extends CollectionKeyBase ? TDependency | `${TDependency}${string}` : TDependency; -type UseOnyxStateOptions = { +/** + * 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. A key read but not listed can go stale, - * since the selector is skipped while the listed deps keep their references. + * 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: readonly OnyxKey[]; + dependencies: TDependencies; /** * Equality for the selector's output. Defaults to `===` with a deep-equal fallback for objects. @@ -77,9 +100,9 @@ function createStateView(snapshotProvider: (key: OnyxKey) => OnyxValue) const liveStateView = createStateView((key) => onyxSubscriptionManager.getState(key as OnyxKey)); -type SnapshotCache = { +type SnapshotCache = { value: T; - selector: UseOnyxStateSelector; + 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; @@ -126,7 +149,7 @@ function defaultSelectorEquality(a: T, b: T): boolean { * 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 { +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 @@ -167,7 +190,7 @@ function useOnyxState(selector: UseOnyxStateSelector, options: UseOnyxStat // 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); + 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. @@ -193,7 +216,9 @@ function useOnyxState(selector: UseOnyxStateSelector, options: UseOnyxStat const depValues = depsArray.map((dep) => onyxSubscriptionManager.getState(dep)); previousStateReadRef.current = false; - const output = selector(liveStateView, previousView); + // 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 `===` diff --git a/tests/types/useOnyxState.ts b/tests/types/useOnyxState.ts index 4d099ad59..b6a32eafe 100644 --- a/tests/types/useOnyxState.ts +++ b/tests/types/useOnyxState.ts @@ -81,4 +81,43 @@ function memberIsNotACollection() { ); } -export {readCollectionRoot, readCollectionMember, readSingleKey, readPreviousState, rootIsNotAMember, memberIsNotACollection}; +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, +};