Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 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: 25 additions & 7 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,11 @@ 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. Synced during
// render (not in an effect) so a `false`→`true` flip can't miss a write that lands before effects run.
const subscribed = options?.subscribed !== false;
const subscribedRef = useLiveRef(subscribed);
Comment thread
LukasMod marked this conversation as resolved.
Outdated

// Create memoized version of selector for performance
const memoizedSelector = useMemo((): UseOnyxSelector<TKey, TReturnValue> | null => {
if (!selector) {
Expand Down Expand Up @@ -265,8 +280,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 All @@ -282,7 +300,7 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
onStoreChangeFnRef.current = null;
};
},
[key, options?.reuseConnection],
[key, options?.reuseConnection, subscribedRef],
);

const result = useSyncExternalStore<UseOnyxResult<TReturnValue>>(subscribe, getSnapshot);
Expand Down
183 changes: 183 additions & 0 deletions tests/unit/useOnyxTest.ts
Comment thread
LukasMod marked this conversation as resolved.
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