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 { diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 6d4f8cd25..3cecc9021 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; @@ -26,6 +29,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`, 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; }; type FetchStatus = 'loading' | 'loaded'; @@ -45,6 +55,21 @@ function useOnyx>( const currentDependenciesRef = useLiveRef(dependencies); const selector = options?.selector; + // 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; + + // 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?.(); + } + }, [subscribed]); + // Create memoized version of selector for performance const memoizedSelector = useMemo((): UseOnyxSelector | null => { if (!selector) { @@ -265,8 +290,11 @@ 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. - onStoreChange(); + // 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(); + } }, reuseConnection: options?.reuseConnection, }); diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index d5b9d0017..cbd37f4d3 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,232 @@ describe('useOnyx', () => { expect(renders.length).toBe(3); }); }); + + describe('subscribed option', () => { + type SubscribedProps = {subscribed?: boolean; tick?: number}; + + 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'); + }); + + 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'); + }); + + // 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'); + + // 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}); + }); + + expect(result.current[0]).toEqual('base-B'); + }); + + 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}); + }); + + 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); + }); + + 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 + + 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'); + }); + + // 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'); + + 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); + }); + + 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'); + }); + }); });