Skip to content
Open
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
21 changes: 18 additions & 3 deletions lib/useOnyx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import decorateWithMetrics from './metrics';
import * as Logger from './Logger';
import onyxSnapshotCache from './OnyxSnapshotCache';
import useLiveRef from './useLiveRef';
import useSelectorEpoch from './useSelectorEpoch';

type UseOnyxSelector<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>> = (data: OnyxValue<TKey> | undefined) => TReturnValue;

Expand Down Expand Up @@ -82,7 +83,7 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
const selector = options?.selector;

// Create memoized version of selector for performance
const memoizedSelector = useMemo(() => {
const memoizedSelector = useMemo((): UseOnyxSelector<TKey, TReturnValue> | null => {
if (!selector) {
return null;
}
Expand Down Expand Up @@ -230,6 +231,8 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
}
}, [key, options?.canEvict]);

const {hasSelectorComputedForCurrentEpoch, markSelectorComputedForCurrentEpoch} = useSelectorEpoch(memoizedSelector);

const getSnapshot = useCallback(() => {
// Check if we have any cache for this Onyx key
// Don't use cache for first connection with initWithStoredValues: false
Expand All @@ -256,10 +259,12 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
// 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.
if (isFirstConnectionRef.current || shouldGetCachedValueRef.current || key !== previousKey) {
if (isFirstConnectionRef.current || shouldGetCachedValueRef.current || key !== previousKey || !hasSelectorComputedForCurrentEpoch) {
// 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;

markSelectorComputedForCurrentEpoch();
newValueRef.current = (selectedValue ?? undefined) as TReturnValue | undefined;

// This flag is `false` when the original Onyx value (without selector) is not defined yet.
Expand Down Expand Up @@ -331,7 +336,17 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
}

return resultRef.current;
}, [options?.initWithStoredValues, options?.allowStaleData, options?.canBeMissing, key, memoizedSelector, cacheKey, previousKey]);
}, [
options?.initWithStoredValues,
options?.allowStaleData,
options?.canBeMissing,
key,
memoizedSelector,
cacheKey,
previousKey,
hasSelectorComputedForCurrentEpoch,
markSelectorComputedForCurrentEpoch,
]);

const subscribe = useCallback(
(onStoreChange: () => void) => {
Expand Down
44 changes: 44 additions & 0 deletions lib/useSelectorEpoch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {useCallback, useRef} from 'react';
import usePrevious from './usePrevious';

type UseSelectorEpochResult = {
hasSelectorComputedForCurrentEpoch: boolean;
markSelectorComputedForCurrentEpoch: () => void;
};

/**
* Tracks selector freshness across async interleavings using generation epochs.
*
* Why:
* - Selector reference can change while external-store callbacks are still in flight.
* - Snapshot cache may otherwise return a value computed by an older selector generation.
*
* How:
* - Increment epoch whenever selector reference changes.
* - Mark epoch as computed only after caller evaluates selector for current snapshot input.
* - Expose a boolean that tells caller whether current selector generation has already been computed.
*
* Usage pattern:
* 1) If `hasSelectorComputedForCurrentEpoch` is false, bypass cache and recompute.
* 2) After recompute, call `markSelectorComputedForCurrentEpoch()`.
*/
function useSelectorEpoch<TSelector>(selector: TSelector | null): UseSelectorEpochResult {
const selectorEpochRef = useRef(0);
const computedSelectorEpochRef = useRef(-1);
const previousSelector = usePrevious(selector);

if (previousSelector !== selector) {
selectorEpochRef.current += 1;
}

const markSelectorComputedForCurrentEpoch = useCallback(() => {
computedSelectorEpochRef.current = selectorEpochRef.current;
}, []);

return {
hasSelectorComputedForCurrentEpoch: !selector || computedSelectorEpochRef.current === selectorEpochRef.current,
markSelectorComputedForCurrentEpoch,
};
}

export default useSelectorEpoch;
29 changes: 22 additions & 7 deletions tests/unit/useOnyxTest.ts
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you think of a way to add a failing test for this first? seems like we had this bug in place because this scenario was not covered by unit tests. For onyx I believe we should try to always create the failing test first and then work on the fix cc @fabioh8010

Copy link
Author

Choose a reason for hiding this comment

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

You can see that there is an update in unit tests within this PR. Adding act() makes its behavior closer to how React works in the browser, like in few other tests here, breaking the scenario (while it shouldn't). The test in its previous form was too loose.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I saw that change, but it was not clear to me if that is all that we could / should cover here. Could you create a specific test for this behaviour that was broken?

Copy link
Author

Choose a reason for hiding this comment

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

Yup, I'll do it.

Copy link
Author

@leshniak leshniak Feb 17, 2026

Choose a reason for hiding this comment

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

@mountiny I've updated the tests and also dug deeper into the root cause.

On the main branch rerender() is called synchronously, so updates scheduled asynchonously with prepareSubscriberUpdate() don't have time to run. As a result, the store subscriber callback
does not get a chance to toggle the isFirstConnection and shouldGetCachedValue flags, which allows the new callback to execute.

On the other hand, if we call the next useOnyx in an asynchronous workflow, those flags are toggled to false, effectively preventing the new selector from executing.

The updated unit test ensures that replacing the selector works predictably in both scenarios.

Copy link
Contributor

Choose a reason for hiding this comment

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

Nice thanks for digging into that

Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,10 @@ describe('useOnyx', () => {
it('should always use the current selector reference to return new data', async () => {
Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name'});

let selector = ((entry: OnyxEntry<{id: string; name: string}>) => `id - ${entry?.id}, name - ${entry?.name}`) as UseOnyxSelector<OnyxKey, string>;
let selector: UseOnyxSelector<OnyxKey, string> = ((entry: OnyxEntry<{id: string; name: string}>) => `id - ${entry?.id}, name - ${entry?.name}`) as UseOnyxSelector<
OnyxKey,
string
>;

const {result, rerender} = renderHook(() =>
useOnyx(ONYXKEYS.TEST_KEY, {
Expand All @@ -437,10 +440,21 @@ describe('useOnyx', () => {
expect(result.current[0]).toEqual('id - test_id, name - test_name');
expect(result.current[1].status).toEqual('loaded');

selector = ((entry: OnyxEntry<{id: string; name: string}>) => `id - ${entry?.id}, name - ${entry?.name} - selector changed`) as UseOnyxSelector<OnyxKey, string>;
selector = ((entry: OnyxEntry<{id: string; name: string}>) => `id - ${entry?.id}, name - ${entry?.name} - selector changed synchronously`) as UseOnyxSelector<OnyxKey, string>;

rerender(undefined);

expect(result.current[0]).toEqual('id - test_id, name - test_name - selector changed');
expect(result.current[0]).toEqual('id - test_id, name - test_name - selector changed synchronously');
expect(result.current[1].status).toEqual('loaded');

selector = ((entry: OnyxEntry<{id: string; name: string}>) => `id - ${entry?.id}, name - ${entry?.name} - selector changed after macrotask`) as UseOnyxSelector<OnyxKey, string>;

await act(async () => {
await waitForPromisesToResolve();
rerender(undefined);
});

expect(result.current[0]).toEqual('id - test_id, name - test_name - selector changed after macrotask');
expect(result.current[1].status).toEqual('loaded');
});

Expand Down Expand Up @@ -681,15 +695,16 @@ describe('useOnyx', () => {

const dependencies = ['constant'];
let selectorCallCount = 0;
const selector = ((data) => {
selectorCallCount++;
return `${dependencies.join(',')}:${(data as {value?: string})?.value}`;
}) as UseOnyxSelector<OnyxKey, string>;

const {result, rerender} = renderHook(() =>
useOnyx(
ONYXKEYS.TEST_KEY,
{
selector: (data) => {
selectorCallCount++;
return `${dependencies.join(',')}:${(data as {value?: string})?.value}`;
},
selector,
},
dependencies,
),
Expand Down