From 415b4d4175c8e97e9bd62d4be93232fc66fb660f Mon Sep 17 00:00:00 2001 From: Maciej Lesniewski Date: Thu, 12 Feb 2026 19:02:59 +0100 Subject: [PATCH 1/6] add a test case for a selector on the dependency list --- tests/unit/useOnyxTest.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index 19fe2cd0..7183f857 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -8,6 +8,7 @@ import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; import * as Logger from '../../lib/Logger'; import onyxSnapshotCache from '../../lib/OnyxSnapshotCache'; import type {UseOnyxSelector} from '../../lib/useOnyx'; +import React from 'react'; const ONYXKEYS = { TEST_KEY: 'test', @@ -19,6 +20,8 @@ const ONYXKEYS = { }, }; +const StrictWrapper = ({children}: React.PropsWithChildren) => React.createElement(React.StrictMode, null, children); + Onyx.init({ keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY], @@ -444,6 +447,38 @@ describe('useOnyx', () => { expect(result.current[1].status).toEqual('loaded'); }); + it('should always use the latest selector reference if it is on the dependency list', async () => { + Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name'}); + + let testSelector = ((entry: OnyxEntry<{id: string; name: string}>) => `id - ${entry?.id}, name - ${entry?.name}`) as UseOnyxSelector; + + const {result, rerender} = renderHook(({selector}: {selector: UseOnyxSelector}) => + useOnyx(ONYXKEYS.TEST_KEY, { + selector, + }, [selector]), + { + initialProps: {selector: testSelector}, + wrapper: StrictWrapper, + concurrentRoot: true, + }, + ); + + expect(result.current[0]).toEqual('id - test_id, name - test_name'); + expect(result.current[1].status).toEqual('loaded'); + + testSelector = ((entry: OnyxEntry<{id: string; name: string}>) => `id - ${entry?.id}, name - ${entry?.name} - selector changed`) as UseOnyxSelector; + + await act(async () => { + React.startTransition(() => { + rerender({selector: testSelector}); + }); + await waitForPromisesToResolve(); + }); + + expect(result.current[0]).toEqual('id - test_id, name - test_name - selector changed'); + expect(result.current[1].status).toEqual('loaded'); + }); + it('should memoize selector output and return same reference when input unchanged', async () => { Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name', count: 1}); From 75f9bd1df88684827da47bdabbc16ba7fd2ae324 Mon Sep 17 00:00:00 2001 From: Maciej Lesniewski Date: Fri, 13 Feb 2026 11:16:37 +0100 Subject: [PATCH 2/6] fixes memoizedSelector reference --- lib/useOnyx.ts | 9 +++++---- tests/unit/useOnyxTest.ts | 27 +++++++++++++++++---------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 0b28a450..50290bfa 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -80,9 +80,10 @@ function useOnyx>( const currentDependenciesRef = useLiveRef(dependencies); const selector = options?.selector; + const memoizedSelectorRef = useRef(null); // Create memoized version of selector for performance - const memoizedSelector = useMemo(() => { + memoizedSelectorRef.current = useMemo(() => { if (!selector) { return null; } @@ -259,7 +260,7 @@ function useOnyx>( if (isFirstConnectionRef.current || shouldGetCachedValueRef.current || key !== previousKey) { // 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; - const selectedValue = memoizedSelector ? memoizedSelector(value) : value; + const selectedValue = memoizedSelectorRef.current ? memoizedSelectorRef.current(value) : value; newValueRef.current = (selectedValue ?? undefined) as TReturnValue | undefined; // This flag is `false` when the original Onyx value (without selector) is not defined yet. @@ -289,7 +290,7 @@ function useOnyx>( // - Non-selector cases use shallow equality for object reference checks // - Normalize null to undefined to ensure consistent comparison (both represent "no value") let areValuesEqual: boolean; - if (memoizedSelector) { + if (memoizedSelectorRef.current) { const normalizedPrevious = previousValueRef.current ?? undefined; const normalizedNew = newValueRef.current ?? undefined; areValuesEqual = normalizedPrevious === normalizedNew; @@ -331,7 +332,7 @@ function useOnyx>( } return resultRef.current; - }, [options?.initWithStoredValues, options?.allowStaleData, options?.canBeMissing, key, memoizedSelector, cacheKey, previousKey]); + }, [options?.initWithStoredValues, options?.allowStaleData, options?.canBeMissing, key, memoizedSelectorRef, cacheKey, previousKey]); const subscribe = useCallback( (onStoreChange: () => void) => { diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index 7183f857..98acaf69 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -1,3 +1,4 @@ +import {createElement, startTransition, StrictMode} from 'react'; import {act, renderHook} from '@testing-library/react-native'; import type {OnyxCollection, OnyxEntry, OnyxKey} from '../../lib'; import Onyx, {useOnyx} from '../../lib'; @@ -8,7 +9,6 @@ import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; import * as Logger from '../../lib/Logger'; import onyxSnapshotCache from '../../lib/OnyxSnapshotCache'; import type {UseOnyxSelector} from '../../lib/useOnyx'; -import React from 'react'; const ONYXKEYS = { TEST_KEY: 'test', @@ -20,7 +20,9 @@ const ONYXKEYS = { }, }; -const StrictWrapper = ({children}: React.PropsWithChildren) => React.createElement(React.StrictMode, null, children); +function StrictWrapper({children}: React.PropsWithChildren) { + return createElement(StrictMode, null, children); +} Onyx.init({ keys: ONYXKEYS, @@ -447,15 +449,20 @@ describe('useOnyx', () => { expect(result.current[1].status).toEqual('loaded'); }); - it('should always use the latest selector reference if it is on the dependency list', async () => { + it('should always use the latest selector reference if the dependency change', async () => { Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name'}); let testSelector = ((entry: OnyxEntry<{id: string; name: string}>) => `id - ${entry?.id}, name - ${entry?.name}`) as UseOnyxSelector; - const {result, rerender} = renderHook(({selector}: {selector: UseOnyxSelector}) => - useOnyx(ONYXKEYS.TEST_KEY, { - selector, - }, [selector]), + const {result, rerender} = renderHook( + ({selector}: {selector: UseOnyxSelector}) => + useOnyx( + ONYXKEYS.TEST_KEY, + { + selector, + }, + [selector], + ), { initialProps: {selector: testSelector}, wrapper: StrictWrapper, @@ -469,11 +476,11 @@ describe('useOnyx', () => { testSelector = ((entry: OnyxEntry<{id: string; name: string}>) => `id - ${entry?.id}, name - ${entry?.name} - selector changed`) as UseOnyxSelector; await act(async () => { - React.startTransition(() => { - rerender({selector: testSelector}); + startTransition(() => { + rerender({selector: testSelector}); }); await waitForPromisesToResolve(); - }); + }); expect(result.current[0]).toEqual('id - test_id, name - test_name - selector changed'); expect(result.current[1].status).toEqual('loaded'); From b9e24a2ac50b33c31ae3926dda4462233fa6e81a Mon Sep 17 00:00:00 2001 From: Maciej Lesniewski Date: Fri, 13 Feb 2026 14:54:51 +0100 Subject: [PATCH 3/6] allow to recompute the sleected value if selector changes --- lib/useOnyx.ts | 52 +++++++++++++++++++++------------------ tests/unit/useOnyxTest.ts | 52 ++++++--------------------------------- 2 files changed, 35 insertions(+), 69 deletions(-) diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 50290bfa..7af3bd3c 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -80,12 +80,12 @@ function useOnyx>( const currentDependenciesRef = useLiveRef(dependencies); const selector = options?.selector; - const memoizedSelectorRef = useRef(null); + const memoizedSelectorRef = useRef<[typeof selector | null, boolean]>([null, true]); // Create memoized version of selector for performance memoizedSelectorRef.current = useMemo(() => { if (!selector) { - return null; + return [null, true]; } let lastInput: OnyxValue | undefined; @@ -93,28 +93,31 @@ function useOnyx>( let lastDependencies: DependencyList = []; let hasComputed = false; - return (input: OnyxValue | undefined): TReturnValue => { - const currentDependencies = currentDependenciesRef.current; - - // Recompute if input changed, dependencies changed, or first time - const dependenciesChanged = !shallowEqual(lastDependencies, currentDependencies); - if (!hasComputed || lastInput !== input || dependenciesChanged) { - // Only proceed if we have a valid selector - if (selector) { - const newOutput = selector(input); - - // Deep equality mode: only update if output actually changed - if (!hasComputed || !deepEqual(lastOutput, newOutput) || dependenciesChanged) { - lastInput = input; - lastOutput = newOutput; - lastDependencies = [...currentDependencies]; - hasComputed = true; + return [ + (input: OnyxValue | undefined): TReturnValue => { + const currentDependencies = currentDependenciesRef.current; + + // Recompute if input changed, dependencies changed, or first time + const dependenciesChanged = !shallowEqual(lastDependencies, currentDependencies); + if (!hasComputed || lastInput !== input || dependenciesChanged) { + // Only proceed if we have a valid selector + if (selector) { + const newOutput = selector(input); + + // Deep equality mode: only update if output actually changed + if (!hasComputed || !deepEqual(lastOutput, newOutput) || dependenciesChanged) { + lastInput = input; + lastOutput = newOutput; + lastDependencies = [...currentDependencies]; + hasComputed = true; + } } } - } - return lastOutput; - }; + return lastOutput; + }, + hasComputed, + ]; }, [currentDependenciesRef, selector]); // Stores the previous cached value as it's necessary to compare with the new value in `getSnapshot()`. @@ -254,13 +257,14 @@ function useOnyx>( return result; } + const [memoizedSelector, hasMemoizedSelectorComputed] = memoizedSelectorRef.current; // 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 || !hasMemoizedSelectorComputed) { // 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; - const selectedValue = memoizedSelectorRef.current ? memoizedSelectorRef.current(value) : value; + const selectedValue = memoizedSelector ? memoizedSelector(value) : value; newValueRef.current = (selectedValue ?? undefined) as TReturnValue | undefined; // This flag is `false` when the original Onyx value (without selector) is not defined yet. @@ -290,7 +294,7 @@ function useOnyx>( // - Non-selector cases use shallow equality for object reference checks // - Normalize null to undefined to ensure consistent comparison (both represent "no value") let areValuesEqual: boolean; - if (memoizedSelectorRef.current) { + if (memoizedSelector) { const normalizedPrevious = previousValueRef.current ?? undefined; const normalizedNew = newValueRef.current ?? undefined; areValuesEqual = normalizedPrevious === normalizedNew; diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index 98acaf69..0bb5ddc8 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -1,4 +1,3 @@ -import {createElement, startTransition, StrictMode} from 'react'; import {act, renderHook} from '@testing-library/react-native'; import type {OnyxCollection, OnyxEntry, OnyxKey} from '../../lib'; import Onyx, {useOnyx} from '../../lib'; @@ -20,10 +19,6 @@ const ONYXKEYS = { }, }; -function StrictWrapper({children}: React.PropsWithChildren) { - return createElement(StrictMode, null, children); -} - Onyx.init({ keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.EVICTABLE_TEST_KEY], @@ -443,44 +438,10 @@ describe('useOnyx', () => { expect(result.current[1].status).toEqual('loaded'); selector = ((entry: OnyxEntry<{id: string; name: string}>) => `id - ${entry?.id}, name - ${entry?.name} - selector changed`) as UseOnyxSelector; - rerender(undefined); - - expect(result.current[0]).toEqual('id - test_id, name - test_name - selector changed'); - expect(result.current[1].status).toEqual('loaded'); - }); - - it('should always use the latest selector reference if the dependency change', async () => { - Onyx.set(ONYXKEYS.TEST_KEY, {id: 'test_id', name: 'test_name'}); - - let testSelector = ((entry: OnyxEntry<{id: string; name: string}>) => `id - ${entry?.id}, name - ${entry?.name}`) as UseOnyxSelector; - - const {result, rerender} = renderHook( - ({selector}: {selector: UseOnyxSelector}) => - useOnyx( - ONYXKEYS.TEST_KEY, - { - selector, - }, - [selector], - ), - { - initialProps: {selector: testSelector}, - wrapper: StrictWrapper, - concurrentRoot: true, - }, - ); - - expect(result.current[0]).toEqual('id - test_id, name - test_name'); - expect(result.current[1].status).toEqual('loaded'); - testSelector = ((entry: OnyxEntry<{id: string; name: string}>) => `id - ${entry?.id}, name - ${entry?.name} - selector changed`) as UseOnyxSelector; + await act(async () => waitForPromisesToResolve()); - await act(async () => { - startTransition(() => { - rerender({selector: testSelector}); - }); - await waitForPromisesToResolve(); - }); + rerender(undefined); expect(result.current[0]).toEqual('id - test_id, name - test_name - selector changed'); expect(result.current[1].status).toEqual('loaded'); @@ -723,15 +684,16 @@ describe('useOnyx', () => { const dependencies = ['constant']; let selectorCallCount = 0; + const selector = ((data) => { + selectorCallCount++; + return `${dependencies.join(',')}:${(data as {value?: string})?.value}`; + }) as UseOnyxSelector; const {result, rerender} = renderHook(() => useOnyx( ONYXKEYS.TEST_KEY, { - selector: (data) => { - selectorCallCount++; - return `${dependencies.join(',')}:${(data as {value?: string})?.value}`; - }, + selector, }, dependencies, ), From cad13f2346051bf436a7f76e95b376ad5d3a11c6 Mon Sep 17 00:00:00 2001 From: Maciej Lesniewski Date: Mon, 16 Feb 2026 11:02:10 +0100 Subject: [PATCH 4/6] properly expose hasComputed flag --- lib/useOnyx.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 7af3bd3c..0a287afe 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -80,12 +80,11 @@ function useOnyx>( const currentDependenciesRef = useLiveRef(dependencies); const selector = options?.selector; - const memoizedSelectorRef = useRef<[typeof selector | null, boolean]>([null, true]); // Create memoized version of selector for performance - memoizedSelectorRef.current = useMemo(() => { + const [memoizedSelector, getHasMemoizedSelectorComputed] = useMemo((): [UseOnyxSelector | null, () => boolean] => { if (!selector) { - return [null, true]; + return [null, () => true]; } let lastInput: OnyxValue | undefined; @@ -116,7 +115,7 @@ function useOnyx>( return lastOutput; }, - hasComputed, + () => hasComputed, ]; }, [currentDependenciesRef, selector]); @@ -257,11 +256,10 @@ function useOnyx>( return result; } - const [memoizedSelector, hasMemoizedSelectorComputed] = memoizedSelectorRef.current; // 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 || !hasMemoizedSelectorComputed) { + if (isFirstConnectionRef.current || shouldGetCachedValueRef.current || key !== previousKey || !getHasMemoizedSelectorComputed()) { // 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; const selectedValue = memoizedSelector ? memoizedSelector(value) : value; @@ -336,7 +334,7 @@ function useOnyx>( } return resultRef.current; - }, [options?.initWithStoredValues, options?.allowStaleData, options?.canBeMissing, key, memoizedSelectorRef, cacheKey, previousKey]); + }, [options?.initWithStoredValues, options?.allowStaleData, options?.canBeMissing, key, memoizedSelector, getHasMemoizedSelectorComputed, cacheKey, previousKey]); const subscribe = useCallback( (onStoreChange: () => void) => { From b6319e0942aeea4c57487ea72247277624f917c9 Mon Sep 17 00:00:00 2001 From: Maciej Lesniewski Date: Tue, 17 Feb 2026 15:05:35 +0100 Subject: [PATCH 5/6] update unit test for sync/async selector change --- tests/unit/useOnyxTest.ts | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index 0bb5ddc8..3502fe1f 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -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; + let selector: UseOnyxSelector = ((entry: OnyxEntry<{id: string; name: string}>) => `id - ${entry?.id}, name - ${entry?.name}`) as UseOnyxSelector< + OnyxKey, + string + >; const {result, rerender} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY, { @@ -437,13 +440,27 @@ 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; - - await act(async () => waitForPromisesToResolve()); + 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'); }); From 11c8b3a268f95cc58d6febcfb22602bdb7b4dc5a Mon Sep 17 00:00:00 2001 From: Maciej Lesniewski Date: Thu, 19 Feb 2026 15:18:02 +0100 Subject: [PATCH 6/6] implements epoch approach for tracking selector freshness --- lib/useOnyx.ts | 64 +++++++++++++++++++++++---------------- lib/useSelectorEpoch.ts | 44 +++++++++++++++++++++++++++ tests/unit/useOnyxTest.ts | 10 ++---- 3 files changed, 84 insertions(+), 34 deletions(-) create mode 100644 lib/useSelectorEpoch.ts diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 0a287afe..9a0a6496 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -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> = (data: OnyxValue | undefined) => TReturnValue; @@ -82,9 +83,9 @@ function useOnyx>( const selector = options?.selector; // Create memoized version of selector for performance - const [memoizedSelector, getHasMemoizedSelectorComputed] = useMemo((): [UseOnyxSelector | null, () => boolean] => { + const memoizedSelector = useMemo((): UseOnyxSelector | null => { if (!selector) { - return [null, () => true]; + return null; } let lastInput: OnyxValue | undefined; @@ -92,31 +93,28 @@ function useOnyx>( let lastDependencies: DependencyList = []; let hasComputed = false; - return [ - (input: OnyxValue | undefined): TReturnValue => { - const currentDependencies = currentDependenciesRef.current; - - // Recompute if input changed, dependencies changed, or first time - const dependenciesChanged = !shallowEqual(lastDependencies, currentDependencies); - if (!hasComputed || lastInput !== input || dependenciesChanged) { - // Only proceed if we have a valid selector - if (selector) { - const newOutput = selector(input); - - // Deep equality mode: only update if output actually changed - if (!hasComputed || !deepEqual(lastOutput, newOutput) || dependenciesChanged) { - lastInput = input; - lastOutput = newOutput; - lastDependencies = [...currentDependencies]; - hasComputed = true; - } + return (input: OnyxValue | undefined): TReturnValue => { + const currentDependencies = currentDependenciesRef.current; + + // Recompute if input changed, dependencies changed, or first time + const dependenciesChanged = !shallowEqual(lastDependencies, currentDependencies); + if (!hasComputed || lastInput !== input || dependenciesChanged) { + // Only proceed if we have a valid selector + if (selector) { + const newOutput = selector(input); + + // Deep equality mode: only update if output actually changed + if (!hasComputed || !deepEqual(lastOutput, newOutput) || dependenciesChanged) { + lastInput = input; + lastOutput = newOutput; + lastDependencies = [...currentDependencies]; + hasComputed = true; } } + } - return lastOutput; - }, - () => hasComputed, - ]; + return lastOutput; + }; }, [currentDependenciesRef, selector]); // Stores the previous cached value as it's necessary to compare with the new value in `getSnapshot()`. @@ -233,6 +231,8 @@ function useOnyx>( } }, [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 @@ -259,10 +259,12 @@ function useOnyx>( // 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 || !getHasMemoizedSelectorComputed()) { + 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; 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. @@ -334,7 +336,17 @@ function useOnyx>( } return resultRef.current; - }, [options?.initWithStoredValues, options?.allowStaleData, options?.canBeMissing, key, memoizedSelector, getHasMemoizedSelectorComputed, cacheKey, previousKey]); + }, [ + options?.initWithStoredValues, + options?.allowStaleData, + options?.canBeMissing, + key, + memoizedSelector, + cacheKey, + previousKey, + hasSelectorComputedForCurrentEpoch, + markSelectorComputedForCurrentEpoch, + ]); const subscribe = useCallback( (onStoreChange: () => void) => { diff --git a/lib/useSelectorEpoch.ts b/lib/useSelectorEpoch.ts new file mode 100644 index 00000000..4c8ff6cb --- /dev/null +++ b/lib/useSelectorEpoch.ts @@ -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(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; diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index 3502fe1f..4e9aca7c 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -440,20 +440,14 @@ 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 synchronously`) as UseOnyxSelector< - OnyxKey, - string - >; + selector = ((entry: OnyxEntry<{id: string; name: string}>) => `id - ${entry?.id}, name - ${entry?.name} - selector changed synchronously`) as UseOnyxSelector; rerender(undefined); 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 - >; + selector = ((entry: OnyxEntry<{id: string; name: string}>) => `id - ${entry?.id}, name - ${entry?.name} - selector changed after macrotask`) as UseOnyxSelector; await act(async () => { await waitForPromisesToResolve();