From dd67718abe37c6d2f3b082d5fb409029547a33e0 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Tue, 14 Jul 2026 10:37:14 +0200 Subject: [PATCH 01/11] Add subscribed option --- lib/useOnyx.ts | 25 +++++- tests/unit/useOnyxTest.ts | 159 +++++++++++++++++++++++++++++++++++++- 2 files changed, 179 insertions(+), 5 deletions(-) diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 6d4f8cd25..2298b4d06 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -26,6 +26,13 @@ type UseOnyxOptions = { * @see `useOnyx` cannot return `null` and so selector will replace `null` with `undefined` to maintain compatibility. */ selector?: UseOnyxSelector; + + /** + * Defaults to `true`. When `false`, keeps the connection open (value stays cache-warm) but stops + * re-rendering on background writes. It defers the render trigger, not the value: any other render still reads the latest value. + * Flipping back to `true` re-renders. + */ + subscribed?: boolean; }; type FetchStatus = 'loading' | 'loaded'; @@ -45,6 +52,13 @@ function useOnyx>( const currentDependenciesRef = useLiveRef(dependencies); const selector = options?.selector; + // Read via a ref inside the Onyx callback so toggling `subscribed` never re-subscribes. + const subscribed = options?.subscribed !== false; + const subscribedRef = useRef(subscribed); + useEffect(() => { + subscribedRef.current = subscribed; + }, [subscribed]); + // Create memoized version of selector for performance const memoizedSelector = useMemo((): UseOnyxSelector | null => { if (!selector) { @@ -150,7 +164,10 @@ function useOnyx>( // Invalidate cache when dependencies change so selector runs with new closure values onyxSnapshotCache.invalidateForKey(key); shouldGetCachedValueRef.current = true; - onStoreChangeFnRef.current(); + // Skip the re-render while paused; the next render picks up the new dependencies via `getSnapshot()`. + if (subscribedRef.current) { + onStoreChangeFnRef.current(); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [...dependencies]); @@ -266,7 +283,11 @@ function useOnyx>( onyxSnapshotCache.invalidateForKey(key); // Finally, we signal that the store changed, making `getSnapshot()` be called again. - onStoreChange(); + // Skipped while paused so background writes don't re-render; the freshest value is still + // read via `getSnapshot()` on the next render. + if (subscribedRef.current) { + onStoreChange(); + } }, reuseConnection: options?.reuseConnection, }); diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index d5b9d0017..dc6a47739 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -1,11 +1,13 @@ import {act, renderHook} from '@testing-library/react-native'; + import type {OnyxCollection, OnyxEntry, OnyxKey} from '../../lib'; +import type {UseOnyxSelector} from '../../lib/useOnyx'; +import type GenericCollection from '../utils/GenericCollection'; + import Onyx, {useOnyx} from '../../lib'; +import onyxSnapshotCache from '../../lib/OnyxSnapshotCache'; import StorageMock from '../../lib/storage'; -import type GenericCollection from '../utils/GenericCollection'; import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; -import onyxSnapshotCache from '../../lib/OnyxSnapshotCache'; -import type {UseOnyxSelector} from '../../lib/useOnyx'; const ONYXKEYS = { TEST_KEY: 'test', @@ -1318,4 +1320,155 @@ describe('useOnyx', () => { expect(renders.length).toBe(3); }); }); + + describe('subscribed option', () => { + type SubscribedProps = {subscribed?: boolean; tick?: number}; + + // While subscribed is false, a background write should not re-render the consumer. + it('does not re-render on a background write when subscribed is false', async () => { + await Onyx.set(ONYXKEYS.TEST_KEY, 'v1'); + + const renders: Array<{value: unknown; status: string}> = []; + const {result} = renderHook( + ({subscribed}: SubscribedProps) => { + const r = useOnyx(ONYXKEYS.TEST_KEY, {subscribed}); + renders.push({value: r[0], status: r[1].status}); + return r; + }, + {initialProps: {subscribed: false}}, + ); + + // Mount reads the warm value straight from cache + await act(async () => waitForPromisesToResolve()); + expect(result.current[0]).toEqual('v1'); + expect(renders.length).toBe(1); + + // Background write while paused — connection stays open but onStoreChange is gated + await act(async () => { + Onyx.merge(ONYXKEYS.TEST_KEY, 'v2'); + await waitForPromisesToResolve(); + }); + + // No extra render, and the value is intentionally still the old one + expect(renders.length).toBe(1); + expect(result.current[0]).toEqual('v1'); + }); + + // A render from any other cause while paused should serve the latest value, not a stale snapshot. + // Keeping the connection open and invalidating on each write is what makes this pass. + it('serves the latest value on an unrelated re-render while subscribed is false', async () => { + await Onyx.set(ONYXKEYS.TEST_KEY, 'v1'); + + const {result, rerender} = renderHook(({subscribed}: SubscribedProps) => useOnyx(ONYXKEYS.TEST_KEY, {subscribed}), { + initialProps: {subscribed: false, tick: 0} as SubscribedProps, + }); + + await act(async () => waitForPromisesToResolve()); + expect(result.current[0]).toEqual('v1'); + + // Write while paused — no re-render from Onyx + await act(async () => { + Onyx.merge(ONYXKEYS.TEST_KEY, 'v2'); + await waitForPromisesToResolve(); + }); + expect(result.current[0]).toEqual('v1'); // Not yet re-rendered + + // Force an unrelated re-render — subscribed stays false, only tick changes + await act(async () => { + rerender({subscribed: false, tick: 1}); + }); + + // getSnapshot should read fresh: v2, not the stale v1 + expect(result.current[0]).toEqual('v2'); + }); + + // Flipping subscribed from false to true (re-focus) re-renders with the latest value, and a warm + // key shows 'loaded' immediately without a loading flash. + it('catches up to the latest value with no loading flash when flipped back to subscribed', async () => { + await Onyx.set(ONYXKEYS.TEST_KEY, 'v1'); + + const {result, rerender} = renderHook(({subscribed}: SubscribedProps) => useOnyx(ONYXKEYS.TEST_KEY, {subscribed}), {initialProps: {subscribed: false} as SubscribedProps}); + + await act(async () => waitForPromisesToResolve()); + expect(result.current[0]).toEqual('v1'); + + await act(async () => { + Onyx.merge(ONYXKEYS.TEST_KEY, 'v2'); + await waitForPromisesToResolve(); + }); + expect(result.current[0]).toEqual('v1'); // Paused: still stale + + // Re-focus + await act(async () => { + rerender({subscribed: true}); + }); + + expect(result.current[0]).toEqual('v2'); + expect(result.current[1].status).toEqual('loaded'); + + // Once subscribed again, later writes re-render + await act(async () => { + Onyx.merge(ONYXKEYS.TEST_KEY, 'v3'); + await waitForPromisesToResolve(); + }); + expect(result.current[0]).toEqual('v3'); + }); + + // Default (true) is unchanged: writes re-render as before. + it('re-renders on background writes when subscribed is omitted (default true)', async () => { + await Onyx.set(ONYXKEYS.TEST_KEY, 'v1'); + + const renders: Array<{value: unknown; status: string}> = []; + const {result} = renderHook(() => { + const r = useOnyx(ONYXKEYS.TEST_KEY); + renders.push({value: r[0], status: r[1].status}); + return r; + }); + + await act(async () => waitForPromisesToResolve()); + const rendersAfterMount = renders.length; + + await act(async () => { + Onyx.merge(ONYXKEYS.TEST_KEY, 'v2'); + await waitForPromisesToResolve(); + }); + + expect(result.current[0]).toEqual('v2'); + expect(renders.length).toBeGreaterThan(rendersAfterMount); + }); + + // With two subscribers on the same key, pausing one should not stop the other from re-rendering. + it('isolates paused/active subscribers sharing a connection (reuseConnection)', async () => { + await Onyx.set(ONYXKEYS.TEST_KEY, 'v1'); + + const activeRenders: unknown[] = []; + const pausedRenders: unknown[] = []; + + const active = renderHook(() => { + const r = useOnyx(ONYXKEYS.TEST_KEY, {reuseConnection: true}); + activeRenders.push(r[0]); + return r; + }); + const paused = renderHook(() => { + const r = useOnyx(ONYXKEYS.TEST_KEY, {reuseConnection: true, subscribed: false}); + pausedRenders.push(r[0]); + return r; + }); + + await act(async () => waitForPromisesToResolve()); + const activeAfterMount = activeRenders.length; + const pausedAfterMount = pausedRenders.length; + + await act(async () => { + Onyx.merge(ONYXKEYS.TEST_KEY, 'v2'); + await waitForPromisesToResolve(); + }); + + // Active subscriber re-rendered to the new value; paused one did not re-render at all + expect(active.result.current[0]).toEqual('v2'); + expect(activeRenders.length).toBeGreaterThan(activeAfterMount); + expect(pausedRenders.length).toBe(pausedAfterMount); + expect(paused.result.current[0]).toEqual('v1'); + }); + }); }); From f4bd80c507421b29ec7a5ebf7a01a8a000c3e3a3 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Tue, 14 Jul 2026 10:42:11 +0200 Subject: [PATCH 02/11] update docs --- API.md | 2 ++ lib/Onyx.ts | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/API.md b/API.md index e766e551f..6e0237151 100644 --- a/API.md +++ b/API.md @@ -81,6 +81,7 @@ This method will be deprecated soon. Please use `Onyx.connectWithoutView()` inst | connectOptions.key | The Onyx key to subscribe to. | | connectOptions.callback | A function that will be called when the Onyx data we are subscribed changes. | | connectOptions.selector | This will be used to subscribe to a subset of an Onyx key's data. **Only used inside `useOnyx()` hook.** Using this setting on `useOnyx()` can have very positive performance benefits because the component will only re-render when the subset of data changes. Otherwise, any change of data on any property would normally cause the component to re-render (and that can be expensive from a performance standpoint). | +| connectOptions.subscribed | Defaults to `true`. **Only used inside `useOnyx()` hook.** When `false`, keeps the connection open (value stays cache-warm) but stops re-rendering on background writes. It defers the render trigger, not the value: any other render still reads the latest value, and flipping back to `true` re-renders. | **Example** ```ts @@ -103,6 +104,7 @@ Connects to an Onyx key given the options passed and listens to its changes. | connectOptions.key | The Onyx key to subscribe to. | | connectOptions.callback | A function that will be called when the Onyx data we are subscribed changes. | | connectOptions.selector | This will be used to subscribe to a subset of an Onyx key's data. **Only used inside `useOnyx()` hook.** Using this setting on `useOnyx()` can have very positive performance benefits because the component will only re-render when the subset of data changes. Otherwise, any change of data on any property would normally cause the component to re-render (and that can be expensive from a performance standpoint). | +| connectOptions.subscribed | Defaults to `true`. **Only used inside `useOnyx()` hook.** When `false`, keeps the connection open (value stays cache-warm) but stops re-rendering on background writes. It defers the render trigger, not the value: any other render still reads the latest value, and flipping back to `true` re-renders. | **Example** ```ts diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 16a0f6ec6..ffaf33b30 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -98,6 +98,9 @@ function init({ * Using this setting on `useOnyx()` can have very positive performance benefits because the component will only re-render * when the subset of data changes. Otherwise, any change of data on any property would normally * cause the component to re-render (and that can be expensive from a performance standpoint). + * @param connectOptions.subscribed Defaults to `true`. **Only used inside `useOnyx()` hook.** When `false`, keeps the connection open + * (value stays cache-warm) but stops re-rendering on background writes. It defers the render trigger, not the value: any other + * render still reads the latest value, and flipping back to `true` re-renders. * @returns The connection object to use when calling `Onyx.disconnect()`. */ function connect(connectOptions: ConnectOptions): Connection { @@ -122,6 +125,9 @@ function connect(connectOptions: ConnectOptions): Co * Using this setting on `useOnyx()` can have very positive performance benefits because the component will only re-render * when the subset of data changes. Otherwise, any change of data on any property would normally * cause the component to re-render (and that can be expensive from a performance standpoint). + * @param connectOptions.subscribed Defaults to `true`. **Only used inside `useOnyx()` hook.** When `false`, keeps the connection open + * (value stays cache-warm) but stops re-rendering on background writes. It defers the render trigger, not the value: any other + * render still reads the latest value, and flipping back to `true` re-renders. * @returns The connection object to use when calling `Onyx.disconnect()`. */ function connectWithoutView(connectOptions: ConnectOptions): Connection { From 76830e4e9012a6dfd66d97faf36d7ec27a59cf74 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Tue, 14 Jul 2026 10:54:39 +0200 Subject: [PATCH 03/11] restore import order --- tests/unit/useOnyxTest.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index dc6a47739..7733028fc 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -1,13 +1,11 @@ import {act, renderHook} from '@testing-library/react-native'; - import type {OnyxCollection, OnyxEntry, OnyxKey} from '../../lib'; -import type {UseOnyxSelector} from '../../lib/useOnyx'; -import type GenericCollection from '../utils/GenericCollection'; - import Onyx, {useOnyx} from '../../lib'; -import onyxSnapshotCache from '../../lib/OnyxSnapshotCache'; import StorageMock from '../../lib/storage'; +import type GenericCollection from '../utils/GenericCollection'; import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; +import onyxSnapshotCache from '../../lib/OnyxSnapshotCache'; +import type {UseOnyxSelector} from '../../lib/useOnyx'; const ONYXKEYS = { TEST_KEY: 'test', From 9e6e74e41e1f77272ae958126a38a1c23938863a Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Tue, 14 Jul 2026 10:56:56 +0200 Subject: [PATCH 04/11] prettier fix --- lib/useOnyx.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 2298b4d06..66d704aa0 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -29,7 +29,7 @@ type UseOnyxOptions = { /** * Defaults to `true`. When `false`, keeps the connection open (value stays cache-warm) but stops - * re-rendering on background writes. It defers the render trigger, not the value: any other render still reads the latest value. + * re-rendering on background writes. It defers the render trigger, not the value: any other render still reads the latest value. * Flipping back to `true` re-renders. */ subscribed?: boolean; From 9b8bcea76eeb0b6a37d316e5d2ec2c6f332d06b8 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Wed, 15 Jul 2026 10:44:52 +0200 Subject: [PATCH 05/11] remove subscribed guard on subscribedRef --- lib/useOnyx.ts | 5 +---- tests/unit/useOnyxTest.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 66d704aa0..05ff3f575 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -164,10 +164,7 @@ function useOnyx>( // Invalidate cache when dependencies change so selector runs with new closure values onyxSnapshotCache.invalidateForKey(key); shouldGetCachedValueRef.current = true; - // Skip the re-render while paused; the next render picks up the new dependencies via `getSnapshot()`. - if (subscribedRef.current) { - onStoreChangeFnRef.current(); - } + onStoreChangeFnRef.current(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [...dependencies]); diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index 7733028fc..fe3dff758 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -1380,6 +1380,38 @@ describe('useOnyx', () => { expect(result.current[0]).toEqual('v2'); }); + // A dependencies change is consumer-driven, not a background write, so subscribed: false must not defer + // it. Uses a stable selector whose output depends on an external value fed via `dependencies` — the only + // shape where the deps-effect notify is load-bearing (getSnapshot's hasSelectorChanged can't recompute it). + it('applies a dependencies change while subscribed is false', async () => { + await Onyx.set(ONYXKEYS.TEST_KEY, 'base'); + + // Stable selector reference; its output closes over `dep`, signalled via `dependencies` + let dep = 'A'; + const selector = (value: unknown) => `${value as string}-${dep}`; + + // `dependencies` is [dep] only; `subscribed` is a prop purely to force re-renders + const {result, rerender} = renderHook(({subscribed}: SubscribedProps) => useOnyx(ONYXKEYS.TEST_KEY, {subscribed, selector}, [dep]), { + initialProps: {subscribed: false} as SubscribedProps, + }); + + await act(async () => waitForPromisesToResolve()); + expect(result.current[0]).toEqual('base-A'); + + // Warm-up re-render (dep unchanged) to clear the "read fresh from cache" flag the connect callback left set + await act(async () => rerender({subscribed: false})); + expect(result.current[0]).toEqual('base-A'); + + // Change the dependency while paused — the Onyx value is untouched, so the deps change is the only signal + await act(async () => { + dep = 'B'; + rerender({subscribed: false}); + }); + + // getSnapshot should recompute with the new dependency: base-B, not the stale base-A + expect(result.current[0]).toEqual('base-B'); + }); + // Flipping subscribed from false to true (re-focus) re-renders with the latest value, and a warm // key shows 'loaded' immediately without a loading flash. it('catches up to the latest value with no loading flash when flipped back to subscribed', async () => { From 52c7e8f3cee7f520c5abe6bdfb15161f81be56d4 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Wed, 15 Jul 2026 14:49:43 +0200 Subject: [PATCH 06/11] fix initial edge case --- lib/useOnyx.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 05ff3f575..702888a4d 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -1,12 +1,15 @@ +import type {DependencyList} from 'react'; + import {deepEqual, shallowEqual} from 'fast-equals'; import {useCallback, useEffect, useMemo, useRef, useSyncExternalStore} from 'react'; -import type {DependencyList} from 'react'; -import OnyxCache, {TASK} from './OnyxCache'; + import type {Connection} from './OnyxConnectionManager'; -import connectionManager from './OnyxConnectionManager'; -import OnyxUtils from './OnyxUtils'; import type {CollectionKeyBase, OnyxKey, OnyxValue} from './types'; + +import OnyxCache, {TASK} from './OnyxCache'; +import connectionManager from './OnyxConnectionManager'; import onyxSnapshotCache from './OnyxSnapshotCache'; +import OnyxUtils from './OnyxUtils'; import useLiveRef from './useLiveRef'; type UseOnyxSelector> = (data: OnyxValue | undefined) => TReturnValue; @@ -280,9 +283,11 @@ function useOnyx>( onyxSnapshotCache.invalidateForKey(key); // Finally, we signal that the store changed, making `getSnapshot()` be called again. - // Skipped while paused so background writes don't re-render; the freshest value is still - // read via `getSnapshot()` on the next render. - if (subscribedRef.current) { + // Background writes are skipped while paused so they don't re-render; the freshest value is + // still read via `getSnapshot()` on the next render. The INITIAL load is always delivered + // though (status still 'loading'), otherwise a cold key mounted with `subscribed: false` would + // stay stuck at 'loading' until some unrelated render — only subsequent updates should pause. + if (subscribedRef.current || resultRef.current?.[1]?.status === 'loading') { onStoreChange(); } }, From e12bd8663b3eb95b73aad685644336c2213f2c33 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Wed, 15 Jul 2026 15:25:03 +0200 Subject: [PATCH 07/11] use useLiveRef for subscribedRef --- lib/useOnyx.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 702888a4d..569da1dad 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -55,12 +55,10 @@ function useOnyx>( const currentDependenciesRef = useLiveRef(dependencies); const selector = options?.selector; - // Read via a ref inside the Onyx callback so toggling `subscribed` never re-subscribes. + // Read via a ref inside the Onyx callback so toggling `subscribed` never re-subscribes. Synced during + // render (not in an effect) so a `false`→`true` flip can't miss a write that lands before effects run. const subscribed = options?.subscribed !== false; - const subscribedRef = useRef(subscribed); - useEffect(() => { - subscribedRef.current = subscribed; - }, [subscribed]); + const subscribedRef = useLiveRef(subscribed); // Create memoized version of selector for performance const memoizedSelector = useMemo((): UseOnyxSelector | null => { @@ -282,11 +280,8 @@ function useOnyx>( // Invalidate snapshot cache for this key when data changes onyxSnapshotCache.invalidateForKey(key); - // Finally, we signal that the store changed, making `getSnapshot()` be called again. - // Background writes are skipped while paused so they don't re-render; the freshest value is - // still read via `getSnapshot()` on the next render. The INITIAL load is always delivered - // though (status still 'loading'), otherwise a cold key mounted with `subscribed: false` would - // stay stuck at 'loading' until some unrelated render — only subsequent updates should pause. + // Trigger a re-render, except for paused background writes. The initial load is never paused + // though, otherwise a cold `subscribed: false` key would stay stuck 'loading' until some render. if (subscribedRef.current || resultRef.current?.[1]?.status === 'loading') { onStoreChange(); } @@ -305,7 +300,7 @@ function useOnyx>( onStoreChangeFnRef.current = null; }; }, - [key, options?.reuseConnection], + [key, options?.reuseConnection, subscribedRef], ); const result = useSyncExternalStore>(subscribe, getSnapshot); From b51368c6d2b3158abfbf8c244d839df589f6461e Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Wed, 15 Jul 2026 15:37:55 +0200 Subject: [PATCH 08/11] restore useEffect --- lib/useOnyx.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 569da1dad..507700e8f 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -55,10 +55,12 @@ function useOnyx>( const currentDependenciesRef = useLiveRef(dependencies); const selector = options?.selector; - // Read via a ref inside the Onyx callback so toggling `subscribed` never re-subscribes. Synced during - // render (not in an effect) so a `false`→`true` flip can't miss a write that lands before effects run. + // Read via a ref inside the Onyx callback so toggling `subscribed` never re-subscribes. const subscribed = options?.subscribed !== false; - const subscribedRef = useLiveRef(subscribed); + const subscribedRef = useRef(subscribed); + useEffect(() => { + subscribedRef.current = subscribed; + }, [subscribed]); // Create memoized version of selector for performance const memoizedSelector = useMemo((): UseOnyxSelector | null => { @@ -300,7 +302,7 @@ function useOnyx>( onStoreChangeFnRef.current = null; }; }, - [key, options?.reuseConnection, subscribedRef], + [key, options?.reuseConnection], ); const result = useSyncExternalStore>(subscribe, getSnapshot); From 13f825e360fe4634fe9de314e9a7e3914a957382 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Wed, 15 Jul 2026 15:42:00 +0200 Subject: [PATCH 09/11] update unit tests --- tests/unit/useOnyxTest.ts | 46 ++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index fe3dff758..fc7adb5fa 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -1,11 +1,13 @@ import {act, renderHook} from '@testing-library/react-native'; + import type {OnyxCollection, OnyxEntry, OnyxKey} from '../../lib'; +import type {UseOnyxSelector} from '../../lib/useOnyx'; +import type GenericCollection from '../utils/GenericCollection'; + import Onyx, {useOnyx} from '../../lib'; +import onyxSnapshotCache from '../../lib/OnyxSnapshotCache'; import StorageMock from '../../lib/storage'; -import type GenericCollection from '../utils/GenericCollection'; import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; -import onyxSnapshotCache from '../../lib/OnyxSnapshotCache'; -import type {UseOnyxSelector} from '../../lib/useOnyx'; const ONYXKEYS = { TEST_KEY: 'test', @@ -1352,8 +1354,7 @@ describe('useOnyx', () => { expect(result.current[0]).toEqual('v1'); }); - // A render from any other cause while paused should serve the latest value, not a stale snapshot. - // Keeping the connection open and invalidating on each write is what makes this pass. + // A render from any other cause while paused serves the latest value, not a stale snapshot. it('serves the latest value on an unrelated re-render while subscribed is false', async () => { await Onyx.set(ONYXKEYS.TEST_KEY, 'v1'); @@ -1380,9 +1381,7 @@ describe('useOnyx', () => { expect(result.current[0]).toEqual('v2'); }); - // A dependencies change is consumer-driven, not a background write, so subscribed: false must not defer - // it. Uses a stable selector whose output depends on an external value fed via `dependencies` — the only - // shape where the deps-effect notify is load-bearing (getSnapshot's hasSelectorChanged can't recompute it). + // A dependencies change is consumer-driven, not a background write, so subscribed: false must not defer it. it('applies a dependencies change while subscribed is false', async () => { await Onyx.set(ONYXKEYS.TEST_KEY, 'base'); @@ -1412,8 +1411,35 @@ describe('useOnyx', () => { expect(result.current[0]).toEqual('base-B'); }); - // Flipping subscribed from false to true (re-focus) re-renders with the latest value, and a warm - // key shows 'loaded' immediately without a loading flash. + // A cold key mounted with subscribed: false must still complete its initial load (only later writes pause). + it('delivers the initial load for a cold key even while subscribed is false', async () => { + await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'storage_value'); + + let renderCount = 0; + const {result} = renderHook(() => { + renderCount++; + return useOnyx(ONYXKEYS.TEST_KEY, {subscribed: false}); + }); + + // Nothing in cache yet → starts loading. + expect(result.current[1].status).toEqual('loading'); + + // The initial load is delivered while paused — no refocus, no unrelated render needed. + await act(async () => waitForPromisesToResolve()); + expect(result.current[0]).toEqual('storage_value'); + expect(result.current[1].status).toEqual('loaded'); + + // A SUBSEQUENT background write is still suppressed while paused (no re-render, value unchanged). + const rendersAfterLoad = renderCount; + await act(async () => { + Onyx.merge(ONYXKEYS.TEST_KEY, 'updated_value'); + await waitForPromisesToResolve(); + }); + expect(result.current[0]).toEqual('storage_value'); + expect(renderCount).toBe(rendersAfterLoad); + }); + + // Flipping back to subscribed re-renders with the latest value; a warm key shows 'loaded' with no flash. it('catches up to the latest value with no loading flash when flipped back to subscribed', async () => { await Onyx.set(ONYXKEYS.TEST_KEY, 'v1'); From c034d60402b18acb9647ba432f5391a211d0cab0 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 16 Jul 2026 10:00:31 +0200 Subject: [PATCH 10/11] add Catch-up for the commit effect gap --- lib/useOnyx.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 507700e8f..78f6e412f 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -60,6 +60,15 @@ function useOnyx>( const subscribedRef = useRef(subscribed); useEffect(() => { subscribedRef.current = subscribed; + + // Catch-up for the commit→effect gap: a write can land after the `false`→`true` flip render commits + // but before this effect syncs the ref, and the still-stale ref gates its `onStoreChange()`. The gated + // callback leaves `shouldGetCachedValueRef` set, so deliver it now (mirrors TanStack's + // `observer.updateResult()` after subscribing). No-op when nothing was gated: `getSnapshot()` then + // returns the same cached reference and `useSyncExternalStore` bails out. + if (subscribed && shouldGetCachedValueRef.current) { + onStoreChangeFnRef.current?.(); + } }, [subscribed]); // Create memoized version of selector for performance From 578edade372c71a256fc6505070027d8effa73bb Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 16 Jul 2026 12:46:08 +0200 Subject: [PATCH 11/11] update tests and comments --- lib/useOnyx.ts | 21 ++++++++++---------- tests/unit/useOnyxTest.ts | 41 +++++++++++++++++++++++++++++---------- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 78f6e412f..3cecc9021 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -31,9 +31,9 @@ type UseOnyxOptions = { selector?: UseOnyxSelector; /** - * Defaults to `true`. When `false`, keeps the connection open (value stays cache-warm) but stops - * re-rendering on background writes. It defers the render trigger, not the value: any other render still reads the latest value. - * Flipping back to `true` re-renders. + * Defaults to `true`. When `false`, the connection stays open (the value stays cache-warm) but background + * writes no longer trigger a re-render. Only the render trigger is deferred, not the value: any render still + * reads the latest value, and flipping back to `true` re-renders with it. */ subscribed?: boolean; }; @@ -55,17 +55,16 @@ function useOnyx>( const currentDependenciesRef = useLiveRef(dependencies); const selector = options?.selector; - // Read via a ref inside the Onyx callback so toggling `subscribed` never re-subscribes. + // The Onyx callback reads `subscribed` via a ref so toggling it never re-subscribes. The ref is synced in an + // effect so the gate only ever reflects committed renders. const subscribed = options?.subscribed !== false; const subscribedRef = useRef(subscribed); useEffect(() => { subscribedRef.current = subscribed; - // Catch-up for the commit→effect gap: a write can land after the `false`→`true` flip render commits - // but before this effect syncs the ref, and the still-stale ref gates its `onStoreChange()`. The gated - // callback leaves `shouldGetCachedValueRef` set, so deliver it now (mirrors TanStack's - // `observer.updateResult()` after subscribing). No-op when nothing was gated: `getSnapshot()` then - // returns the same cached reference and `useSyncExternalStore` bails out. + // A write can land between the flip render (`false` to `true`) and this effect, while the stale ref still + // gates it. The gated callback leaves `shouldGetCachedValueRef` set, so deliver it now. No-op otherwise: + // `getSnapshot()` returns the same cached reference and `useSyncExternalStore` bails out. if (subscribed && shouldGetCachedValueRef.current) { onStoreChangeFnRef.current?.(); } @@ -291,8 +290,8 @@ function useOnyx>( // Invalidate snapshot cache for this key when data changes onyxSnapshotCache.invalidateForKey(key); - // Trigger a re-render, except for paused background writes. The initial load is never paused - // though, otherwise a cold `subscribed: false` key would stay stuck 'loading' until some render. + // Trigger a re-render unless paused. The initial load is never paused — gating it would leave + // a cold `subscribed: false` key stuck at 'loading' until some unrelated render. if (subscribedRef.current || resultRef.current?.[1]?.status === 'loading') { onStoreChange(); } diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index fc7adb5fa..cbd37f4d3 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -1324,7 +1324,6 @@ describe('useOnyx', () => { describe('subscribed option', () => { type SubscribedProps = {subscribed?: boolean; tick?: number}; - // While subscribed is false, a background write should not re-render the consumer. it('does not re-render on a background write when subscribed is false', async () => { await Onyx.set(ONYXKEYS.TEST_KEY, 'v1'); @@ -1354,7 +1353,6 @@ describe('useOnyx', () => { expect(result.current[0]).toEqual('v1'); }); - // A render from any other cause while paused serves the latest value, not a stale snapshot. it('serves the latest value on an unrelated re-render while subscribed is false', async () => { await Onyx.set(ONYXKEYS.TEST_KEY, 'v1'); @@ -1407,11 +1405,9 @@ describe('useOnyx', () => { rerender({subscribed: false}); }); - // getSnapshot should recompute with the new dependency: base-B, not the stale base-A expect(result.current[0]).toEqual('base-B'); }); - // A cold key mounted with subscribed: false must still complete its initial load (only later writes pause). it('delivers the initial load for a cold key even while subscribed is false', async () => { await StorageMock.setItem(ONYXKEYS.TEST_KEY, 'storage_value'); @@ -1421,7 +1417,6 @@ describe('useOnyx', () => { return useOnyx(ONYXKEYS.TEST_KEY, {subscribed: false}); }); - // Nothing in cache yet → starts loading. expect(result.current[1].status).toEqual('loading'); // The initial load is delivered while paused — no refocus, no unrelated render needed. @@ -1429,7 +1424,7 @@ describe('useOnyx', () => { expect(result.current[0]).toEqual('storage_value'); expect(result.current[1].status).toEqual('loaded'); - // A SUBSEQUENT background write is still suppressed while paused (no re-render, value unchanged). + // A subsequent background write is still suppressed while paused (no re-render, value unchanged). const rendersAfterLoad = renderCount; await act(async () => { Onyx.merge(ONYXKEYS.TEST_KEY, 'updated_value'); @@ -1439,7 +1434,6 @@ describe('useOnyx', () => { expect(renderCount).toBe(rendersAfterLoad); }); - // Flipping back to subscribed re-renders with the latest value; a warm key shows 'loaded' with no flash. it('catches up to the latest value with no loading flash when flipped back to subscribed', async () => { await Onyx.set(ONYXKEYS.TEST_KEY, 'v1'); @@ -1454,7 +1448,6 @@ describe('useOnyx', () => { }); expect(result.current[0]).toEqual('v1'); // Paused: still stale - // Re-focus await act(async () => { rerender({subscribed: true}); }); @@ -1470,7 +1463,36 @@ describe('useOnyx', () => { expect(result.current[0]).toEqual('v3'); }); - // Default (true) is unchanged: writes re-render as before. + // The production pattern (`subscribed: isFocused`) mounts subscribed and blurs later, so gating must + // engage via the ref sync on the flip — mounting already-paused (covered above) doesn't exercise it. + it('stops re-rendering on background writes after flipping subscribed to false', async () => { + await Onyx.set(ONYXKEYS.TEST_KEY, 'v1'); + + const renders: unknown[] = []; + const {result, rerender} = renderHook( + ({subscribed}: SubscribedProps) => { + const r = useOnyx(ONYXKEYS.TEST_KEY, {subscribed}); + renders.push(r[0]); + return r; + }, + {initialProps: {subscribed: true} as SubscribedProps}, + ); + + await act(async () => waitForPromisesToResolve()); + expect(result.current[0]).toEqual('v1'); + + await act(async () => rerender({subscribed: false})); + const rendersAfterFlip = renders.length; + + await act(async () => { + Onyx.merge(ONYXKEYS.TEST_KEY, 'v2'); + await waitForPromisesToResolve(); + }); + + expect(renders.length).toBe(rendersAfterFlip); + expect(result.current[0]).toEqual('v1'); + }); + it('re-renders on background writes when subscribed is omitted (default true)', async () => { await Onyx.set(ONYXKEYS.TEST_KEY, 'v1'); @@ -1493,7 +1515,6 @@ describe('useOnyx', () => { expect(renders.length).toBeGreaterThan(rendersAfterMount); }); - // With two subscribers on the same key, pausing one should not stop the other from re-rendering. it('isolates paused/active subscribers sharing a connection (reuseConnection)', async () => { await Onyx.set(ONYXKEYS.TEST_KEY, 'v1');