From dd67718abe37c6d2f3b082d5fb409029547a33e0 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Tue, 14 Jul 2026 10:37:14 +0200 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 7a850e026a2d56d65781d427d9ec9f48c848f16d Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Wed, 15 Jul 2026 12:56:37 +0200 Subject: [PATCH 6/6] noop subscription instead of ref guard --- lib/useOnyx.ts | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 05ff3f575..32ee13fd0 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -1,6 +1,7 @@ import {deepEqual, shallowEqual} from 'fast-equals'; import {useCallback, useEffect, useMemo, useRef, useSyncExternalStore} from 'react'; import type {DependencyList} from 'react'; +import NOOP from 'lodash/noop'; import OnyxCache, {TASK} from './OnyxCache'; import type {Connection} from './OnyxConnectionManager'; import connectionManager from './OnyxConnectionManager'; @@ -52,12 +53,9 @@ function useOnyx>( const currentDependenciesRef = useLiveRef(dependencies); const selector = options?.selector; - // Read via a ref inside the Onyx callback so toggling `subscribed` never re-subscribes. + // TanStack `shouldSubscribe` parity: `subscribed` gates whether we open a live subscription at all. + // It's in the `subscribe` deps below, so flipping it re-runs subscribe (connect on resume, noop while paused). 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 => { @@ -177,7 +175,9 @@ function useOnyx>( // Check if we have any cache for this Onyx key // Don't use cache during active data updates (when shouldGetCachedValueRef is true) const isFirstConnection = connectedKeyRef.current !== key; - if (!shouldGetCachedValueRef.current) { + // TanStack `getCurrentResult` parity: while paused there is no live connection to invalidate the snapshot + // cache on writes, so we must never serve it — always recompute from the freshest Onyx cache value below. + if (subscribed && !shouldGetCachedValueRef.current) { const cachedResult = onyxSnapshotCache.getCachedResult>(key, cacheKey); if (cachedResult !== undefined) { resultRef.current = cachedResult; @@ -189,7 +189,7 @@ function useOnyx>( // so we can return any cached value right away. For the case where the key has changed, If we don't return the cached value right away, then the UI will show the incorrect (previous) value for a brief period which looks like a UI glitch to the user. After the connection is made, we only // update `newValueRef` when `Onyx.connect()` callback is fired. const hasSelectorChanged = lastComputedSelectorRef.current !== memoizedSelector; - if (isFirstConnection || shouldGetCachedValueRef.current || hasSelectorChanged) { + if (!subscribed || isFirstConnection || shouldGetCachedValueRef.current || hasSelectorChanged) { // Gets the value from cache and maps it with selector. It changes `null` to `undefined` for `useOnyx` compatibility. const value = OnyxUtils.tryGetCachedValue(key) as OnyxValue; const selectedValue = memoizedSelector ? memoizedSelector(value) : value; @@ -245,10 +245,17 @@ function useOnyx>( } return resultRef.current; - }, [key, memoizedSelector, cacheKey]); + }, [key, memoizedSelector, cacheKey, subscribed]); const subscribe = useCallback( (onStoreChange: () => void) => { + // TanStack `subscribed: false` parity: don't wire a live subscription while paused. getSnapshot still + // serves the current cache value on any render, and flipping `subscribed` back to true re-runs this + // callback (it's in the deps) and re-renders with the latest value. + if (!subscribed) { + return NOOP + } + // Reset internal state so the hook properly transitions through loading // for the new key instead of preserving stale state from the previous one. // Only reset when the key has actually changed (not on initial mount). @@ -280,11 +287,7 @@ 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) { - onStoreChange(); - } + onStoreChange(); }, reuseConnection: options?.reuseConnection, }); @@ -300,7 +303,7 @@ function useOnyx>( onStoreChangeFnRef.current = null; }; }, - [key, options?.reuseConnection], + [key, options?.reuseConnection, subscribed], ); const result = useSyncExternalStore>(subscribe, getSnapshot); @@ -311,3 +314,4 @@ function useOnyx>( export default useOnyx; export type {FetchStatus, ResultMetadata, UseOnyxResult, UseOnyxOptions, UseOnyxSelector}; +