Skip to content
Merged
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
14 changes: 14 additions & 0 deletions API-INTERNAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ The resulting collection will only contain items that are returned by the select
<dt><a href="#get">get()</a></dt>
<dd><p>Get some data from the store</p>
</dd>
<dt><a href="#tupleGet">tupleGet()</a></dt>
<dd><p>This helper exists to map an array of Onyx keys such as <code>[&#39;report_&#39;, &#39;conciergeReportID&#39;]</code>
to the values for those keys (correctly typed) such as <code>[OnyxCollection&lt;Report&gt;, OnyxEntry&lt;string&gt;]</code></p>
<p>Note: just using <code>.map</code>, you&#39;d end up with <code>Array&lt;OnyxCollection&lt;Report&gt;|OnyxEntry&lt;string&gt;&gt;</code>, which is not what we want. This preserves the order of the keys provided.</p>
</dd>
<dt><a href="#storeKeyBySubscriptions">storeKeyBySubscriptions(subscriptionID, key)</a></dt>
<dd><p>Stores a subscription ID associated with a given key.</p>
</dd>
Expand Down Expand Up @@ -241,6 +246,15 @@ The resulting collection will only contain items that are returned by the select
## get()
Get some data from the store

**Kind**: global function
<a name="tupleGet"></a>

## tupleGet()
This helper exists to map an array of Onyx keys such as `['report_', 'conciergeReportID']`
to the values for those keys (correctly typed) such as `[OnyxCollection<Report>, OnyxEntry<string>]`

Note: just using `.map`, you'd end up with `Array<OnyxCollection<Report>|OnyxEntry<string>>`, which is not what we want. This preserves the order of the keys provided.

**Kind**: global function
<a name="storeKeyBySubscriptions"></a>

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,21 @@ DO NOT use more than one `withOnyx` component at a time. It adds overhead and pr

It's also beneficial to use a [selector](https://github.com/Expensify/react-native-onyx/blob/main/API.md#connectmapping--number) with the mapping in case you need to grab a single item in a collection (like a single report action).

### useOnyx()'s `canBeMissing` option

You must pass the `canBeMissing` configuration flag to `useOnyx` if you want the hook to log an alert when data is missing from Onyx store. Regarding usage in `Expensify/App` repo, if the component calling this is the one loading the data by calling an action, then you should set this to `true`. If the component calling this does not load the data then you should set it to `false`, which means that if the data is not there, it will log an alert, as it means we are using data that no one loaded and that's most probably a bug.

```javascript
const Component = ({reportID}) => {
// This hook will log an alert (via `Logger.logAlert()`) if `report` is `undefined`.
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: false});

// rest of the component's code.
};

export default Component;
```

## Collections

Collections allow keys with similar value types to be subscribed together by subscribing to the collection key. To define one, it must be included in the `ONYXKEYS.COLLECTION` object and it must be suffixed with an underscore. Member keys should use a unique identifier or index after the collection key prefix (e.g. `report_42`).
Expand Down
15 changes: 9 additions & 6 deletions lib/Logger.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
type Parameters = string | Record<string, unknown> | Array<Record<string, unknown>> | Error;

type LogData = {
message: string;
level: 'alert' | 'info' | 'hmmm';
parameters?: Parameters;
};
type LoggerCallback = (data: LogData) => void;

Expand All @@ -17,22 +20,22 @@ function registerLogger(callback: LoggerCallback) {
/**
* Send an alert message to the logger
*/
function logAlert(message: string) {
logger({message: `[Onyx] ${message}`, level: 'alert'});
function logAlert(message: string, parameters?: Parameters) {
logger({message: `[Onyx] ${message}`, level: 'alert', parameters});
}

/**
* Send an info message to the logger
*/
function logInfo(message: string) {
logger({message: `[Onyx] ${message}`, level: 'info'});
function logInfo(message: string, parameters?: Parameters) {
logger({message: `[Onyx] ${message}`, level: 'info', parameters});
}

/**
* Send an hmmm message to the logger
*/
function logHmmm(message: string) {
logger({message: `[Onyx] ${message}`, level: 'hmmm'});
function logHmmm(message: string, parameters?: Parameters) {
logger({message: `[Onyx] ${message}`, level: 'hmmm', parameters});
}

export {registerLogger, logInfo, logAlert, logHmmm};
2 changes: 1 addition & 1 deletion lib/OnyxUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ function multiGet<TKey extends OnyxKey>(keys: CollectionKeyBase[]): Promise<Map<
* This helper exists to map an array of Onyx keys such as `['report_', 'conciergeReportID']`
* to the values for those keys (correctly typed) such as `[OnyxCollection<Report>, OnyxEntry<string>]`
*
* Note: just using .map, you'd end up with `Array<OnyxCollection<Report>|OnyxEntry<string>>`, which is not what we want. This preserves the order of the keys provided.
* Note: just using `.map`, you'd end up with `Array<OnyxCollection<Report>|OnyxEntry<string>>`, which is not what we want. This preserves the order of the keys provided.
*/
function tupleGet<Keys extends readonly OnyxKey[]>(keys: Keys): Promise<{[Index in keyof Keys]: OnyxValue<Keys[Index]>}> {
return Promise.all(keys.map((key) => OnyxUtils.get(key))) as Promise<{[Index in keyof Keys]: OnyxValue<Keys[Index]>}>;
Expand Down
50 changes: 30 additions & 20 deletions lib/useOnyx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {CollectionKeyBase, KeyValueMapping, OnyxCollection, OnyxKey, OnyxVa
import useLiveRef from './useLiveRef';
import usePrevious from './usePrevious';
import decorateWithMetrics from './metrics';
import * as Logger from './Logger';

type BaseUseOnyxOptions = {
/**
Expand Down Expand Up @@ -37,6 +38,14 @@ type BaseUseOnyxOptions = {
* If set to `true`, the key can be changed dynamically during the component lifecycle.
*/
allowDynamicKey?: boolean;

/**
* If the component calling this is the one loading the data by calling an action, then you should set this to `true`.
*
* If the component calling this does not load the data then you should set it to `false`, which means that if the data
* is not there, it will log an alert, as it means we are using data that no one loaded and that's most probably a bug.
*/
canBeMissing?: boolean;
};

type UseOnyxInitialValueOption<TInitialValue> = {
Expand Down Expand Up @@ -98,17 +107,6 @@ function tryGetCachedValue<TKey extends OnyxKey>(key: TKey): OnyxValue<OnyxKey>
return values;
}

/**
* Gets the value from cache and maps it with selector. It changes `null` to `undefined` for `useOnyx` compatibility.
*/
function getCachedValue<TKey extends OnyxKey, TValue>(key: TKey, selector?: UseOnyxSelector<TKey, TValue>) {
const value = tryGetCachedValue(key) as OnyxValue<TKey>;

const selectedValue = selector ? selector(value) : (value as TValue);

return selectedValue ?? undefined;
}

function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
key: TKey,
options?: BaseUseOnyxOptions & UseOnyxInitialValueOption<TReturnValue> & Required<UseOnyxSelectorOption<TKey, TReturnValue>>,
Expand Down Expand Up @@ -219,6 +217,8 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
}, [key, options?.canEvict]);

const getSnapshot = useCallback(() => {
let isOnyxValueDefined = true;

// We return the initial result right away during the first connection if `initWithStoredValues` is set to `false`.
if (isFirstConnectionRef.current && options?.initWithStoredValues === false) {
return resultRef.current;
Expand All @@ -228,11 +228,14 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
// so we can return any cached value right away. After the connection is made, we only
// update `newValueRef` when `Onyx.connect()` callback is fired.
if (isFirstConnectionRef.current || shouldGetCachedValueRef.current) {
// If `newValueRef.current` is `undefined` it means that the cache doesn't have a value for that key yet.
// 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);
// Gets the value from cache and maps it with selector. It changes `null` to `undefined` for `useOnyx` compatibility.
const value = tryGetCachedValue(key) as OnyxValue<TKey>;
const selectedValue = selectorRef.current ? selectorRef.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.
// It will be used later to check if we need to log an alert that the value is missing.
isOnyxValueDefined = value !== null && value !== undefined;

// 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.
Expand All @@ -255,7 +258,7 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<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 TReturnValue;
newValueRef.current = options.initialValue;
newFetchStatus = 'loaded';
}

Expand All @@ -269,7 +272,7 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<TKey>>(
areValuesEqual = shallowEqual(previousValueRef.current ?? undefined, newValueRef.current);
}

// We updated the cached value and the result in the following conditions:
// We update the cached value and the result in the following conditions:
// We will update the cached value and the result in any of the following situations:
// - The previously cached value is different from the new value.
// - The previously cached value is `null` (not set from cache yet) and we have cache for this key
Expand All @@ -280,11 +283,18 @@ function useOnyx<TKey extends OnyxKey, TReturnValue = OnyxValue<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 ?? undefined, {status: newFetchStatus ?? 'loaded'}];
const newStatus = newFetchStatus ?? 'loaded';
resultRef.current = [previousValueRef.current ?? undefined, {status: newStatus}];

// If `canBeMissing` is set to `false` and the Onyx value of that key is not defined,
// we log an alert so it can be acknowledged by the consumer.
if (options?.canBeMissing === false && newStatus === 'loaded' && !isOnyxValueDefined) {
Logger.logAlert(`useOnyx returned no data for key with canBeMissing set to false.`, {key, showAlert: true});
}
}

return resultRef.current;
}, [options?.initWithStoredValues, options?.allowStaleData, options?.initialValue, key, selectorRef]);
}, [options?.initWithStoredValues, options?.allowStaleData, options?.initialValue, options?.canBeMissing, key, selectorRef]);

const subscribe = useCallback(
(onStoreChange: () => void) => {
Expand Down
130 changes: 130 additions & 0 deletions tests/unit/useOnyxTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import OnyxUtils from '../../lib/OnyxUtils';
import StorageMock from '../../lib/storage';
import type GenericCollection from '../utils/GenericCollection';
import waitForPromisesToResolve from '../utils/waitForPromisesToResolve';
import * as Logger from '../../lib/Logger';

const ONYXKEYS = {
TEST_KEY: 'test',
Expand Down Expand Up @@ -749,6 +750,135 @@ describe('useOnyx', () => {
});
});

describe('canBeMissing', () => {
let logAlertFn = jest.fn();
const alertMessage = 'useOnyx returned no data for key with canBeMissing set to false.';

beforeEach(() => {
logAlertFn = jest.fn();
jest.spyOn(Logger, 'logAlert').mockImplementation(logAlertFn);
});

afterEach(() => {
(Logger.logAlert as unknown as jest.SpyInstance<void, Parameters<typeof Logger.logAlert>>).mockRestore();
});

it('should not log an alert if Onyx doesn\'t return data in loaded state and "canBeMissing" property is not provided', async () => {
const {result: result1} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY));

expect(result1.current[0]).toBeUndefined();
expect(result1.current[1].status).toEqual('loading');

await act(async () => waitForPromisesToResolve());

expect(result1.current[0]).toBeUndefined();
expect(result1.current[1].status).toEqual('loaded');
expect(logAlertFn).not.toBeCalledWith(alertMessage);

await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, 'test'));

expect(result1.current[0]).toBe('test');

await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, null));

expect(result1.current[0]).toBeUndefined();
expect(logAlertFn).not.toBeCalledWith(alertMessage);
});

it('should not log an alert if Onyx doesn\'t return data, "canBeMissing" property is false but "initWithStoredValues" is also false', async () => {
const {result: result1} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY, {canBeMissing: false, initWithStoredValues: false}));

expect(result1.current[0]).toBeUndefined();
expect(result1.current[1].status).toEqual('loaded');

expect(logAlertFn).not.toBeCalledWith(alertMessage);
});

it('should log an alert if Onyx doesn\'t return data in loaded state and "canBeMissing" property is false', async () => {
const {result: result1} = renderHook(() => useOnyx(ONYXKEYS.TEST_KEY, {canBeMissing: false}));

expect(result1.current[0]).toBeUndefined();
expect(result1.current[1].status).toEqual('loading');
expect(logAlertFn).not.toBeCalledWith(alertMessage);

await act(async () => waitForPromisesToResolve());

expect(result1.current[0]).toBeUndefined();
expect(result1.current[1].status).toEqual('loaded');
expect(logAlertFn).toHaveBeenCalledTimes(1);
expect(logAlertFn).toHaveBeenNthCalledWith(1, alertMessage, {key: ONYXKEYS.TEST_KEY, showAlert: true});

await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, 'test'));

expect(result1.current[0]).toBe('test');

await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, null));

expect(result1.current[0]).toBeUndefined();
expect(logAlertFn).toHaveBeenCalledTimes(2);
expect(logAlertFn).toHaveBeenNthCalledWith(2, alertMessage, {key: ONYXKEYS.TEST_KEY, showAlert: true});
});

it('should log an alert if Onyx doesn\'t return selected data in loaded state and "canBeMissing" property is false', async () => {
const {result: result1} = renderHook(() =>
useOnyx(ONYXKEYS.TEST_KEY, {
// @ts-expect-error bypass
selector: (entry: OnyxEntry<string>) => (entry ? `${entry}_changed` : undefined),
canBeMissing: false,
}),
);

expect(result1.current[0]).toBeUndefined();
expect(result1.current[1].status).toEqual('loading');
expect(logAlertFn).not.toBeCalledWith(alertMessage);

await act(async () => waitForPromisesToResolve());

expect(result1.current[0]).toBeUndefined();
expect(result1.current[1].status).toEqual('loaded');
expect(logAlertFn).toHaveBeenCalledTimes(1);
expect(logAlertFn).toHaveBeenNthCalledWith(1, alertMessage, {key: ONYXKEYS.TEST_KEY, showAlert: true});

await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, 'test'));

expect(result1.current[0]).toBe('test_changed');

await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, null));

expect(result1.current[0]).toBeUndefined();
expect(logAlertFn).toHaveBeenCalledTimes(2);
expect(logAlertFn).toHaveBeenNthCalledWith(2, alertMessage, {key: ONYXKEYS.TEST_KEY, showAlert: true});
});

it('should log an alert if Onyx doesn\'t return data but there is a selector that always return something and "canBeMissing" property is false', async () => {
const {result: result1} = renderHook(() =>
useOnyx(ONYXKEYS.TEST_KEY, {
// @ts-expect-error bypass
// This selector will always return a value, even if the Onyx data is missing.
selector: (entry: OnyxEntry<string>) => `${entry}_changed`,
canBeMissing: false,
}),
);

await act(async () => waitForPromisesToResolve());

expect(result1.current[0]).toBe('undefined_changed');
expect(result1.current[1].status).toEqual('loaded');
expect(logAlertFn).toHaveBeenCalledTimes(1);
expect(logAlertFn).toHaveBeenNthCalledWith(1, alertMessage, {key: ONYXKEYS.TEST_KEY, showAlert: true});

await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, 'test'));

expect(result1.current[0]).toBe('test_changed');

await act(async () => Onyx.set(ONYXKEYS.TEST_KEY, null));

expect(result1.current[0]).toBe('undefined_changed');
expect(logAlertFn).toHaveBeenCalledTimes(2);
expect(logAlertFn).toHaveBeenNthCalledWith(2, alertMessage, {key: ONYXKEYS.TEST_KEY, showAlert: true});
});
});

// This test suite must be the last one to avoid problems when running the other tests here.
describe('canEvict', () => {
const error = (key: string) => `canEvict can't be used on key '${key}'. This key must explicitly be flagged as safe for removal by adding it to Onyx.init({safeEvictionKeys: []}).`;
Expand Down