diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 9709ba3f1..994ff2168 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 memoizedShallowEqual from './memoizedShallowEqual'; import useLiveRef from './useLiveRef'; @@ -27,6 +30,14 @@ 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, and the value is frozen: incidental re-renders keep returning the + * last delivered result instead of the latest one. The initial load is exempt, so a cold key still resolves. + * Flipping back to `true` re-renders and catches up to the latest value. + */ + subscribed?: boolean; }; type FetchStatus = 'loading' | 'loaded'; @@ -46,6 +57,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) { @@ -161,6 +187,20 @@ function useOnyx>( const lastComputedSelectorRef = useRef(memoizedSelector); const getSnapshot = useCallback(() => { + // While unsubscribed, keep returning the last delivered result so incidental re-renders don't leak fresh data past the gate. Pending writes leave `shouldGetCachedValueRef` + // set, and the `subscribed` flip effect delivers the fresh value once the consumer resubscribes. + // First connection and `loading` are exempt so a cold `subscribed: false` key still resolves. + const hasSelectorChanged = lastComputedSelectorRef.current !== memoizedSelector; + + if (!subscribedRef.current && connectedKeyRef.current === key && resultRef.current[1].status !== 'loading') { + // A selector-identity change is a consumer-driven dirtying signal with no Onyx write behind it, + // so mark the snapshot dirty; otherwise the catch-up effect can't detect it on resubscribe. + if (hasSelectorChanged) { + shouldGetCachedValueRef.current = true; + } + return resultRef.current; + } + // 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; @@ -175,7 +215,6 @@ function useOnyx>( // We get the value from cache while the first connection to Onyx is being made or if the key has changed, // 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) { // 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; @@ -268,8 +307,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..5d2fcd133 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,368 @@ 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('keeps returning the last delivered value on an unrelated re-render while subscribed is false, then delivers on resubscribe', 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}); + }); + + // The snapshot stays frozen at the last delivered value: incidental re-renders must not leak + // fresh data past the gate (otherwise any parent/focus re-render defeats the pause). + expect(result.current[0]).toEqual('v1'); + + // Resubscribing delivers the pending fresh value + await act(async () => { + rerender({subscribed: true, tick: 1}); + }); + expect(result.current[0]).toEqual('v2'); + }); + + // A dependencies change is consumer-driven, not a background write, so subscribed: false must not defer it. + it('defers a dependencies change while subscribed is false and applies it on resubscribe', 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 snapshot stays frozen at the last delivered value + await act(async () => { + dep = 'B'; + rerender({subscribed: false}); + }); + expect(result.current[0]).toEqual('base-A'); + + // Resubscribing recomputes with the new dependency closure + await act(async () => rerender({subscribed: true})); + 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'); + }); + + // A key change is consumer-driven (like a dependencies change), so subscribed: false must not block it: + // the hook re-subscribes and treats the new key as a first connection, which is exempt from the gate. + it('loads the new key value on a key change while subscribed is false, then keeps gating background writes', async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}1000`, 'report_1000_value'); + await Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}1001`, 'report_1001_value'); + + type KeyProps = {key: string}; + const {result, rerender} = renderHook(({key}: KeyProps) => useOnyx(key, {subscribed: false}), { + initialProps: {key: `${ONYXKEYS.COLLECTION.TEST_KEY}1000`} as KeyProps, + }); + + await act(async () => waitForPromisesToResolve()); + expect(result.current[0]).toEqual('report_1000_value'); + expect(result.current[1].status).toEqual('loaded'); + + // Switch to another collection member while paused + await act(async () => { + rerender({key: `${ONYXKEYS.COLLECTION.TEST_KEY}1001`}); + await waitForPromisesToResolve(); + }); + + expect(result.current[0]).toEqual('report_1001_value'); + expect(result.current[1].status).toEqual('loaded'); + + // Still paused on the new key: background writes remain suppressed + await act(async () => { + Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_KEY}1001`, 'report_1001_updated'); + await waitForPromisesToResolve(); + }); + expect(result.current[0]).toEqual('report_1001_value'); + + // And writes to the old key are ignored entirely + await act(async () => { + Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_KEY}1000`, 'report_1000_updated'); + await waitForPromisesToResolve(); + }); + expect(result.current[0]).toEqual('report_1001_value'); + }); + + // Unlike the warm switch above, a cold member has no cached value to read during the switch render, + // so resolving it depends on the connect callback's initial-load exemption firing after the storage read. + it('resolves a cold key through loading on a key change while subscribed is false', async () => { + await StorageMock.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}1000`, {id: 1000}); + await StorageMock.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}1001`, {id: 1001}); + + type KeyProps = {key: string}; + const {result, rerender} = renderHook(({key}: KeyProps) => useOnyx(key, {subscribed: false}), { + initialProps: {key: `${ONYXKEYS.COLLECTION.TEST_KEY}1000`} as KeyProps, + }); + + await act(async () => waitForPromisesToResolve()); + expect(result.current[0]).toEqual({id: 1000}); + + // Switch to a member that only exists in storage + rerender({key: `${ONYXKEYS.COLLECTION.TEST_KEY}1001`}); + expect(result.current[1].status).toEqual('loading'); + + await act(async () => waitForPromisesToResolve()); + expect(result.current[0]).toEqual({id: 1001}); + expect(result.current[1].status).toEqual('loaded'); + }); + + // The pause freezes per subscription, not per key: writes suppressed while paused must surface + // when the consumer switches away and back, since each key change is a fresh first connection. + it('shows values written while paused after switching away and back to the key', async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}1000`, 'report_1000_value'); + await Onyx.set(`${ONYXKEYS.COLLECTION.TEST_KEY}1001`, 'report_1001_value'); + + type KeyProps = {key: string}; + const {result, rerender} = renderHook(({key}: KeyProps) => useOnyx(key, {subscribed: false}), { + initialProps: {key: `${ONYXKEYS.COLLECTION.TEST_KEY}1000`} as KeyProps, + }); + + await act(async () => waitForPromisesToResolve()); + expect(result.current[0]).toEqual('report_1000_value'); + + // While paused: a write to the connected key is suppressed, a write to the future key lands unconnected + await act(async () => { + Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_KEY}1000`, 'report_1000_updated'); + Onyx.merge(`${ONYXKEYS.COLLECTION.TEST_KEY}1001`, 'report_1001_updated'); + await waitForPromisesToResolve(); + }); + expect(result.current[0]).toEqual('report_1000_value'); + + // Switching to the other member shows its latest value, regardless of write timing + await act(async () => { + rerender({key: `${ONYXKEYS.COLLECTION.TEST_KEY}1001`}); + await waitForPromisesToResolve(); + }); + expect(result.current[0]).toEqual('report_1001_updated'); + + // Switching back shows the write that was suppressed while paused + await act(async () => { + rerender({key: `${ONYXKEYS.COLLECTION.TEST_KEY}1000`}); + await waitForPromisesToResolve(); + }); + expect(result.current[0]).toEqual('report_1000_updated'); + }); + + // A selector-identity change while paused must be frozen like any other update: the paused fast path + // returns the last delivered output instead of recomputing. On resubscribe the new selector applies + it('freezes a selector change while paused and applies it on resubscribe', async () => { + await Onyx.set(ONYXKEYS.TEST_KEY, 'v1'); + + const selectorA = (value: unknown) => `A:${value as string}`; + const selectorB = (value: unknown) => `B:${value as string}`; + + type SelectorProps = {subscribed?: boolean; selector: (value: unknown) => string}; + const {result, rerender} = renderHook(({subscribed, selector}: SelectorProps) => useOnyx(ONYXKEYS.TEST_KEY, {subscribed, selector}), { + initialProps: {subscribed: false, selector: selectorA} as SelectorProps, + }); + + await act(async () => waitForPromisesToResolve()); + expect(result.current[0]).toEqual('A:v1'); + + // Selector identity changes while paused — no Onyx write, no dependency change + await act(async () => rerender({subscribed: false, selector: selectorB})); + + // Frozen: the selector change is deferred, not applied while paused + expect(result.current[0]).toEqual('A:v1'); + + // Resubscribing must catch up to the new selector output + await act(async () => rerender({subscribed: true, selector: selectorB})); + expect(result.current[0]).toEqual('B:v1'); + }); + }); });