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..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'; @@ -26,6 +27,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 +53,10 @@ function useOnyx>( const currentDependenciesRef = useLiveRef(dependencies); const selector = options?.selector; + // 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; + // Create memoized version of selector for performance const memoizedSelector = useMemo((): UseOnyxSelector | null => { if (!selector) { @@ -163,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; @@ -175,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; @@ -231,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). @@ -282,7 +303,7 @@ function useOnyx>( onStoreChangeFnRef.current = null; }; }, - [key, options?.reuseConnection], + [key, options?.reuseConnection, subscribed], ); const result = useSyncExternalStore>(subscribe, getSnapshot); @@ -293,3 +314,4 @@ function useOnyx>( export default useOnyx; export type {FetchStatus, ResultMetadata, UseOnyxResult, UseOnyxOptions, UseOnyxSelector}; + diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index d5b9d0017..fe3dff758 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -1318,4 +1318,187 @@ 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'); + }); + + // 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 () => { + 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'); + }); + }); });