diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 0b28a450..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,7 +83,7 @@ function useOnyx>( const selector = options?.selector; // Create memoized version of selector for performance - const memoizedSelector = useMemo(() => { + const memoizedSelector = useMemo((): UseOnyxSelector | null => { if (!selector) { return null; } @@ -230,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 @@ -256,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) { + 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. @@ -331,7 +336,17 @@ function useOnyx>( } 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) => { 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 19fe2cd0..4e9aca7c 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,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; + 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'); + 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; + + 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'); }); @@ -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; const {result, rerender} = renderHook(() => useOnyx( ONYXKEYS.TEST_KEY, { - selector: (data) => { - selectorCallCount++; - return `${dependencies.join(',')}:${(data as {value?: string})?.value}`; - }, + selector, }, dependencies, ),