Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 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
Comment thread
LukasMod marked this conversation as resolved.
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
Comment thread
LukasMod marked this conversation as resolved.
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
32 changes: 26 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`, 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 +55,13 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
const currentDependenciesRef = useLiveRef(dependencies);
const selector = options?.selector;

// Read via a ref inside the Onyx callback so toggling `subscribed` never re-subscribes.
const subscribed = options?.subscribed !== false;
const subscribedRef = useRef(subscribed);
useEffect(() => {
subscribedRef.current = subscribed;
Comment on lines +64 to +65

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
Contributor 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

}, [subscribed]);
Comment thread
LukasMod marked this conversation as resolved.
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 +282,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, except for paused background writes. The initial load is never paused
// though, otherwise a cold `subscribed: false` key would stay stuck 'loading' until some render.
if (subscribedRef.current || resultRef.current?.[1]?.status === 'loading') {
onStoreChange();
}
},
reuseConnection: options?.reuseConnection,
});
Expand Down
215 changes: 212 additions & 3 deletions tests/unit/useOnyxTest.ts
Comment thread
LukasMod marked this conversation as resolved.
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,211 @@ 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 serves the latest value, not a stale snapshot.
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});
});

// getSnapshot should recompute with the new dependency: base-B, not the stale base-A
expect(result.current[0]).toEqual('base-B');
});

// A cold key mounted with subscribed: false must still complete its initial load (only later writes pause).
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});
});

// Nothing in cache yet → starts loading.
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);
});

// Flipping back to subscribed re-renders with the latest value; a warm key shows 'loaded' with no 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