Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/Onyx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TKey extends OnyxKey>(connectOptions: ConnectOptions<TKey>): Connection {
Expand All @@ -122,6 +125,9 @@ function connect<TKey extends OnyxKey>(connectOptions: ConnectOptions<TKey>): 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<TKey extends OnyxKey>(connectOptions: ConnectOptions<TKey>): Connection {
Expand Down
30 changes: 26 additions & 4 deletions lib/useOnyx.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -26,6 +27,13 @@ type UseOnyxOptions<TKey extends OnyxKey, TReturnValue> = {
* @see `useOnyx` cannot return `null` and so selector will replace `null` with `undefined` to maintain compatibility.
*/
selector?: UseOnyxSelector<TKey, TReturnValue>;

/**
* 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';
Expand All @@ -45,6 +53,10 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
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<TKey, TReturnValue> | null => {
if (!selector) {
Expand Down Expand Up @@ -163,7 +175,9 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
// 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<UseOnyxResult<TReturnValue>>(key, cacheKey);
if (cachedResult !== undefined) {
resultRef.current = cachedResult;
Expand All @@ -175,7 +189,7 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
// 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<TKey>;
const selectedValue = memoizedSelector ? memoizedSelector(value) : value;
Expand Down Expand Up @@ -231,10 +245,17 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
}

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
Comment on lines +255 to +256

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep paused hooks connected to hydrate and invalidate

When subscribed: false is used, this early return skips connectionManager.connect, so the hook no longer keeps the Onyx connection open. If a component mounts paused for a key that is not already in cache, no initial subscription callback ever loads/marks the key as loaded, leaving it stuck in loading; for cached keys, background writes also cannot invalidate the snapshot cache, so resuming can briefly render stale data until the async reconnect callback runs. The paused path should keep the connection and only suppress the render trigger.

Useful? React with 👍 / 👎.

}

// 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).
Expand Down Expand Up @@ -282,7 +303,7 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
onStoreChangeFnRef.current = null;
};
},
[key, options?.reuseConnection],
[key, options?.reuseConnection, subscribed],
);

const result = useSyncExternalStore<UseOnyxResult<TReturnValue>>(subscribe, getSnapshot);
Expand All @@ -293,3 +314,4 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
export default useOnyx;

export type {FetchStatus, ResultMetadata, UseOnyxResult, UseOnyxOptions, UseOnyxSelector};

183 changes: 183 additions & 0 deletions tests/unit/useOnyxTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
Loading