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
40 changes: 34 additions & 6 deletions lib/useOnyx.ts
Original file line number Diff line number Diff line change
@@ -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<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>> = (data: OnyxValue<TKey> | undefined) => TReturnValue;
Expand All @@ -26,6 +29,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`, 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';
Expand All @@ -45,6 +55,21 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
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;
Comment on lines +62 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Sync subscribed ref before passive effects

When a consumer flips subscribed from true to false (for example during a screen-blur render), this passive effect leaves subscribedRef.current at the old true value until after commit. Any Onyx write from a sibling layout effect or immediate microtask in that commit→effect window will pass the guard in the connection callback and call onStoreChange(), causing the off-screen/background re-render this option is meant to suppress. Syncing the ref during render or in a layout effect avoids that stale-true window.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You're right that there's a small gap. When subscribed flips from true to false, the ref only updates after React runs effects. If an Onyx write lands in that tiny window between the render committing and the effect running, the callback still sees true and triggers one re-render that ideally would have been skipped.

We're keeping it this way on purpose, for three reasons:

  1. First, the damage is tiny and harmless. Worst case is one extra render, showing correct data, on a screen that just went off-screen. Compare that to the opposite direction (false to true): if the ref were stale there, a visible screen would miss fresh data, which is a real correctness bug. That direction is already handled by the catch-up in this effect. So the rule of thumb here is: when the gate is briefly wrong, it should fail by rendering too much, never by showing stale data. This window fails in the safe direction.

  2. Second, the suggested fixes are worse than the problem. We can't "catch up" after the fact like we did for the focus direction, because you can't undo a render that already happened. The only real fix is updating the ref earlier. Updating it during render was already flagged as a P2 in an earlier round: with concurrent rendering, an aborted render could write false into the ref while the visible screen is still subscribed, and then a visible screen stops getting updates. useLayoutEffect would work but brings the SSR warning problem and would be the first use of that pattern in this library. Neither trade is worth it to skip one background render in case of that tiny gap.

  3. Third, TanStack Query has exactly the same window in its subscribed option and ships with it unmitigated. Their subscription is only torn down when React runs the passive effect, so a query update in that same commit-to-effect gap also re-renders the component that just unsubscribed. This isn't an implementation bug on our side, it's just how useSyncExternalStore works: subscription changes always take effect when effects flush, not when the render commits.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yeah I think it's not worth investing on this, bringing more unecessary complexity is exactly what we want to avoid in the hook


// 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]);
Comment thread
LukasMod marked this conversation as resolved.

// Create memoized version of selector for performance
const memoizedSelector = useMemo((): UseOnyxSelector<TKey, TReturnValue> | null => {
if (!selector) {
Expand Down Expand Up @@ -265,8 +290,11 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
// 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,
});
Expand Down
236 changes: 233 additions & 3 deletions tests/unit/useOnyxTest.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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');
});
});
});
Loading