diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 0b26428f..2d89f508 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -1,11 +1,10 @@ import {deepEqual, shallowEqual} from 'fast-equals'; import {useCallback, useEffect, useRef, useSyncExternalStore} from 'react'; -import type {IsEqual} from 'type-fest'; import OnyxCache from './OnyxCache'; import type {Connection} from './OnyxConnectionManager'; import connectionManager from './OnyxConnectionManager'; import OnyxUtils from './OnyxUtils'; -import type {CollectionKeyBase, OnyxCollection, OnyxEntry, OnyxKey, OnyxValue, Selector} from './types'; +import type {CollectionKeyBase, KeyValueMapping, OnyxCollection, OnyxKey, OnyxValue} from './types'; import useLiveRef from './useLiveRef'; import usePrevious from './usePrevious'; @@ -33,43 +32,78 @@ type UseOnyxInitialValueOption = { initialValue?: TInitialValue; }; +type UseOnyxSelector> = (data: OnyxValue | undefined) => TReturnValue; + type UseOnyxSelectorOption = { /** * This will be used to subscribe to a subset of an Onyx key's data. * 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). + * @see `useOnyx` cannot return `null` and so selector will replace `null` with `undefined` to maintain compatibility. */ - selector?: Selector; + selector?: UseOnyxSelector; }; type UseOnyxOptions = BaseUseOnyxOptions & UseOnyxInitialValueOption & UseOnyxSelectorOption; type FetchStatus = 'loading' | 'loaded'; -type SelectedValue = TKey extends CollectionKeyBase ? OnyxCollection : OnyxEntry; - -type CachedValue = IsEqual> extends true ? TValue : SelectedValue; - type ResultMetadata = { status: FetchStatus; }; -type UseOnyxResult = [CachedValue, ResultMetadata]; +type UseOnyxResult = [NonNullable | undefined, ResultMetadata]; + +/** + * Gets the cached value from the Onyx cache. If the key is a collection key, it will return all the values in the collection. + * It is a fork of `tryGetCachedValue` from `OnyxUtils` caused by different selector logic in `useOnyx`. It should be unified in the future, when `withOnyx` is removed. + */ +function tryGetCachedValue(key: TKey): OnyxValue { + if (!OnyxUtils.isCollectionKey(key)) { + return OnyxCache.get(key); + } + + const allCacheKeys = OnyxCache.getAllKeys(); + + // It is possible we haven't loaded all keys yet so we do not know if the + // collection actually exists. + if (allCacheKeys.size === 0) { + return; + } + + const values: OnyxCollection = {}; + allCacheKeys.forEach((cacheKey) => { + if (!cacheKey.startsWith(key)) { + return; + } + + values[cacheKey] = OnyxCache.get(cacheKey); + }); + + return values; +} + +/** + * Gets the value from cache and maps it with selector. It changes `null` to `undefined` for `useOnyx` compatibility. + */ +function getCachedValue(key: TKey, selector?: UseOnyxSelector) { + const value = tryGetCachedValue(key) as OnyxValue; + + const selectedValue = selector ? selector(value) : (value as TValue); -function getCachedValue(key: TKey, selector?: Selector): CachedValue | undefined { - return (OnyxUtils.tryGetCachedValue(key, {selector}) ?? undefined) as CachedValue | undefined; + return selectedValue ?? undefined; } function useOnyx>( key: TKey, options?: BaseUseOnyxOptions & UseOnyxInitialValueOption & Required>, -): UseOnyxResult; +): UseOnyxResult; function useOnyx>( key: TKey, options?: BaseUseOnyxOptions & UseOnyxInitialValueOption>, -): UseOnyxResult; -function useOnyx>(key: TKey, options?: UseOnyxOptions): UseOnyxResult { +): UseOnyxResult; +function useOnyx>(key: TKey, options?: UseOnyxOptions): UseOnyxResult { const connectionRef = useRef(null); const previousKey = usePrevious(key); @@ -78,16 +112,16 @@ function useOnyx>(key: TKey // Stores the previous cached value as it's necessary to compare with the new value in `getSnapshot()`. // We initialize it to `null` to simulate that we don't have any value from cache yet. - const previousValueRef = useRef | undefined | null>(null); + const previousValueRef = useRef(null); // Stores the newest cached value in order to compare with the previous one and optimize `getSnapshot()` execution. - const newValueRef = useRef | undefined | null>(null); + const newValueRef = useRef(null); // Stores the previously result returned by the hook, containing the data from cache and the fetch status. // We initialize it to `undefined` and `loading` fetch status to simulate the initial result when the hook is loading from the cache. // However, if `initWithStoredValues` is `false` we set the fetch status to `loaded` since we want to signal that data is ready. - const resultRef = useRef>([ - undefined as CachedValue, + const resultRef = useRef>([ + undefined, { status: options?.initWithStoredValues === false ? 'loaded' : 'loading', }, @@ -159,7 +193,7 @@ function useOnyx>(key: TKey // If `newValueRef.current` is `null` or any other value it means that the cache does have a value for that key. // This difference between `undefined` and other values is crucial and it's used to address the following // conditions and use cases. - newValueRef.current = getCachedValue(key, selectorRef.current); + newValueRef.current = getCachedValue(key, selectorRef.current); // We set this flag to `false` again since we don't want to get the newest cached value every time `getSnapshot()` is executed, // and only when `Onyx.connect()` callback is fired. @@ -182,11 +216,11 @@ function useOnyx>(key: TKey // If data is not present in cache and `initialValue` is set during the first connection, // we set the new value to `initialValue` and fetch status to `loaded` since we already have some data to return to the consumer. if (isFirstConnectionRef.current && !hasCacheForKey && options?.initialValue !== undefined) { - newValueRef.current = (options?.initialValue ?? undefined) as CachedValue; + newValueRef.current = (options?.initialValue ?? undefined) as TReturnValue; newFetchStatus = 'loaded'; } - // We do a deep equality check if `selector` is defined, since each `OnyxUtils.tryGetCachedValue()` call will + // We do a deep equality check if `selector` is defined, since each `tryGetCachedValue()` call will // generate a plain new primitive/object/array that was created using the `selector` function. // For the other cases we will only deal with object reference checks, so just a shallow equality check is enough. let areValuesEqual: boolean; @@ -204,11 +238,11 @@ function useOnyx>(key: TKey previousValueRef.current = newValueRef.current; // If the new value is `null` we default it to `undefined` to ensure the consumer gets a consistent result from the hook. - resultRef.current = [previousValueRef.current as CachedValue, {status: newFetchStatus ?? 'loaded'}]; + resultRef.current = [previousValueRef.current ?? undefined, {status: newFetchStatus ?? 'loaded'}]; } return resultRef.current; - }, [key, selectorRef, options?.initWithStoredValues, options?.allowStaleData, options?.initialValue]); + }, [options?.initWithStoredValues, options?.allowStaleData, options?.initialValue, key, selectorRef]); const subscribe = useCallback( (onStoreChange: () => void) => { @@ -247,7 +281,7 @@ function useOnyx>(key: TKey checkEvictableKey(); }, [checkEvictableKey]); - const result = useSyncExternalStore>(subscribe, getSnapshot); + const result = useSyncExternalStore>(subscribe, getSnapshot); return result; } diff --git a/tests/unit/useOnyxTest.ts b/tests/unit/useOnyxTest.ts index cbd95d41..bfe7c364 100644 --- a/tests/unit/useOnyxTest.ts +++ b/tests/unit/useOnyxTest.ts @@ -1,5 +1,5 @@ import {act, renderHook} from '@testing-library/react-native'; -import type {OnyxEntry} from '../../lib'; +import type {OnyxCollection, OnyxEntry} from '../../lib'; import Onyx, {useOnyx} from '../../lib'; import OnyxUtils from '../../lib/OnyxUtils'; import StorageMock from '../../lib/storage'; @@ -176,7 +176,11 @@ describe('useOnyx', () => { const {result} = renderHook(() => useOnyx(ONYXKEYS.COLLECTION.TEST_KEY, { // @ts-expect-error bypass - selector: (entry: OnyxEntry<{id: string; name: string}>) => entry?.id, + selector: (entries: OnyxCollection<{id: string; name: string}>) => + Object.entries(entries ?? {}).reduce>>((acc, [key, value]) => { + acc[key] = value?.id; + return acc; + }, {}), }), );