diff --git a/.nvmrc b/.nvmrc index d5a15960..48b14e6b 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.10.0 +20.14.0 diff --git a/API-INTERNAL.md b/API-INTERNAL.md index aae82ccc..db3e45f5 100644 --- a/API-INTERNAL.md +++ b/API-INTERNAL.md @@ -115,7 +115,8 @@ whatever it is we attempted to do.

removeNullValues()

Removes a key from storage if the value is null. -Otherwise removes all nested null values in objects and returns the object

+Otherwise removes all nested null values in objects, +if shouldRemoveNestedNulls is true and returns the object.

prepareKeyValuePairsForStorage()

Storage expects array like: [["@MyApp_user", value_1], ["@MyApp_key", value_2]] @@ -367,7 +368,8 @@ Notifies subscribers and writes current value to cache ## removeNullValues() ⇒ Removes a key from storage if the value is null. -Otherwise removes all nested null values in objects and returns the object +Otherwise removes all nested null values in objects, +if shouldRemoveNestedNulls is true and returns the object. **Kind**: global function **Returns**: The value without null values and a boolean "wasRemoved", which indicates if the key got removed completely diff --git a/README.md b/README.md index 52461ffb..f8291991 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Awesome persistent storage solution wrapped in a Pub/Sub library. - Onyx allows other code to subscribe to changes in data, and then publishes change events whenever data is changed - Anything needing to read Onyx data needs to: 1. Know what key the data is stored in (for web, you can find this by looking in the JS console > Application > local storage) - 2. Subscribe to changes of the data for a particular key or set of keys. React components use `withOnyx()` and non-React libs use `Onyx.connect()`. + 2. Subscribe to changes of the data for a particular key or set of keys. React function components use the `useOnyx()` hook (recommended), both class and function components can use `withOnyx()` HOC (deprecated, not-recommended) and non-React libs use `Onyx.connect()`. 3. Get initialized with the current value of that key from persistent storage (Onyx does this by calling `setState()` or triggering the `callback` with the values currently on disk as part of the connection process) - Subscribing to Onyx keys is done using a constant defined in `ONYXKEYS`. Each Onyx key represents either a collection of items or a specific entry in storage. For example, since all reports are stored as individual keys like `report_1234`, if code needs to know about all the reports (e.g. display a list of them in the nav menu), then it would subscribe to the key `ONYXKEYS.COLLECTION.REPORT`. @@ -116,7 +116,41 @@ To teardown the subscription call `Onyx.disconnect()` with the `connectionID` re Onyx.disconnect(connectionID); ``` -We can also access values inside React components via the `withOnyx()` [higher order component](https://reactjs.org/docs/higher-order-components.html). When the data changes the component will re-render. +We can also access values inside React function components via the `useOnyx()` [hook](https://react.dev/reference/react/hooks) (recommended) or class and function components via the `withOnyx()` [higher order component](https://reactjs.org/docs/higher-order-components.html) (deprecated, not-recommended). When the data changes the component will re-render. + +```javascript +import React from 'react'; +import {useOnyx} from 'react-native-onyx'; + +const App = () => { + const [session] = useOnyx('session'); + + return ( + + {session.token ? Logged in : Logged out} + + ); +}; + +export default App; +``` + +The `useOnyx()` hook won't delay the rendering of the component using it while the key/entity is being fetched and passed to the component. However, you can simulate this behavior by checking if the `status` of the hook's result metadata is `loading`. When `status` is `loading` it means that the Onyx data is being loaded into cache and thus is not immediately available, while `loaded` means that the data is already loaded and available to be consumed. + +```javascript +const [reports, reportsResult] = useOnyx(ONYXKEYS.COLLECTION.REPORT); +const [session, sessionResult] = useOnyx(ONYXKEYS.SESSION); + +if (reportsResult.status === 'loading' || sessionResult.status === 'loading') { + return ; // or `null` if you don't want to render anything. +} + +// rest of the component's code. +``` + +> [!warning] +> ## Deprecated Note +> Please note that the `withOnyx()` Higher Order Component (HOC) is now considered deprecated. Use `useOnyx()` hook instead. ```javascript import React from 'react'; @@ -135,7 +169,7 @@ export default withOnyx({ })(App); ``` -While `Onyx.connect()` gives you more control on how your component reacts as data is fetched from disk, `withOnyx()` will delay the rendering of the wrapped component until all keys/entities have been fetched and passed to the component, this can be convenient for simple cases. This however, can really delay your application if many entities are connected to the same component, you can pass an `initialValue` to each key to allow Onyx to eagerly render your component with this value. +Differently from `useOnyx()`, `withOnyx()` will delay the rendering of the wrapped component until all keys/entities have been fetched and passed to the component, this can be convenient for simple cases. This however, can really delay your application if many entities are connected to the same component, you can pass an `initialValue` to each key to allow Onyx to eagerly render your component with this value. ```javascript export default withOnyx({ @@ -146,7 +180,9 @@ export default withOnyx({ })(App); ``` -Additionally, if your component has many keys/entities when your component will mount but will receive many updates as data is fetched from DB and passed down to it, as every key that gets fetched will trigger a `setState` on the `withOnyx` HOC. This might cause re-renders on the initial mounting, preventing the component from mounting/rendering in reasonable time, making your app feel slow and even delaying animations. You can workaround this by passing an additional object with the `shouldDelayUpdates` property set to true. Onyx will then put all the updates in a queue until you decide when then should be applied, the component will receive a function `markReadyForHydration`. A good place to call this function is on the `onLayout` method, which gets triggered after your component has been rendered. +Additionally, if your component has many keys/entities when your component will mount but will receive many updates as data is fetched from DB and passed down to it, as every key that gets fetched will trigger a `setState` on the `withOnyx` HOC. This might cause re-renders on the initial mounting, preventing the component from mounting/rendering in reasonable time, making your app feel slow and even delaying animations. + +You can workaround this by passing an additional object with the `shouldDelayUpdates` property set to true. Onyx will then put all the updates in a queue until you decide when then should be applied, the component will receive a function `markReadyForHydration`. A good place to call this function is on the `onLayout` method, which gets triggered after your component has been rendered. ```javascript const App = ({session, markReadyForHydration}) => ( @@ -164,27 +200,43 @@ export default withOnyx({ }, true)(App); ``` -### Dependent Onyx Keys and withOnyx() +### Dependent Onyx Keys and useOnyx() Some components need to subscribe to multiple Onyx keys at once and sometimes, one key might rely on the data from another key. This is similar to a JOIN in SQL. Example: To get the policy of a report, the `policy` key depends on the `report` key. ```javascript -export default withOnyx({ - report: { - key: ({reportID) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - }, - policy: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, - }, -})(App); +const App = ({reportID}) => { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`); + + return ( + + {/* Render with policy data */} + + ); +}; + +export default App; ``` -Background info: -- The `key` value can be a function that returns the key that Onyx subscribes to -- The first argument to the `key` function is the `props` from the component +**Detailed explanation of how this is handled and rendered with `useOnyx()`:** + +1. The component mounts with a `reportID={1234}` prop. +2. The `useOnyx` hook evaluates the mapping and subscribes to the key `reports_1234` using the `reportID` prop. +3. The `useOnyx` hook fetches the data for the key `reports_1234` from Onyx and sets the state with the initial value (if provided). +4. Since `report` is not defined yet, `report?.policyID` defaults to `undefined`. The `useOnyx` hook subscribes to the key `policies_undefined`. +5. The `useOnyx` hook reads the data and updates the state of the component: + - `report={{reportID: 1234, policyID: 1, ...rest of the object...}}` + - `policy={undefined}` (since there is no policy with ID `undefined`) +6. The `useOnyx` hook again evaluates the key `policies_1` after fetching the updated `report` object which has `policyID: 1`. +7. The `useOnyx` hook reads the data and updates the state with: + - `policy={{policyID: 1, ...rest of the object...}}` +8. Now, all mappings have values that are defined (not undefined), and the component is rendered with all necessary data. + +* It is VERY important to NOT use empty string default values like `report.policyID || ''`. This results in the key returned to `useOnyx` as `policies_`, which subscribes to the ENTIRE POLICY COLLECTION and is most assuredly not what you were intending. You can use a default of `0` (as long as you are reasonably sure that there is never a policyID=0). This allows Onyx to return `undefined` as the value of the policy key, which is handled by `useOnyx` appropriately. -**Detailed explanation of how this is handled and rendered:** +**Detailed explanation of how this is handled and rendered with `withOnyx` HOC:** 1. The component mounts with a `reportID={1234}` prop 2. `withOnyx` evaluates the mapping 3. `withOnyx` connects to the key `reports_1234` because of the prop passed to the component @@ -239,15 +291,23 @@ Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, { There are several ways to subscribe to these keys: ```javascript -withOnyx({ - allReports: {key: ONYXKEYS.COLLECTION.REPORT}, -})(MyComponent); +const MyComponent = () => { + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + + return ( + + {/* Render with allReports data */} + + ); +}; + +export default MyComponent; ``` This will add a prop to the component called `allReports` which is an object of collection member key/values. Changes to the individual member keys will modify the entire object and new props will be passed with each individual key update. The prop doesn't update on the initial rendering of the component until the entire collection has been read out of Onyx. ```js -Onyx.connect({key: ONYXKEYS.COLLECTION.REPORT}, callback: (memberValue, memberKey) => {...}}); +Onyx.connect({key: ONYXKEYS.COLLECTION.REPORT}, callback: (memberValue, memberKey) => {...}); ``` This will fire the callback once per member key depending on how many collection member keys are currently stored. Changes to those keys after the initial callbacks fire will occur when each individual key is updated. @@ -256,11 +316,11 @@ This will fire the callback once per member key depending on how many collection Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, - callback: (allReports) => {...}}, + callback: (allReports) => {...}, }); ``` -This final option forces `Onyx.connect()` to behave more like `withOnyx()` and only update the callback once with the entire collection initially and later with an updated version of the collection when individual keys update. +This final option forces `Onyx.connect()` to behave more like `useOnyx()` and only update the callback once with the entire collection initially and later with an updated version of the collection when individual keys update. ### Performance Considerations When Using Collections @@ -270,12 +330,12 @@ Remember, `mergeCollection()` will notify a subscriber only *once* with the tota ```js // Bad -_.each(reports, report => Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report)); // -> A component using withOnyx() will have it's state updated with each iteration +_.each(reports, report => Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report)); // -> A component using useOnyx() will have it's state updated with each iteration // Good const values = {}; _.each(reports, report => values[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`] = report); -Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, values); // -> A component using withOnyx() will only have it's state updated once +Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, values); // -> A component using useOnyx() will only have its state updated once ``` ## Clean up @@ -325,12 +385,20 @@ Onyx.init({ ``` ```js -export default withOnyx({ - reportActions: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}_`, - canEvict: props => !props.isActiveReport, - }, -})(ReportActionsView); +const ReportActionsView = ({reportID, isActiveReport}) => { + const [reportActions] = useOnyx( + `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}_`, + {canEvict: () => !isActiveReport} + ); + + return ( + + {/* Render with reportActions data */} + + ); +}; + +export default ReportActionsView; ``` # Benchmarks diff --git a/lib/Onyx.ts b/lib/Onyx.ts index 544d5a82..d6d011f3 100644 --- a/lib/Onyx.ts +++ b/lib/Onyx.ts @@ -15,15 +15,17 @@ import type { InitOptions, KeyValueMapping, Mapping, - MixedOperationsQueue, - NonUndefined, - NullableKeyValueMapping, - NullishDeep, + OnyxInputKeyValueMapping, OnyxCollection, - OnyxEntry, + MixedOperationsQueue, OnyxKey, + OnyxMergeCollectionInput, + OnyxMergeInput, + OnyxMultiSetInput, + OnyxSetInput, OnyxUpdate, OnyxValue, + OnyxInput, } from './types'; import OnyxUtils from './OnyxUtils'; import logMessages from './logMessages'; @@ -47,7 +49,7 @@ function init({ if (shouldSyncMultipleInstances) { Storage.keepInstancesSync?.((key, value) => { - const prevValue = cache.getValue(key, false) as OnyxValue; + const prevValue = cache.get(key, false) as OnyxValue; cache.set(key, value); OnyxUtils.keyChanged(key, value as OnyxValue, prevValue); }); @@ -113,7 +115,7 @@ function connect(connectOptions: ConnectOptions): nu // Performance improvement // If the mapping is connected to an onyx key that is not a collection // we can skip the call to getAllKeys() and return an array with a single item - if (Boolean(mapping.key) && typeof mapping.key === 'string' && !mapping.key.endsWith('_') && cache.storageKeys.has(mapping.key)) { + if (Boolean(mapping.key) && typeof mapping.key === 'string' && !mapping.key.endsWith('_') && cache.getAllKeys().has(mapping.key)) { return new Set([mapping.key]); } return OnyxUtils.getAllKeys(); @@ -130,12 +132,12 @@ function connect(connectOptions: ConnectOptions): nu // component. This null value will be filtered out so that the connected component can utilize defaultProps. if (matchingKeys.length === 0) { if (mapping.key && !OnyxUtils.isCollectionKey(mapping.key)) { - cache.set(mapping.key, null); + cache.addNullishStorageKey(mapping.key); } - // Here we cannot use batching because the null value is expected to be set immediately for default props + // Here we cannot use batching because the nullish value is expected to be set immediately for default props // or they will be undefined. - OnyxUtils.sendDataToConnection(mapping, null as OnyxValue, undefined, false); + OnyxUtils.sendDataToConnection(mapping, null, undefined, false); return; } @@ -211,9 +213,25 @@ function disconnect(connectionID: number, keyToRemoveFromEvictionBlocklist?: Ony * @param key ONYXKEY to set * @param value value to store */ -function set(key: TKey, value: NonUndefined>): Promise { - // check if the value is compatible with the existing value in the storage - const existingValue = cache.getValue(key, false); +function set(key: TKey, value: OnyxSetInput): Promise { + // When we use Onyx.set to set a key we want to clear the current delta changes from Onyx.merge that were queued + // before the value was set. If Onyx.merge is currently reading the old value from storage, it will then not apply the changes. + if (OnyxUtils.hasPendingMergeForKey(key)) { + delete OnyxUtils.getMergeQueue()[key]; + } + + // Onyx.set will ignore `undefined` values as inputs, therefore we can return early. + if (value === undefined) { + return Promise.resolve(); + } + + const existingValue = cache.get(key, false); + // If the existing value as well as the new value are null, we can return early. + if (existingValue === undefined && value === null) { + return Promise.resolve(); + } + + // Check if the value is compatible with the existing value in the storage const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(value, existingValue); if (!isCompatible) { Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'set', existingValueType, newValueType)); @@ -222,22 +240,29 @@ function set(key: TKey, value: NonUndefined; - if (OnyxUtils.hasPendingMergeForKey(key)) { - delete OnyxUtils.getMergeQueue()[key]; + const logSetCall = (hasChanged = true) => { + // Logging properties only since values could be sensitive things we don't want to log + Logger.logInfo(`set called for key: ${key}${_.isObject(value) ? ` properties: ${_.keys(value).join(',')}` : ''} hasChanged: ${hasChanged}`); + }; + + // Calling "OnyxUtils.removeNullValues" removes the key from storage and cache and updates the subscriber. + // Therefore, we don't need to further broadcast and update the value so we can return early. + if (wasRemoved) { + logSetCall(); + return Promise.resolve(); } + const valueWithoutNullValues = valueAfterRemoving as OnyxValue; const hasChanged = cache.hasValueChanged(key, valueWithoutNullValues); - // Logging properties only since values could be sensitive things we don't want to log - Logger.logInfo(`set called for key: ${key}${_.isObject(value) ? ` properties: ${_.keys(value).join(',')}` : ''} hasChanged: ${hasChanged}`); + logSetCall(hasChanged); // This approach prioritizes fast UI changes without waiting for data to be stored in device storage. - const updatePromise = OnyxUtils.broadcastUpdate(key, valueWithoutNullValues, hasChanged, wasRemoved); + const updatePromise = OnyxUtils.broadcastUpdate(key, valueWithoutNullValues, hasChanged); // If the value has not changed or the key got removed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead. - if (!hasChanged || wasRemoved) { + if (!hasChanged) { return updatePromise; } @@ -256,18 +281,18 @@ function set(key: TKey, value: NonUndefined): Promise { - const keyValuePairs = OnyxUtils.prepareKeyValuePairsForStorage(data, true); +function multiSet(data: OnyxMultiSetInput): Promise { + const keyValuePairsToSet = OnyxUtils.prepareKeyValuePairsForStorage(data, true); - const updatePromises = keyValuePairs.map(([key, value]) => { - const prevValue = cache.getValue(key, false); + const updatePromises = keyValuePairsToSet.map(([key, value]) => { + const prevValue = cache.get(key, false); // Update cache and optimistically inform subscribers on the next tick cache.set(key, value); return OnyxUtils.scheduleSubscriberUpdate(key, value, prevValue); }); - return Storage.multiSet(keyValuePairs) + return Storage.multiSet(keyValuePairsToSet) .catch((error) => OnyxUtils.evictStorageAndRetry(error, multiSet, data)) .then(() => { OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MULTI_SET, undefined, data); @@ -292,7 +317,7 @@ function multiSet(data: Partial): Promise { * Onyx.merge(ONYXKEYS.POLICY, {id: 1}); // -> {id: 1} * Onyx.merge(ONYXKEYS.POLICY, {name: 'My Workspace'}); // -> {id: 1, name: 'My Workspace'} */ -function merge(key: TKey, changes: NonUndefined>>): Promise { +function merge(key: TKey, changes: OnyxMergeInput): Promise { const mergeQueue = OnyxUtils.getMergeQueue(); const mergeQueuePromise = OnyxUtils.getMergeQueuePromise(); @@ -313,7 +338,7 @@ function merge(key: TKey, changes: NonUndefined { // Calls to Onyx.set after a merge will terminate the current merge process and clear the merge queue if (mergeQueue[key] == null) { - return undefined; + return Promise.resolve(); } try { @@ -325,10 +350,10 @@ function merge(key: TKey, changes: NonUndefined>; if (!validChanges.length) { - return undefined; + return Promise.resolve(); } const batchedDeltaChanges = OnyxUtils.applyMerge(undefined, validChanges, false); @@ -341,9 +366,21 @@ function merge(key: TKey, changes: NonUndefined { + // Logging properties only since values could be sensitive things we don't want to log + Logger.logInfo(`merge called for key: ${key}${_.isObject(batchedDeltaChanges) ? ` properties: ${_.keys(batchedDeltaChanges).join(',')}` : ''} hasChanged: ${hasChanged}`); + }; + // If the batched changes equal null, we want to remove the key from storage, to reduce storage size const {wasRemoved} = OnyxUtils.removeNullValues(key, batchedDeltaChanges); + // Calling "OnyxUtils.removeNullValues" removes the key from storage and cache and updates the subscriber. + // Therefore, we don't need to further broadcast and update the value so we can return early. + if (wasRemoved) { + logMergeCall(); + return Promise.resolve(); + } + // For providers that can't handle delta changes, we need to merge the batched changes with the existing value beforehand. // The "preMergedValue" will be directly "set" in storage instead of being merged // Therefore we merge the batched changes with the existing value to get the final merged value that will be stored. @@ -353,14 +390,13 @@ function merge(key: TKey, changes: NonUndefined, hasChanged, wasRemoved); + const updatePromise = OnyxUtils.broadcastUpdate(key, preMergedValue as OnyxValue, hasChanged); // If the value has not changed, calling Storage.setItem() would be redundant and a waste of performance, so return early instead. - if (!hasChanged || wasRemoved) { + if (!hasChanged) { return updatePromise; } @@ -390,13 +426,13 @@ function merge(key: TKey, changes: NonUndefined(collectionKey: TKey, collection: Collection>): Promise { +function mergeCollection(collectionKey: TKey, collection: OnyxMergeCollectionInput): Promise { // Gracefully handle bad mergeCollection updates so it doesn't block the merge queue if (!OnyxUtils.isValidMergeCollection(collectionKey, collection)) { return Promise.resolve(); } - const mergedCollection: NullableKeyValueMapping = collection; + const mergedCollection: OnyxInputKeyValueMapping = collection; return OnyxUtils.getAllKeys() .then((persistedKeys) => { @@ -415,7 +451,7 @@ function mergeCollection(collectionKey: TK const newKeys = keys.filter((key) => !persistedKeys.has(key)); - const existingKeyCollection = existingKeys.reduce((obj: NullableKeyValueMapping, key) => { + const existingKeyCollection = existingKeys.reduce((obj: OnyxInputKeyValueMapping, key) => { const {isCompatible, existingValueType, newValueType} = utils.checkCompatibilityWithExistingValue(mergedCollection[key], cachedCollectionForExistingKeys[key]); if (!isCompatible) { Logger.logAlert(logMessages.incompatibleUpdateAlert(key, 'mergeCollection', existingValueType, newValueType)); @@ -424,13 +460,13 @@ function mergeCollection(collectionKey: TK // eslint-disable-next-line no-param-reassign obj[key] = mergedCollection[key]; return obj; - }, {}); + }, {}) as Record>; - const newCollection = newKeys.reduce((obj: NullableKeyValueMapping, key) => { + const newCollection = newKeys.reduce((obj: OnyxInputKeyValueMapping, key) => { // eslint-disable-next-line no-param-reassign obj[key] = mergedCollection[key]; return obj; - }, {}); + }, {}) as Record>; // When (multi-)merging the values with the existing values in storage, // we don't want to remove nested null values from the data that we pass to the storage layer, @@ -501,9 +537,11 @@ function mergeCollection(collectionKey: TK function clear(keysToPreserve: OnyxKey[] = []): Promise { return OnyxUtils.getAllKeys() .then((keys) => { + cache.clearNullishStorageKeys(); + const keysToBeClearedFromStorage: OnyxKey[] = []; const keyValuesToResetAsCollection: Record> = {}; - const keyValuesToResetIndividually: NullableKeyValueMapping = {}; + const keyValuesToResetIndividually: KeyValueMapping = {}; // The only keys that should not be cleared are: // 1. Anything specifically passed in keysToPreserve (because some keys like language preferences, offline @@ -520,8 +558,8 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { // 2. Figure out whether it is a collection key or not, // since collection key subscribers need to be updated differently if (!isKeyToPreserve) { - const oldValue = cache.getValue(key); - const newValue = defaultKeyStates[key] ?? null; + const oldValue = cache.get(key); + const newValue = defaultKeyStates[key] ?? undefined; if (newValue !== oldValue) { cache.set(key, newValue); const collectionKey = key.substring(0, key.indexOf('_') + 1); @@ -548,7 +586,7 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { // Notify the subscribers for each key/value group so they can receive the new values Object.entries(keyValuesToResetIndividually).forEach(([key, value]) => { - updatePromises.push(OnyxUtils.scheduleSubscriberUpdate(key, value, cache.getValue(key, false))); + updatePromises.push(OnyxUtils.scheduleSubscriberUpdate(key, value, cache.get(key, false))); }); Object.entries(keyValuesToResetAsCollection).forEach(([key, value]) => { updatePromises.push(OnyxUtils.scheduleNotifyCollectionSubscribers(key, value)); @@ -558,7 +596,7 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise { const defaultKeyValuePairs = Object.entries( Object.keys(defaultKeyStates) .filter((key) => !keysToPreserve.includes(key)) - .reduce((obj: NullableKeyValueMapping, key) => { + .reduce((obj: KeyValueMapping, key) => { // eslint-disable-next-line no-param-reassign obj[key] = defaultKeyStates[key]; return obj; diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index f139b041..530f8e66 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -9,7 +9,10 @@ import type {OnyxKey, OnyxValue} from './types'; */ class OnyxCache { /** Cache of all the storage keys available in persistent storage */ - storageKeys: Set; + private storageKeys: Set; + + /** A list of keys where a nullish value has been fetched from storage before, but the key still exists in cache */ + private nullishStorageKeys: Set; /** Unique list of keys maintained in access order (most recent at the end) */ private recentKeys: Set; @@ -28,6 +31,7 @@ class OnyxCache { constructor() { this.storageKeys = new Set(); + this.nullishStorageKeys = new Set(); this.recentKeys = new Set(); this.storageMap = {}; this.pendingPromises = new Map(); @@ -36,9 +40,12 @@ class OnyxCache { bindAll( this, 'getAllKeys', - 'getValue', + 'get', 'hasCacheForKey', 'addKey', + 'addNullishStorageKey', + 'hasNullishStorageKey', + 'clearNullishStorageKeys', 'set', 'drop', 'merge', @@ -57,19 +64,18 @@ class OnyxCache { } /** - * Get a cached value from storage - * @param [shouldReindexCache] – This is an LRU cache, and by default accessing a value will make it become last in line to be evicted. This flag can be used to skip that and just access the value directly without side-effects. + * Allows to set all the keys at once. + * This is useful when we are getting + * all the keys from the storage provider + * and we want to keep the cache in sync. + * + * Previously, we had to call `addKey` in a loop + * to achieve the same result. + * + * @param keys - an array of keys */ - getValue(key: OnyxKey, shouldReindexCache = true): OnyxValue { - if (shouldReindexCache) { - this.addToAccessedKeys(key); - } - return this.storageMap[key]; - } - - /** Check whether cache has data for the given key */ - hasCacheForKey(key: OnyxKey): boolean { - return this.storageMap[key] !== undefined; + setAllKeys(keys: OnyxKey[]) { + this.storageKeys = new Set(keys); } /** Saves a key in the storage keys list @@ -79,6 +85,37 @@ class OnyxCache { this.storageKeys.add(key); } + /** Used to set keys that are null/undefined in storage without adding null to the storage map */ + addNullishStorageKey(key: OnyxKey): void { + this.nullishStorageKeys.add(key); + } + + /** Used to set keys that are null/undefined in storage without adding null to the storage map */ + hasNullishStorageKey(key: OnyxKey): boolean { + return this.nullishStorageKeys.has(key); + } + + /** Used to clear keys that are null/undefined in cache */ + clearNullishStorageKeys(): void { + this.nullishStorageKeys = new Set(); + } + + /** Check whether cache has data for the given key */ + hasCacheForKey(key: OnyxKey): boolean { + return this.storageMap[key] !== undefined || this.hasNullishStorageKey(key); + } + + /** + * Get a cached value from storage + * @param [shouldReindexCache] – This is an LRU cache, and by default accessing a value will make it become last in line to be evicted. This flag can be used to skip that and just access the value directly without side-effects. + */ + get(key: OnyxKey, shouldReindexCache = true): OnyxValue { + if (shouldReindexCache) { + this.addToAccessedKeys(key); + } + return this.storageMap[key]; + } + /** * Set's a key value in cache * Adds the key to the storage keys list as well @@ -86,6 +123,16 @@ class OnyxCache { set(key: OnyxKey, value: OnyxValue): OnyxValue { this.addKey(key); this.addToAccessedKeys(key); + + // When a key is explicitly set in cache, we can remove it from the list of nullish keys, + // since it will either be set to a non nullish value or removed from the cache completely. + this.nullishStorageKeys.delete(key); + + if (value === null || value === undefined) { + delete this.storageMap[key]; + return undefined; + } + this.storageMap[key] = value; return value; @@ -107,27 +154,18 @@ class OnyxCache { throw new Error('data passed to cache.merge() must be an Object of onyx key/value pairs'); } - this.storageMap = {...utils.fastMerge(this.storageMap, data, false)}; + this.storageMap = {...utils.fastMerge(this.storageMap, data)}; - const storageKeys = this.getAllKeys(); - const mergedKeys = Object.keys(data); - this.storageKeys = new Set([...storageKeys, ...mergedKeys]); - mergedKeys.forEach((key) => this.addToAccessedKeys(key)); - } + Object.entries(data).forEach(([key, value]) => { + this.addKey(key); + this.addToAccessedKeys(key); - /** - * Allows to set all the keys at once. - * This is useful when we are getting - * all the keys from the storage provider - * and we want to keep the cache in sync. - * - * Previously, we had to call `addKey` in a loop - * to achieve the same result. - * - * @param keys - an array of keys - */ - setAllKeys(keys: OnyxKey[]) { - this.storageKeys = new Set(keys); + if (value === null || value === undefined) { + this.addNullishStorageKey(key); + } else { + this.nullishStorageKeys.delete(key); + } + }); } /** diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index b52b00b3..175ff469 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -3,34 +3,32 @@ import {deepEqual} from 'fast-equals'; import lodashClone from 'lodash/clone'; import type {ValueOf} from 'type-fest'; +import DevTools from './DevTools'; import * as Logger from './Logger'; +import type Onyx from './Onyx'; import cache from './OnyxCache'; -import * as Str from './Str'; import * as PerformanceUtils from './PerformanceUtils'; -import Storage from './storage'; -import utils from './utils'; +import * as Str from './Str'; import unstable_batchedUpdates from './batch'; -import DevTools from './DevTools'; +import Storage from './storage'; import type { - DeepRecord, - Mapping, CollectionKey, CollectionKeyBase, - NullableKeyValueMapping, + DeepRecord, + DefaultConnectCallback, + DefaultConnectOptions, + KeyValueMapping, + Mapping, + OnyxCollection, + OnyxEntry, + OnyxInput, OnyxKey, OnyxValue, Selector, - WithOnyxInstanceState, - OnyxCollection, WithOnyxConnectOptions, - DefaultConnectOptions, - OnyxEntry, - KeyValueMapping, - DefaultConnectCallback, - Collection, - NullishDeep, } from './types'; -import type Onyx from './Onyx'; +import utils from './utils'; +import type {WithOnyxState} from './withOnyx/types'; // Method constants const METHOD = { @@ -105,7 +103,7 @@ function getDefaultKeyStates(): Record> { * @param initialKeyStates - initial data to set when `init()` and `clear()` are called * @param safeEvictionKeys - This is an array of keys (individual or collection patterns) that when provided to Onyx are flagged as "safe" for removal. */ -function initStoreValues(keys: DeepRecord, initialKeyStates: Partial, safeEvictionKeys: OnyxKey[]): void { +function initStoreValues(keys: DeepRecord, initialKeyStates: Partial, safeEvictionKeys: OnyxKey[]): void { // We need the value of the collection keys later for checking if a // key is a collection. We store it in a map for faster lookup. const collectionValues = Object.values(keys.COLLECTION ?? {}) as string[]; @@ -197,7 +195,7 @@ function batchUpdates(updates: () => void): Promise { function reduceCollectionWithSelector( collection: OnyxCollection, selector: Selector, - withOnyxInstanceState: WithOnyxInstanceState | undefined, + withOnyxInstanceState: WithOnyxState | undefined, ): Record { return Object.entries(collection ?? {}).reduce((finalCollection: Record, [key, item]) => { // eslint-disable-next-line no-param-reassign @@ -208,36 +206,41 @@ function reduceCollectionWithSelector> { +function get>(key: TKey): Promise { // When we already have the value in cache - resolve right away if (cache.hasCacheForKey(key)) { - return Promise.resolve(cache.getValue(key)); + return Promise.resolve(cache.get(key) as TValue); } const taskName = `get:${key}`; // When a value retrieving task for this key is still running hook to it if (cache.hasPendingTask(taskName)) { - return cache.getTaskPromise(taskName) as Promise>; + return cache.getTaskPromise(taskName) as Promise; } // Otherwise retrieve the value from storage and capture a promise to aid concurrent usages const promise = Storage.getItem(key) .then((val) => { + if (val === undefined) { + cache.addNullishStorageKey(key); + return undefined; + } + cache.set(key, val); return val; }) .catch((err) => Logger.logInfo(`Unable to get item from persistent storage. Key: ${key} Error: ${err}`)); - return cache.captureTask(taskName, promise); + return cache.captureTask(taskName, promise) as Promise; } /** Returns current key names stored in persisted storage */ function getAllKeys(): Promise> { // When we've already read stored keys, resolve right away - const storedKeys = cache.getAllKeys(); - if (storedKeys.size > 0) { - return Promise.resolve(storedKeys); + const cachedKeys = cache.getAllKeys(); + if (cachedKeys.size > 0) { + return Promise.resolve(cachedKeys); } const taskName = 'getAllKeys'; @@ -250,6 +253,7 @@ function getAllKeys(): Promise> { // Otherwise retrieve the keys from storage and capture a promise to aid concurrent usages const promise = Storage.getAllKeys().then((keys) => { cache.setAllKeys(keys); + // return the updated set of keys return cache.getAllKeys(); }); @@ -309,7 +313,7 @@ function isSafeEvictionKey(testKey: OnyxKey): boolean { * If the requested key is a collection, it will return an object with all the collection members. */ function tryGetCachedValue(key: TKey, mapping?: Partial>): OnyxValue { - let val = cache.getValue(key); + let val = cache.get(key); if (isCollectionKey(key)) { const allCacheKeys = cache.getAllKeys(); @@ -322,7 +326,7 @@ function tryGetCachedValue(key: TKey, mapping?: Partial k.startsWith(key)); const values = matchingKeys.reduce((finalObject: NonNullable>, matchedKey) => { - const cachedValue = cache.getValue(matchedKey); + const cachedValue = cache.get(matchedKey); if (cachedValue) { // This is permissible because we're in the process of constructing the final object in a reduce function. // eslint-disable-next-line no-param-reassign @@ -423,7 +427,13 @@ function getCachedCollection(collectionKey: TKey return; } - collection[key] = cache.getValue(key); + const cachedValue = cache.get(key); + + if (cachedValue === undefined && !cache.hasNullishStorageKey(key)) { + return; + } + + collection[key] = cache.get(key); }); return collection; @@ -435,21 +445,15 @@ function getCachedCollection(collectionKey: TKey function keysChanged( collectionKey: TKey, partialCollection: OnyxCollection, - previousPartialCollection: OnyxCollection | undefined, + partialPreviousCollection: OnyxCollection | undefined, notifyRegularSubscibers = true, notifyWithOnyxSubscibers = true, ): void { - const previousCollectionWithoutNestedNulls = previousPartialCollection === undefined ? {} : (utils.removeNestedNullValues(previousPartialCollection) as Record); - // We prepare the "cached collection" which is the entire collection + the new partial data that // was merged in via mergeCollection(). const cachedCollection = getCachedCollection(collectionKey); - const cachedCollectionWithoutNestedNulls = utils.removeNestedNullValues(cachedCollection) as Record; - // If the previous collection equals the new collection then we do not need to notify any subscribers. - if (previousPartialCollection !== undefined && deepEqual(cachedCollectionWithoutNestedNulls, previousCollectionWithoutNestedNulls)) { - return; - } + const previousCollection = partialPreviousCollection ?? {}; // We are iterating over all subscribers similar to keyChanged(). However, we are looking for subscribers who are subscribing to either a collection key or // individual collection key member for the collection that is being updated. It is important to note that the collection parameter cane be a PARTIAL collection @@ -486,7 +490,7 @@ function keysChanged( // send the whole cached collection. if (isSubscribedToCollectionKey) { if (subscriber.waitForCollectionCallback) { - subscriber.callback(cachedCollectionWithoutNestedNulls); + subscriber.callback(cachedCollection); continue; } @@ -496,11 +500,11 @@ function keysChanged( for (let j = 0; j < dataKeys.length; j++) { const dataKey = dataKeys[j]; - if (deepEqual(cachedCollectionWithoutNestedNulls[dataKey], previousCollectionWithoutNestedNulls[dataKey])) { + if (deepEqual(cachedCollection[dataKey], previousCollection[dataKey])) { continue; } - subscriber.callback(cachedCollectionWithoutNestedNulls[dataKey], dataKey); + subscriber.callback(cachedCollection[dataKey], dataKey); } continue; } @@ -508,12 +512,12 @@ function keysChanged( // And if the subscriber is specifically only tracking a particular collection member key then we will // notify them with the cached data for that key only. if (isSubscribedToCollectionMemberKey) { - if (deepEqual(cachedCollectionWithoutNestedNulls[subscriber.key], previousCollectionWithoutNestedNulls[subscriber.key])) { + if (deepEqual(cachedCollection[subscriber.key], previousCollection[subscriber.key])) { continue; } const subscriberCallback = subscriber.callback as DefaultConnectCallback; - subscriberCallback(cachedCollectionWithoutNestedNulls[subscriber.key], subscriber.key as TKey); + subscriberCallback(cachedCollection[subscriber.key], subscriber.key as TKey); continue; } @@ -537,24 +541,30 @@ function keysChanged( const previousData = prevState[subscriber.statePropertyName]; const newData = reduceCollectionWithSelector(cachedCollection, collectionSelector, subscriber.withOnyxInstance.state); - if (!deepEqual(previousData, newData)) { - return { - [subscriber.statePropertyName]: newData, - }; + if (deepEqual(previousData, newData)) { + return null; } - return null; + + return { + [subscriber.statePropertyName]: newData, + }; }); continue; } subscriber.withOnyxInstance.setStateProxy((prevState) => { - const finalCollection = lodashClone(prevState?.[subscriber.statePropertyName] ?? {}); + const prevCollection = prevState?.[subscriber.statePropertyName] ?? {}; + const finalCollection = lodashClone(prevCollection); const dataKeys = Object.keys(partialCollection ?? {}); for (let j = 0; j < dataKeys.length; j++) { const dataKey = dataKeys[j]; finalCollection[dataKey] = cachedCollection[dataKey]; } + if (deepEqual(prevCollection, finalCollection)) { + return null; + } + PerformanceUtils.logSetStateCall(subscriber, prevState?.[subscriber.statePropertyName], finalCollection, 'keysChanged', collectionKey); return { [subscriber.statePropertyName]: finalCollection, @@ -565,7 +575,7 @@ function keysChanged( // If a React component is only interested in a single key then we can set the cached value directly to the state name. if (isSubscribedToCollectionMemberKey) { - if (deepEqual(cachedCollectionWithoutNestedNulls[subscriber.key], previousCollectionWithoutNestedNulls[subscriber.key])) { + if (deepEqual(cachedCollection[subscriber.key], previousCollection[subscriber.key])) { continue; } @@ -584,33 +594,35 @@ function keysChanged( subscriber.withOnyxInstance.setStateProxy((prevState) => { const prevData = prevState[subscriber.statePropertyName]; const newData = selector(cachedCollection[subscriber.key], subscriber.withOnyxInstance.state); - if (!deepEqual(prevData, newData)) { - PerformanceUtils.logSetStateCall(subscriber, prevData, newData, 'keysChanged', collectionKey); - return { - [subscriber.statePropertyName]: newData, - }; + + if (deepEqual(prevData, newData)) { + return null; } - return null; + PerformanceUtils.logSetStateCall(subscriber, prevData, newData, 'keysChanged', collectionKey); + return { + [subscriber.statePropertyName]: newData, + }; }); continue; } subscriber.withOnyxInstance.setStateProxy((prevState) => { - const data = cachedCollection[subscriber.key]; - const previousData = prevState[subscriber.statePropertyName]; + const prevData = prevState[subscriber.statePropertyName]; + const newData = cachedCollection[subscriber.key]; // Avoids triggering unnecessary re-renders when feeding empty objects - if (utils.isEmptyObject(data) && utils.isEmptyObject(previousData)) { + if (utils.isEmptyObject(newData) && utils.isEmptyObject(prevData)) { return null; } - if (data === previousData) { + + if (deepEqual(prevData, newData)) { return null; } - PerformanceUtils.logSetStateCall(subscriber, previousData, data, 'keysChanged', collectionKey); + PerformanceUtils.logSetStateCall(subscriber, prevData, newData, 'keysChanged', collectionKey); return { - [subscriber.statePropertyName]: data, + [subscriber.statePropertyName]: newData, }; }); } @@ -656,16 +668,14 @@ function keyChanged( } if (isCollectionKey(subscriber.key) && subscriber.waitForCollectionCallback) { const cachedCollection = getCachedCollection(subscriber.key); - const cachedCollectionWithoutNestedNulls = utils.removeNestedNullValues(cachedCollection) as Record; - cachedCollectionWithoutNestedNulls[key] = value; - subscriber.callback(cachedCollectionWithoutNestedNulls); + cachedCollection[key] = value; + subscriber.callback(cachedCollection); continue; } - const valueWithoutNestedNulls = utils.removeNestedNullValues(value); const subscriberCallback = subscriber.callback as DefaultConnectCallback; - subscriberCallback(valueWithoutNestedNulls, key); + subscriberCallback(value, key); continue; } @@ -690,24 +700,31 @@ function keyChanged( ...prevWithOnyxData, ...newWithOnyxData, }; - if (!deepEqual(prevWithOnyxData, prevDataWithNewData)) { - PerformanceUtils.logSetStateCall(subscriber, prevWithOnyxData, newWithOnyxData, 'keyChanged', key); - return { - [subscriber.statePropertyName]: prevDataWithNewData, - }; + + if (deepEqual(prevWithOnyxData, prevDataWithNewData)) { + return null; } - return null; + + PerformanceUtils.logSetStateCall(subscriber, prevWithOnyxData, newWithOnyxData, 'keyChanged', key); + return { + [subscriber.statePropertyName]: prevDataWithNewData, + }; }); continue; } subscriber.withOnyxInstance.setStateProxy((prevState) => { - const collection = prevState[subscriber.statePropertyName] || {}; + const prevCollection = prevState[subscriber.statePropertyName] || {}; const newCollection = { - ...collection, + ...prevCollection, [key]: value, }; - PerformanceUtils.logSetStateCall(subscriber, collection, newCollection, 'keyChanged', key); + + if (deepEqual(prevCollection, newCollection)) { + return null; + } + + PerformanceUtils.logSetStateCall(subscriber, prevCollection, newCollection, 'keyChanged', key); return { [subscriber.statePropertyName]: newCollection, }; @@ -722,12 +739,13 @@ function keyChanged( const prevValue = selector(previousValue, subscriber.withOnyxInstance.state); const newValue = selector(value, subscriber.withOnyxInstance.state); - if (!deepEqual(prevValue, newValue)) { - return { - [subscriber.statePropertyName]: newValue, - }; + if (deepEqual(prevValue, newValue)) { + return null; } - return null; + + return { + [subscriber.statePropertyName]: newValue, + }; }); continue; } @@ -761,7 +779,7 @@ function keyChanged( * - sets state on the withOnyxInstances * - triggers the callback function */ -function sendDataToConnection(mapping: Mapping, val: OnyxValue, matchedKey: TKey | undefined, isBatched: boolean): void { +function sendDataToConnection(mapping: Mapping, value: OnyxValue | null, matchedKey: TKey | undefined, isBatched: boolean): void { // If the mapping no longer exists then we should not send any data. // This means our subscriber disconnected or withOnyx wrapped component unmounted. if (!callbackToStateMapping[mapping.connectionID]) { @@ -769,15 +787,15 @@ function sendDataToConnection(mapping: Mapping, val: } if ('withOnyxInstance' in mapping && mapping.withOnyxInstance) { - let newData: OnyxValue = val; + let newData: OnyxValue = value; // If the mapping has a selector, then the component's state must only be updated with the data // returned by the selector. if (mapping.selector) { if (isCollectionKey(mapping.key)) { - newData = reduceCollectionWithSelector(val as OnyxCollection, mapping.selector, mapping.withOnyxInstance.state); + newData = reduceCollectionWithSelector(value as OnyxCollection, mapping.selector, mapping.withOnyxInstance.state); } else { - newData = mapping.selector(val, mapping.withOnyxInstance.state); + newData = mapping.selector(value, mapping.withOnyxInstance.state); } } @@ -790,8 +808,13 @@ function sendDataToConnection(mapping: Mapping, val: return; } - const valuesWithoutNestedNulls = utils.removeNestedNullValues(val); - (mapping as DefaultConnectOptions).callback?.(valuesWithoutNestedNulls, matchedKey as TKey); + // When there are no matching keys in "Onyx.connect", we pass null to "sendDataToConnection" explicitly, + // to allow the withOnyx instance to set the value in the state initially and therefore stop the loading state once all + // required keys have been set. + // If we would pass undefined to setWithOnyxInstance instead, withOnyx would not set the value in the state. + // withOnyx will internally replace null values with undefined and never pass null values to wrapped components. + // For regular callbacks, we never want to pass null values, but always just undefined if a value is not set in cache or storage. + (mapping as DefaultConnectOptions).callback?.(value === null ? undefined : value, matchedKey as TKey); } /** @@ -839,7 +862,7 @@ function getCollectionDataAndSendAsObject(matchingKeys: Co * These missingKeys will be later to use to multiGet the data from the storage. */ matchingKeys.forEach((key) => { - const cacheValue = cache.getValue(key) as OnyxValue; + const cacheValue = cache.get(key) as OnyxValue; if (cacheValue) { data[key] = cacheValue; return; @@ -927,9 +950,9 @@ function scheduleNotifyCollectionSubscribers( * Remove a key from Onyx and update the subscribers */ function remove(key: TKey): Promise { - const prevValue = cache.getValue(key, false) as OnyxValue; + const prevValue = cache.get(key, false) as OnyxValue; cache.drop(key); - scheduleSubscriberUpdate(key, null as OnyxValue, prevValue); + scheduleSubscriberUpdate(key, undefined as OnyxValue, prevValue); return Storage.removeItem(key).then(() => undefined); } @@ -981,12 +1004,12 @@ function evictStorageAndRetry(key: TKey, value: OnyxValue, hasChanged?: boolean, wasRemoved = false): Promise { - const prevValue = cache.getValue(key, false) as OnyxValue; +function broadcastUpdate(key: TKey, value: OnyxValue, hasChanged?: boolean): Promise { + const prevValue = cache.get(key, false) as OnyxValue; // Update subscribers if the cached value has changed, or when the subscriber specifically requires // all updates regardless of value changes (indicated by initWithStoredValues set to false). - if (hasChanged && !wasRemoved) { + if (hasChanged) { cache.set(key, value); } else { cache.addToAccessedKeys(key); @@ -999,8 +1022,8 @@ function hasPendingMergeForKey(key: OnyxKey): boolean { return !!mergeQueue[key]; } -type RemoveNullValuesOutput = { - value: Record | unknown[] | null; +type RemoveNullValuesOutput | undefined> = { + value: Value; wasRemoved: boolean; }; @@ -1011,16 +1034,20 @@ type RemoveNullValuesOutput = { * * @returns The value without null values and a boolean "wasRemoved", which indicates if the key got removed completely */ -function removeNullValues(key: OnyxKey, value: OnyxValue, shouldRemoveNestedNulls = true): RemoveNullValuesOutput { +function removeNullValues | undefined>(key: OnyxKey, value: Value, shouldRemoveNestedNulls = true): RemoveNullValuesOutput { if (value === null) { remove(key); return {value, wasRemoved: true}; } + if (value === undefined) { + return {value, wasRemoved: false}; + } + // We can remove all null values in an object by merging it with itself // utils.fastMerge recursively goes through the object and removes all null values // Passing two identical objects as source and target to fastMerge will not change it, but only remove the null values - return {value: shouldRemoveNestedNulls ? utils.removeNestedNullValues(value as Record) : (value as Record), wasRemoved: false}; + return {value: shouldRemoveNestedNulls ? utils.removeNestedNullValues(value) : value, wasRemoved: false}; } /** @@ -1030,11 +1057,11 @@ function removeNullValues(key: OnyxKey, value: OnyxValue, shouldRemoveN * @return an array of key - value pairs <[key, value]> */ -function prepareKeyValuePairsForStorage(data: Record>, shouldRemoveNestedNulls: boolean): Array<[OnyxKey, OnyxValue]> { - return Object.entries(data).reduce]>>((pairs, [key, value]) => { +function prepareKeyValuePairsForStorage(data: Record>, shouldRemoveNestedNulls: boolean): Array<[OnyxKey, OnyxInput]> { + return Object.entries(data).reduce]>>((pairs, [key, value]) => { const {value: valueAfterRemoving, wasRemoved} = removeNullValues(key, value, shouldRemoveNestedNulls); - if (!wasRemoved) { + if (!wasRemoved && valueAfterRemoving !== undefined) { pairs.push([key, valueAfterRemoving]); } @@ -1047,7 +1074,11 @@ function prepareKeyValuePairsForStorage(data: Record * * @param changes Array of changes that should be applied to the existing value */ -function applyMerge(existingValue: OnyxValue, changes: Array>, shouldRemoveNestedNulls: boolean): OnyxValue { +function applyMerge | undefined, TChange extends OnyxInput | undefined>( + existingValue: TValue, + changes: TChange[], + shouldRemoveNestedNulls: boolean, +): TChange { const lastChange = changes?.at(-1); if (Array.isArray(lastChange)) { @@ -1056,15 +1087,12 @@ function applyMerge(existingValue: OnyxValue, changes: Array change && typeof change === 'object')) { // Object values are then merged one after the other - return changes.reduce( - (modifiedData, change) => utils.fastMerge(modifiedData as Record, change as Record, shouldRemoveNestedNulls), - existingValue || {}, - ); + return changes.reduce((modifiedData, change) => utils.fastMerge(modifiedData, change, shouldRemoveNestedNulls), (existingValue || {}) as TChange); } // If we have anything else we can't merge it so we'll // simply return the last value that was queued - return lastChange; + return lastChange as TChange; } /** diff --git a/lib/index.ts b/lib/index.ts index e7e81889..04ec3fb9 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,25 +1,49 @@ +import type {ConnectOptions, OnyxUpdate} from './Onyx'; import Onyx from './Onyx'; -import type {OnyxUpdate, ConnectOptions} from './Onyx'; -import type {CustomTypeOptions, OnyxCollection, OnyxEntry, NullishDeep, KeyValueMapping, OnyxKey, Selector, WithOnyxInstanceState, OnyxValue} from './types'; -import type {UseOnyxResult, FetchStatus, ResultMetadata} from './useOnyx'; +import type { + CustomTypeOptions, + KeyValueMapping, + NullishDeep, + OnyxCollection, + OnyxEntry, + OnyxKey, + OnyxValue, + Selector, + OnyxInputValue, + OnyxCollectionInputValue, + OnyxInput, + OnyxSetInput, + OnyxMultiSetInput, + OnyxMergeInput, + OnyxMergeCollectionInput, +} from './types'; +import type {FetchStatus, ResultMetadata, UseOnyxResult} from './useOnyx'; import useOnyx from './useOnyx'; import withOnyx from './withOnyx'; +import type {WithOnyxState} from './withOnyx/types'; export default Onyx; -export {withOnyx, useOnyx}; +export {useOnyx, withOnyx}; export type { + ConnectOptions, CustomTypeOptions, + FetchStatus, + KeyValueMapping, + NullishDeep, OnyxCollection, OnyxEntry, - OnyxUpdate, - ConnectOptions, - NullishDeep, - KeyValueMapping, OnyxKey, - Selector, - WithOnyxInstanceState, - UseOnyxResult, + OnyxInputValue, + OnyxCollectionInputValue, + OnyxInput, + OnyxSetInput, + OnyxMultiSetInput, + OnyxMergeInput, + OnyxMergeCollectionInput, + OnyxUpdate, OnyxValue, - FetchStatus, ResultMetadata, + Selector, + UseOnyxResult, + WithOnyxState, }; diff --git a/lib/storage/providers/IDBKeyValProvider.ts b/lib/storage/providers/IDBKeyValProvider.ts index f3539b50..9b749ab8 100644 --- a/lib/storage/providers/IDBKeyValProvider.ts +++ b/lib/storage/providers/IDBKeyValProvider.ts @@ -23,7 +23,13 @@ const provider: StorageProvider = { idbKeyValStore = newIdbKeyValStore; }, - setItem: (key, value) => set(key, value, idbKeyValStore), + setItem: (key, value) => { + if (value === null) { + provider.removeItem(key); + } + + return set(key, value, idbKeyValStore); + }, multiGet: (keysParam) => getMany(keysParam, idbKeyValStore).then((values) => values.map((value, index) => [keysParam[index], value])), multiMerge: (pairs) => idbKeyValStore('readwrite', (store) => { @@ -32,7 +38,16 @@ const provider: StorageProvider = { const getValues = Promise.all(pairs.map(([key]) => promisifyRequest>(store.get(key)))); return getValues.then((values) => { - const upsertMany = pairs.map(([key, value], index) => { + const pairsWithoutNull = pairs.filter(([key, value]) => { + if (value === null) { + provider.removeItem(key); + return false; + } + + return true; + }); + + const upsertMany = pairsWithoutNull.map(([key, value], index) => { const prev = values[index]; const newValue = utils.fastMerge(prev as Record, value as Record); return promisifyRequest(store.put(newValue, key)); @@ -44,7 +59,18 @@ const provider: StorageProvider = { // Since Onyx also merged the existing value with the changes, we can just set the value directly return provider.setItem(key, preMergedValue); }, - multiSet: (pairs) => setMany(pairs, idbKeyValStore), + multiSet: (pairs) => { + const pairsWithoutNull = pairs.filter(([key, value]) => { + if (value === null) { + provider.removeItem(key); + return false; + } + + return true; + }); + + return setMany(pairsWithoutNull, idbKeyValStore); + }, clear: () => clear(idbKeyValStore), getAllKeys: () => keys(idbKeyValStore), getItem: (key) => diff --git a/lib/types.ts b/lib/types.ts index 8dc10f07..669256ba 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,7 +1,7 @@ -import type {Component} from 'react'; import type {Merge} from 'type-fest'; import type {BuiltIns} from 'type-fest/source/internal'; import type OnyxUtils from './OnyxUtils'; +import type {WithOnyxInstance, WithOnyxState} from './withOnyx/types'; /** * Utility type that excludes `null` from the type `TValue`. @@ -112,33 +112,6 @@ type CollectionKey = `${CollectionKeyBase}${string}`; */ type OnyxKey = Key | CollectionKey; -/** - * Represents a Onyx value that can be either a single entry or a collection of entries, depending on the `TKey` provided. - */ -type OnyxValue = string extends TKey ? unknown : TKey extends CollectionKeyBase ? OnyxCollection : OnyxEntry; - -/** - * Represents a mapping of Onyx keys to values, where keys are either normal or collection Onyx keys - * and values are the corresponding values in Onyx's state. - * - * For collection keys, `KeyValueMapping` allows any string to be appended - * to the key (e.g., 'report_some-id', 'download_some-id'). - * - * The mapping is derived from the `values` property of the `TypeOptions` type. - */ -type KeyValueMapping = { - [TKey in keyof TypeOptions['values'] as TKey extends CollectionKeyBase ? `${TKey}${string}` : TKey]: TypeOptions['values'][TKey]; -}; - -/** - * Represents a mapping object where each `OnyxKey` maps to either a value of its corresponding type in `KeyValueMapping` or `null`. - * - * It's very similar to `KeyValueMapping` but this type accepts using `null` as well. - */ -type NullableKeyValueMapping = { - [TKey in OnyxKey]: OnyxValue; -}; - /** * Represents a selector function type which operates based on the provided `TKey` and `ReturnType`. * @@ -148,10 +121,10 @@ type NullableKeyValueMapping = { * The type `TKey` extends `OnyxKey` and it is the key used to access a value in `KeyValueMapping`. * `TReturnType` is the type of the returned value from the selector function. */ -type Selector = (value: OnyxEntry, state: WithOnyxInstanceState) => TReturnType; +type Selector = (value: OnyxEntry, state?: WithOnyxState) => TReturnType; /** - * Represents a single Onyx entry, that can be either `TOnyxValue` or `null` / `undefined` if it doesn't exist. + * Represents a single Onyx entry, that can be either `TOnyxValue` or `undefined` if it doesn't exist. * * It can be used to specify data retrieved from Onyx e.g. `withOnyx` HOC mappings. * @@ -178,10 +151,10 @@ type Selector = (value: OnyxEntry * })(Component); * ``` */ -type OnyxEntry = TOnyxValue | null | undefined; +type OnyxEntry = TOnyxValue | undefined; /** - * Represents an Onyx collection of entries, that can be either a record of `TOnyxValue`s or `null` / `undefined` if it is empty or doesn't exist. + * Represents an Onyx collection of entries, that can be either a record of `TOnyxValue`s or `undefined` if it is empty or doesn't exist. * * It can be used to specify collection data retrieved from Onyx e.g. `withOnyx` HOC mappings. * @@ -208,7 +181,25 @@ type OnyxEntry = TOnyxValue | null | undefined; * })(Component); * ``` */ -type OnyxCollection = OnyxEntry>; +type OnyxCollection = OnyxEntry>; + +/** + * Represents a mapping of Onyx keys to values, where keys are either normal or collection Onyx keys + * and values are the corresponding values in Onyx's state. + * + * For collection keys, `KeyValueMapping` allows any string to be appended + * to the key (e.g., 'report_some-id', 'download_some-id'). + * + * The mapping is derived from the `values` property of the `TypeOptions` type. + */ +type KeyValueMapping = { + [TKey in keyof TypeOptions['values'] as TKey extends CollectionKeyBase ? `${TKey}${string}` : TKey]: TypeOptions['values'][TKey]; +}; + +/** + * Represents a Onyx value that can be either a single entry or a collection of entries, depending on the `TKey` provided. + */ +type OnyxValue = string extends TKey ? unknown : TKey extends CollectionKeyBase ? OnyxCollection : OnyxEntry; /** Utility type to extract `TOnyxValue` from `OnyxCollection` */ type ExtractOnyxCollectionValue = TOnyxCollection extends NonNullable> ? U : never; @@ -252,11 +243,6 @@ type NullishObjectDeep = { [KeyType in keyof ObjectType]?: NullishDeep | null; }; -/** - * Represents withOnyx's internal state, containing the Onyx props and a `loading` flag. - */ -type WithOnyxInstanceState = (TOnyxProps & {loading: boolean}) | undefined; - /** * Represents a mapping between Onyx collection keys and their respective values. * @@ -266,7 +252,7 @@ type WithOnyxInstanceState = (TOnyxProps & {loading: boolean}) | und * Also, the `TMap` type is inferred automatically in `mergeCollection()` method and represents * the object of collection keys/values specified in the second parameter of the method. */ -type Collection = { +type Collection = { [MapK in keyof TMap]: MapK extends `${TKey}${string}` ? MapK extends `${TKey}` ? never // forbids empty id @@ -274,11 +260,6 @@ type Collection = { : never; }; -type WithOnyxInstance = Component> & { - setStateProxy: (cb: (state: Record>) => OnyxValue) => void; - setWithOnyxState: (statePropertyName: OnyxKey, value: OnyxValue) => void; -}; - /** Represents the base options used in `Onyx.connect()` method. */ type BaseConnectOptions = { initWithStoredValues?: boolean; @@ -294,7 +275,7 @@ type WithOnyxConnectOptions = { canEvict?: boolean; }; -type DefaultConnectCallback = (value: NonUndefined>, key: TKey) => void; +type DefaultConnectCallback = (value: OnyxEntry, key: TKey) => void; type CollectionConnectCallback = (value: NonUndefined>) => void; @@ -331,6 +312,54 @@ type Mapping = ConnectOptions & { connectionID: number; }; +/** + * Represents a single Onyx input value, that can be either `TOnyxValue` or `null` if the key should be deleted. + * This type is used for data passed to Onyx e.g. in `Onyx.merge` and `Onyx.set`. + */ +type OnyxInputValue = TOnyxValue | null; + +/** + * Represents an Onyx collection input, that can be either a record of `TOnyxValue`s or `null` if the key should be deleted. + */ +type OnyxCollectionInputValue = OnyxInputValue>; + +/** + * Represents an input value that can be passed to Onyx methods, that can be either `TOnyxValue` or `null`. + * Setting a key to `null` will remove the key from the store. + * `undefined` is not allowed for setting values, because it will have no effect on the data. + */ +type OnyxInput = OnyxInputValue>; + +/** + * Represents a mapping object where each `OnyxKey` maps to either a value of its corresponding type in `KeyValueMapping` or `null`. + * + * It's very similar to `KeyValueMapping` but this type is used for inputs to Onyx + * (set, merge, mergeCollection) and therefore accepts using `null` to remove a key from Onyx. + */ +type OnyxInputKeyValueMapping = { + [TKey in OnyxKey]: OnyxInput; +}; + +/** + * This represents the value that can be passed to `Onyx.set` and to `Onyx.update` with the method "SET" + */ +type OnyxSetInput = OnyxInput; + +/** + * This represents the value that can be passed to `Onyx.multiSet` and to `Onyx.update` with the method "MULTI_SET" + */ +type OnyxMultiSetInput = Partial; + +/** + * This represents the value that can be passed to `Onyx.merge` and to `Onyx.update` with the method "MERGE" + */ +type OnyxMergeInput = OnyxInput; + +/** + * This represents the value that can be passed to `Onyx.merge` and to `Onyx.update` with the method "MERGE" + */ +type OnyxMergeCollectionInput = Collection>, TMap>; + /** * Represents different kinds of updates that can be passed to `Onyx.update()` method. It is a discriminated union of * different update methods (`SET`, `MERGE`, `MERGE_COLLECTION`), each with their own key and value structure. @@ -341,17 +370,17 @@ type OnyxUpdate = | { onyxMethod: typeof OnyxUtils.METHOD.SET; key: TKey; - value: NonUndefined>; + value: OnyxSetInput; } | { - onyxMethod: typeof OnyxUtils.METHOD.MERGE; + onyxMethod: typeof OnyxUtils.METHOD.MULTI_SET; key: TKey; - value: NonUndefined>>; + value: OnyxMultiSetInput; } | { - onyxMethod: typeof OnyxUtils.METHOD.MULTI_SET; + onyxMethod: typeof OnyxUtils.METHOD.MERGE; key: TKey; - value: Partial; + value: OnyxMergeInput; } | { onyxMethod: typeof OnyxUtils.METHOD.CLEAR; @@ -363,7 +392,7 @@ type OnyxUpdate = [TKey in CollectionKeyBase]: { onyxMethod: typeof OnyxUtils.METHOD.MERGE_COLLECTION; key: TKey; - value: Record<`${TKey}${string}`, NullishDeep>; + value: OnyxMergeCollectionInput; }; }[CollectionKeyBase]; @@ -375,7 +404,7 @@ type InitOptions = { keys?: DeepRecord; /** initial data to set when `init()` and `clear()` is called */ - initialKeyStates?: Partial; + initialKeyStates?: Partial; /** * This is an array of keys (individual or collection patterns) that when provided to Onyx are flagged @@ -400,6 +429,9 @@ type InitOptions = { debugSetState?: boolean; }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type GenericFunction = (...args: any[]) => any; + /** * Represents a combination of Merge and Set operations that should be executed in Onyx */ @@ -421,22 +453,28 @@ export type { DefaultConnectCallback, DefaultConnectOptions, ExtractOnyxCollectionValue, + GenericFunction, InitOptions, Key, KeyValueMapping, Mapping, NonNull, NonUndefined, - NullableKeyValueMapping, + OnyxInputKeyValueMapping, NullishDeep, OnyxCollection, OnyxEntry, OnyxKey, + OnyxInputValue, + OnyxCollectionInputValue, + OnyxInput, + OnyxSetInput, + OnyxMultiSetInput, + OnyxMergeInput, + OnyxMergeCollectionInput, OnyxUpdate, OnyxValue, Selector, WithOnyxConnectOptions, - WithOnyxInstance, - WithOnyxInstanceState, MixedOperationsQueue, }; diff --git a/lib/types/modules/react.d.ts b/lib/types/modules/react.d.ts new file mode 100644 index 00000000..3e2e8fb3 --- /dev/null +++ b/lib/types/modules/react.d.ts @@ -0,0 +1,6 @@ +import type React from 'react'; + +declare module 'react' { + // eslint-disable-next-line @typescript-eslint/ban-types + function forwardRef(render: (props: P, ref: React.ForwardedRef) => React.ReactElement | null): (props: P & React.RefAttributes) => React.ReactElement | null; +} diff --git a/lib/useOnyx.ts b/lib/useOnyx.ts index 958170bb..230cbafa 100644 --- a/lib/useOnyx.ts +++ b/lib/useOnyx.ts @@ -2,21 +2,11 @@ import {deepEqual} from 'fast-equals'; import {useCallback, useEffect, useRef, useSyncExternalStore} from 'react'; import type {IsEqual} from 'type-fest'; import OnyxUtils from './OnyxUtils'; -import type {CollectionKeyBase, KeyValueMapping, NonNull, OnyxCollection, OnyxEntry, OnyxKey, Selector} from './types'; +import type {CollectionKeyBase, OnyxCollection, OnyxKey, OnyxValue, Selector} from './types'; import useLiveRef from './useLiveRef'; import usePrevious from './usePrevious'; import Onyx from './Onyx'; -/** - * Represents a Onyx value that can be either a single entry or a collection of entries, depending on the `TKey` provided. - * It's a variation of `OnyxValue` type that is read-only and excludes the `null` type. - */ -type UseOnyxValue = string extends TKey - ? unknown - : TKey extends CollectionKeyBase - ? NonNull> - : NonNull>; - type BaseUseOnyxOptions = { /** * Determines if this key in this subscription is safe to be evicted. @@ -55,7 +45,7 @@ type UseOnyxOptions = BaseUseOnyxOptions & U type FetchStatus = 'loading' | 'loaded'; -type CachedValue = IsEqual> extends true ? TValue : TKey extends CollectionKeyBase ? NonNullable> : TValue; +type CachedValue = IsEqual> extends true ? TValue : TKey extends CollectionKeyBase ? NonNullable> : TValue; type ResultMetadata = { status: FetchStatus; @@ -67,15 +57,15 @@ function getCachedValue(key: TKey, selector?: Sele return OnyxUtils.tryGetCachedValue(key, {selector}) as CachedValue | undefined; } -function useOnyx>( +function useOnyx>( key: TKey, options?: BaseUseOnyxOptions & UseOnyxInitialValueOption & Required>, ): UseOnyxResult; -function useOnyx>( +function useOnyx>( key: TKey, options?: BaseUseOnyxOptions & UseOnyxInitialValueOption>, ): UseOnyxResult; -function useOnyx>(key: TKey, options?: UseOnyxOptions): UseOnyxResult { +function useOnyx>(key: TKey, options?: UseOnyxOptions): UseOnyxResult { const connectionIDRef = useRef(null); const previousKey = usePrevious(key); diff --git a/lib/utils.ts b/lib/utils.ts index 64c56c0d..502b4da7 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/prefer-for-of */ -import type {OnyxKey} from './types'; +import type {OnyxInput, OnyxKey} from './types'; type EmptyObject = Record; type EmptyValue = EmptyObject | null | undefined; @@ -27,23 +27,28 @@ function isMergeableObject(value: unknown): value is Record { * @param shouldRemoveNestedNulls - If true, null object values will be removed. * @returns - The merged object. */ -function mergeObject>(target: TObject | null, source: TObject, shouldRemoveNestedNulls = true): TObject { +function mergeObject>(target: TObject | unknown | null | undefined, source: TObject, shouldRemoveNestedNulls = true): TObject { const destination: Record = {}; + const targetObject = isMergeableObject(target) ? target : undefined; + // First we want to copy over all keys from the target into the destination object, // in case "target" is a mergable object. // If "shouldRemoveNestedNulls" is true, we want to remove null values from the merged object // and therefore we need to omit keys where either the source or target value is null. - if (isMergeableObject(target)) { - const targetKeys = Object.keys(target); + if (targetObject) { + const targetKeys = Object.keys(targetObject); for (let i = 0; i < targetKeys.length; ++i) { const key = targetKeys[i]; const sourceValue = source?.[key]; - const targetValue = target?.[key]; + const targetValue = targetObject?.[key]; // If "shouldRemoveNestedNulls" is true, we want to remove null values from the merged object. // Therefore, if either target or source value is null, we want to prevent the key from being set. - const isSourceOrTargetNull = targetValue === null || sourceValue === null; + // targetValue should techincally never be "undefined", because it will always be a value from cache or storage + // and we never set "undefined" there. Still, if there targetValue is undefined we don't want to set + // the key explicitly to prevent loose undefined values in objects in cache and storage. + const isSourceOrTargetNull = targetValue === undefined || targetValue === null || sourceValue === null; const shouldOmitTargetKey = shouldRemoveNestedNulls && isSourceOrTargetNull; if (!shouldOmitTargetKey) { @@ -57,7 +62,7 @@ function mergeObject>(target: TObject | for (let i = 0; i < sourceKeys.length; ++i) { const key = sourceKeys[i]; const sourceValue = source?.[key]; - const targetValue = target?.[key]; + const targetValue = targetObject?.[key]; // If undefined is passed as the source value for a key, we want to generally ignore it. // If "shouldRemoveNestedNulls" is set to true and the source value is null, @@ -92,7 +97,7 @@ function mergeObject>(target: TObject | * On native, when merging an existing value with new changes, SQLite will use JSON_PATCH, which removes top-level nullish values. * To be consistent with the behaviour for merge, we'll also want to remove null values for "set" operations. */ -function fastMerge>(target: TObject | null, source: TObject | null, shouldRemoveNestedNulls = true): TObject | null { +function fastMerge(target: TValue, source: TValue, shouldRemoveNestedNulls = true): TValue { // We have to ignore arrays and nullish values here, // otherwise "mergeObject" will throw an error, // because it expects an object as "source" @@ -100,16 +105,17 @@ function fastMerge>(target: TObject | nu return source; } - return mergeObject(target, source, shouldRemoveNestedNulls); + return mergeObject(target, source as Record, shouldRemoveNestedNulls) as TValue; } /** Deep removes the nested null values from the given value. */ -function removeNestedNullValues>(value: unknown | unknown[] | TObject): Record | unknown[] | null { +function removeNestedNullValues | null>(value: TValue): TValue { if (typeof value === 'object' && !Array.isArray(value)) { - return fastMerge(value as Record | null, value as Record | null); + const objectValue = value as Record; + return fastMerge(objectValue, objectValue) as TValue; } - return value as Record | null; + return value; } /** Formats the action name by uppercasing and adding the key if provided. */ @@ -138,4 +144,58 @@ function checkCompatibilityWithExistingValue(value: unknown, existingValue: unkn }; } -export default {isEmptyObject, fastMerge, formatActionName, removeNestedNullValues, checkCompatibilityWithExistingValue}; +/** + * Filters an object based on a condition and an inclusion flag. + * + * @param obj - The object to filter. + * @param condition - The condition to apply. + * @param include - If true, include entries that match the condition; otherwise, exclude them. + * @returns The filtered object. + */ +function filterObject(obj: Record, condition: string | string[] | ((entry: [string, TValue]) => boolean), include: boolean): Record { + const result: Record = {}; + const entries = Object.entries(obj); + + for (let i = 0; i < entries.length; i++) { + const [key, value] = entries[i]; + let shouldInclude: boolean; + + if (Array.isArray(condition)) { + shouldInclude = condition.includes(key); + } else if (typeof condition === 'string') { + shouldInclude = key === condition; + } else { + shouldInclude = condition(entries[i]); + } + + if (include ? shouldInclude : !shouldInclude) { + result[key] = value; + } + } + + return result; +} + +/** + * Picks entries from an object based on a condition. + * + * @param obj - The object to pick entries from. + * @param condition - The condition to determine which entries to pick. + * @returns The object containing only the picked entries. + */ +function pick(obj: Record, condition: string | string[] | ((entry: [string, TValue]) => boolean)): Record { + return filterObject(obj, condition, true); +} + +/** + * Omits entries from an object based on a condition. + * + * @param obj - The object to omit entries from. + * @param condition - The condition to determine which entries to omit. + * @returns The object containing only the remaining entries after omission. + */ +function omit(obj: Record, condition: string | string[] | ((entry: [string, TValue]) => boolean)): Record { + return filterObject(obj, condition, false); +} + +export default {isEmptyObject, fastMerge, formatActionName, removeNestedNullValues, checkCompatibilityWithExistingValue, pick, omit}; diff --git a/lib/withOnyx.js b/lib/withOnyx/index.tsx similarity index 54% rename from lib/withOnyx.js rename to lib/withOnyx/index.tsx index cd862637..fdc5bed5 100644 --- a/lib/withOnyx.js +++ b/lib/withOnyx/index.tsx @@ -3,50 +3,77 @@ * something in Onyx (a key/value store). That way, as soon as data in Onyx changes, the state will be set and the view * will automatically change to reflect the new data. */ -import PropTypes from 'prop-types'; import React from 'react'; -import _ from 'underscore'; -import Onyx from './Onyx'; -import * as Str from './Str'; -import utils from './utils'; -import OnyxUtils from './OnyxUtils'; +import Onyx from '../Onyx'; +import OnyxUtils from '../OnyxUtils'; +import * as Str from '../Str'; +import type {GenericFunction, OnyxKey, WithOnyxConnectOptions} from '../types'; +import utils from '../utils'; +import type {MapOnyxToState, WithOnyxInstance, WithOnyxMapping, WithOnyxProps, WithOnyxState} from './types'; +import cache from '../OnyxCache'; // This is a list of keys that can exist on a `mapping`, but are not directly related to loading data from Onyx. When the keys of a mapping are looped over to check // if a key has changed, it's a good idea to skip looking at these properties since they would have unexpected results. -const mappingPropertiesToIgnoreChangesTo = ['initialValue', 'allowStaleData']; +const mappingPropertiesToIgnoreChangesTo: Array = ['initialValue', 'allowStaleData']; /** * Returns the display name of a component - * - * @param {object} component - * @returns {string} */ -function getDisplayName(component) { +function getDisplayName(component: React.ComponentType): string { return component.displayName || component.name || 'Component'; } /** * Removes all the keys from state that are unrelated to the onyx data being mapped to the component. * - * @param {Object} state of the component - * @param {Object} onyxToStateMapping the object holding all of the mapping configuration for the component - * @returns {Object} + * @param state of the component + * @param onyxToStateMapping the object holding all of the mapping configuration for the component + */ +function getOnyxDataFromState(state: WithOnyxState, onyxToStateMapping: MapOnyxToState) { + return utils.pick(state, Object.keys(onyxToStateMapping)) as Partial>; +} + +/** + * Utility function to return the properly typed entries of the `withOnyx` mapping object. */ -const getOnyxDataFromState = (state, onyxToStateMapping) => _.pick(state, _.keys(onyxToStateMapping)); +function mapOnyxToStateEntries(mapOnyxToState: MapOnyxToState) { + return Object.entries(mapOnyxToState) as Array<[keyof TOnyxProps, WithOnyxMapping]>; +} -export default function (mapOnyxToState, shouldDelayUpdates = false) { +/** + * @deprecated Use `useOnyx` instead of `withOnyx` whenever possible. + * + * This is a higher order component that provides the ability to map a state property directly to + * something in Onyx (a key/value store). That way, as soon as data in Onyx changes, the state will be set and the view + * will automatically change to reflect the new data. + */ +export default function ( + mapOnyxToState: MapOnyxToState, + shouldDelayUpdates = false, +): (component: React.ComponentType) => React.ComponentType> { // A list of keys that must be present in tempState before we can render the WrappedComponent - const requiredKeysForInit = _.chain(mapOnyxToState) - .omit((config) => config.initWithStoredValues === false) - .keys() - .value(); - return (WrappedComponent) => { + const requiredKeysForInit = Object.keys( + utils.omit(mapOnyxToState, ([, options]) => (options as MapOnyxToState[keyof TOnyxProps]).initWithStoredValues === false), + ); + + return (WrappedComponent: React.ComponentType): React.ComponentType> => { const displayName = getDisplayName(WrappedComponent); - class withOnyx extends React.Component { - pendingSetStates = []; - constructor(props) { + class withOnyx extends React.Component, WithOnyxState> { + // eslint-disable-next-line react/static-property-placement + static displayName: string; + + pendingSetStates: Array | ((state: WithOnyxState) => WithOnyxState | null)> = []; + + shouldDelayUpdates: boolean; + + activeConnectionIDs: Record; + + tempState: WithOnyxState | undefined; + + constructor(props: WithOnyxProps) { super(props); + this.shouldDelayUpdates = shouldDelayUpdates; this.setWithOnyxState = this.setWithOnyxState.bind(this); this.flushPendingSetStates = this.flushPendingSetStates.bind(this); @@ -55,38 +82,40 @@ export default function (mapOnyxToState, shouldDelayUpdates = false) { // disconnected. It is a key value store with the format {[mapping.key]: connectionID}. this.activeConnectionIDs = {}; - const cachedState = _.reduce( - mapOnyxToState, - (resultObj, mapping, propertyName) => { - const key = Str.result(mapping.key, props); - let value = OnyxUtils.tryGetCachedValue(key, mapping); - if (!value && mapping.initialValue) { - value = mapping.initialValue; - } + const cachedState = mapOnyxToStateEntries(mapOnyxToState).reduce>((resultObj, [propName, mapping]) => { + const key = Str.result(mapping.key as GenericFunction, props); + let value = OnyxUtils.tryGetCachedValue(key, mapping as Partial>); + const hasCacheForKey = cache.hasCacheForKey(key); - /** - * If we have a pending merge for a key it could mean that data is being set via Onyx.merge() and someone expects a component to have this data immediately. - * - * @example - * - * Onyx.merge('report_123', value); - * Navigation.navigate(route); // Where "route" expects the "value" to be available immediately once rendered. - * - * In reality, Onyx.merge() will only update the subscriber after all merges have been batched and the previous value is retrieved via a get() (returns a promise). - * So, we won't use the cache optimization here as it will lead us to arbitrarily defer various actions in the application code. - */ - if (mapping.initWithStoredValues !== false && ((value !== undefined && !OnyxUtils.hasPendingMergeForKey(key)) || mapping.allowStaleData)) { - // eslint-disable-next-line no-param-reassign - resultObj[propertyName] = value; - } + if (!hasCacheForKey && !value && mapping.initialValue) { + value = mapping.initialValue; + } - return resultObj; - }, - {}, - ); + /** + * If we have a pending merge for a key it could mean that data is being set via Onyx.merge() and someone expects a component to have this data immediately. + * + * @example + * + * Onyx.merge('report_123', value); + * Navigation.navigate(route); // Where "route" expects the "value" to be available immediately once rendered. + * + * In reality, Onyx.merge() will only update the subscriber after all merges have been batched and the previous value is retrieved via a get() (returns a promise). + * So, we won't use the cache optimization here as it will lead us to arbitrarily defer various actions in the application code. + */ + const hasPendingMergeForKey = OnyxUtils.hasPendingMergeForKey(key); + const hasValueInCache = hasCacheForKey || value !== undefined; + const shouldSetState = mapping.initWithStoredValues !== false && ((hasValueInCache && !hasPendingMergeForKey) || !!mapping.allowStaleData); + + if (shouldSetState) { + // eslint-disable-next-line no-param-reassign + resultObj[propName] = value as WithOnyxState[keyof TOnyxProps]; + } + + return resultObj; + }, {} as WithOnyxState); // If we have all the data we need, then we can render the component immediately - cachedState.loading = _.size(cachedState) < requiredKeysForInit.length; + cachedState.loading = Object.keys(cachedState).length < requiredKeysForInit.length; // Object holding the temporary initial state for the component while we load the various Onyx keys this.tempState = cachedState; @@ -98,17 +127,19 @@ export default function (mapOnyxToState, shouldDelayUpdates = false) { const onyxDataFromState = getOnyxDataFromState(this.state, mapOnyxToState); // Subscribe each of the state properties to the proper Onyx key - _.each(mapOnyxToState, (mapping, propertyName) => { - if (_.includes(mappingPropertiesToIgnoreChangesTo, propertyName)) { + mapOnyxToStateEntries(mapOnyxToState).forEach(([propName, mapping]) => { + if (mappingPropertiesToIgnoreChangesTo.includes(propName)) { return; } - const key = Str.result(mapping.key, {...this.props, ...onyxDataFromState}); - this.connectMappingToOnyx(mapping, propertyName, key); + + const key = Str.result(mapping.key as GenericFunction, {...this.props, ...onyxDataFromState}); + this.connectMappingToOnyx(mapping, propName, key); }); + this.checkEvictableKeys(); } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps: WithOnyxProps, prevState: WithOnyxState) { // The whole purpose of this method is to check to see if a key that is subscribed to Onyx has changed, and then Onyx needs to be disconnected from the old // key and connected to the new key. // For example, a key could change if KeyB depends on data loading from Onyx for KeyA. @@ -116,9 +147,9 @@ export default function (mapOnyxToState, shouldDelayUpdates = false) { const onyxDataFromState = getOnyxDataFromState(this.state, mapOnyxToState); const prevOnyxDataFromState = getOnyxDataFromState(prevState, mapOnyxToState); - _.each(mapOnyxToState, (mapping, propName) => { + mapOnyxToStateEntries(mapOnyxToState).forEach(([propName, mapping]) => { // Some properties can be ignored because they aren't related to onyx keys and they will never change - if (_.includes(mappingPropertiesToIgnoreChangesTo, propName)) { + if (mappingPropertiesToIgnoreChangesTo.includes(propName)) { return; } @@ -129,26 +160,27 @@ export default function (mapOnyxToState, shouldDelayUpdates = false) { // (eg. if a user switches chats really quickly). In this case, it's much more stable to always look at the changes to prevProp and prevState to derive the key. // The second case cannot be used all the time because the onyx data doesn't change the first time that `componentDidUpdate()` runs after loading. In this case, // the `mapping.previousKey` must be used for the comparison or else this logic never detects that onyx data could have changed during the loading process. - const previousKey = isFirstTimeUpdatingAfterLoading ? mapping.previousKey : Str.result(mapping.key, {...prevProps, ...prevOnyxDataFromState}); - const newKey = Str.result(mapping.key, {...this.props, ...onyxDataFromState}); + const previousKey = isFirstTimeUpdatingAfterLoading ? mapping.previousKey : Str.result(mapping.key as GenericFunction, {...prevProps, ...prevOnyxDataFromState}); + const newKey = Str.result(mapping.key as GenericFunction, {...this.props, ...onyxDataFromState}); if (previousKey !== newKey) { Onyx.disconnect(this.activeConnectionIDs[previousKey], previousKey); delete this.activeConnectionIDs[previousKey]; this.connectMappingToOnyx(mapping, propName, newKey); } }); + this.checkEvictableKeys(); } componentWillUnmount() { // Disconnect everything from Onyx - _.each(mapOnyxToState, (mapping) => { - const key = Str.result(mapping.key, {...this.props, ...getOnyxDataFromState(this.state, mapOnyxToState)}); + mapOnyxToStateEntries(mapOnyxToState).forEach(([, mapping]) => { + const key = Str.result(mapping.key as GenericFunction, {...this.props, ...getOnyxDataFromState(this.state, mapOnyxToState)}); Onyx.disconnect(this.activeConnectionIDs[key], key); }); } - setStateProxy(modifier) { + setStateProxy(modifier: WithOnyxState | ((state: WithOnyxState) => WithOnyxState | null)) { if (this.shouldDelayUpdates) { this.pendingSetStates.push(modifier); } else { @@ -170,12 +202,9 @@ export default function (mapOnyxToState, shouldDelayUpdates = false) { * We however need to workaround this issue in the HOC. The addition of initialValue makes things even more complex, * since you cannot be really sure if the component has been updated before or after the initial hydration. Therefore if * initialValue is there, we just check if the update is different than that and then try to handle it as best as we can. - * - * @param {String} statePropertyName - * @param {*} val */ - setWithOnyxState(statePropertyName, val) { - const prevValue = this.state[statePropertyName]; + setWithOnyxState(statePropertyName: T, val: WithOnyxState[T]) { + const prevVal = this.state[statePropertyName]; // If the component is not loading (read "mounting"), then we can just update the state // There is a small race condition. @@ -186,18 +215,21 @@ export default function (mapOnyxToState, shouldDelayUpdates = false) { // This simply bypasses the loading check if the tempState is gone and the update can be safely queued with a normal setStateProxy. if (!this.state.loading || !this.tempState) { // Performance optimization, do not trigger update with same values - if (prevValue === val || (utils.isEmptyObject(prevValue) && utils.isEmptyObject(val))) { + if (prevVal === val || (utils.isEmptyObject(prevVal) && utils.isEmptyObject(val))) { return; } - this.setStateProxy({[statePropertyName]: val}); + const valueWithoutNull = val === null ? undefined : val; + + this.setStateProxy({[statePropertyName]: valueWithoutNull} as WithOnyxState); return; } this.tempState[statePropertyName] = val; // If some key does not have a value yet, do not update the state yet - const tempStateIsMissingKey = _.some(requiredKeysForInit, (key) => _.isUndefined(this.tempState[key])); + const tempStateIsMissingKey = requiredKeysForInit.some((key) => !(key in (this.tempState ?? {}))); + if (tempStateIsMissingKey) { return; } @@ -207,35 +239,34 @@ export default function (mapOnyxToState, shouldDelayUpdates = false) { // Full of hacky workarounds to prevent the race condition described above. this.setState((prevState) => { - const finalState = _.reduce( - stateUpdate, - (result, value, key) => { - if (key === 'loading') { - return result; - } - - const initialValue = mapOnyxToState[key].initialValue; - - // If initialValue is there and the state contains something different it means - // an update has already been received and we can discard the value we are trying to hydrate - if (!_.isUndefined(initialValue) && !_.isUndefined(prevState[key]) && prevState[key] !== initialValue) { - // eslint-disable-next-line no-param-reassign - result[key] = prevState[key]; - - // if value is already there (without initial value) then we can discard the value we are trying to hydrate - } else if (!_.isUndefined(prevState[key])) { - // eslint-disable-next-line no-param-reassign - result[key] = prevState[key]; - } else { - // eslint-disable-next-line no-param-reassign - result[key] = value; - } + const finalState = Object.keys(stateUpdate).reduce>((result, _key) => { + const key = _key as keyof TOnyxProps; + + if (key === 'loading') { return result; - }, - {}, - ); + } + + const initialValue = mapOnyxToState[key].initialValue; + + // If initialValue is there and the state contains something different it means + // an update has already been received and we can discard the value we are trying to hydrate + if (initialValue !== undefined && prevState[key] !== undefined && prevState[key] !== initialValue && prevState[key] !== null) { + // eslint-disable-next-line no-param-reassign + result[key] = prevState[key]; + } else if (prevState[key] !== undefined && prevState[key] !== null) { + // if value is already there (without initial value) then we can discard the value we are trying to hydrate + // eslint-disable-next-line no-param-reassign + result[key] = prevState[key]; + } else if (stateUpdate[key] !== null) { + // eslint-disable-next-line no-param-reassign + result[key] = stateUpdate[key]; + } + + return result; + }, {} as WithOnyxState); finalState.loading = false; + return finalState; }); } @@ -249,13 +280,13 @@ export default function (mapOnyxToState, shouldDelayUpdates = false) { // We will add this key to our list of recently accessed keys // if the canEvict function returns true. This is necessary criteria // we MUST use to specify if a key can be removed or not. - _.each(mapOnyxToState, (mapping) => { - if (_.isUndefined(mapping.canEvict)) { + mapOnyxToStateEntries(mapOnyxToState).forEach(([, mapping]) => { + if (mapping.canEvict === undefined) { return; } - const canEvict = Str.result(mapping.canEvict, this.props); - const key = Str.result(mapping.key, this.props); + const canEvict = !!Str.result(mapping.canEvict as GenericFunction, this.props); + const key = Str.result(mapping.key as GenericFunction, this.props); if (!OnyxUtils.isSafeEvictionKey(key)) { throw new Error(`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: []}).`); @@ -272,26 +303,27 @@ export default function (mapOnyxToState, shouldDelayUpdates = false) { /** * Takes a single mapping and binds the state of the component to the store * - * @param {object} mapping - * @param {string|function} mapping.key key to connect to. can be a string or a + * @param mapping.key key to connect to. can be a string or a * function that takes this.props as an argument and returns a string - * @param {string} statePropertyName the name of the state property that Onyx will add the data to - * @param {boolean} [mapping.initWithStoredValues] If set to false, then no data will be prefilled into the + * @param statePropertyName the name of the state property that Onyx will add the data to + * @param [mapping.initWithStoredValues] If set to false, then no data will be prefilled into the * component - * @param {string} key to connect to Onyx with + * @param key to connect to Onyx with */ - connectMappingToOnyx(mapping, statePropertyName, key) { + connectMappingToOnyx(mapping: MapOnyxToState[keyof TOnyxProps], statePropertyName: keyof TOnyxProps, key: OnyxKey) { + const onyxMapping = mapOnyxToState[statePropertyName] as WithOnyxMapping; + // Remember what the previous key was so that key changes can be detected when data is being loaded from Onyx. This will allow // dependent keys to finish loading their data. // eslint-disable-next-line no-param-reassign - mapOnyxToState[statePropertyName].previousKey = key; + onyxMapping.previousKey = key; // eslint-disable-next-line rulesdir/prefer-onyx-connect-in-libs this.activeConnectionIDs[key] = Onyx.connect({ ...mapping, key, - statePropertyName, - withOnyxInstance: this, + statePropertyName: statePropertyName as string, + withOnyxInstance: this as unknown as WithOnyxInstance, displayName, }); } @@ -304,14 +336,15 @@ export default function (mapOnyxToState, shouldDelayUpdates = false) { this.shouldDelayUpdates = false; this.pendingSetStates.forEach((modifier) => { - this.setState(modifier); + this.setState(modifier as WithOnyxState); }); + this.pendingSetStates = []; } render() { // Remove any null values so that React replaces them with default props - const propsToPass = _.omit(this.props, _.isNull); + const propsToPass = utils.omit(this.props as Omit, ([, propValue]) => propValue === null); if (this.state.loading) { return null; @@ -319,35 +352,24 @@ export default function (mapOnyxToState, shouldDelayUpdates = false) { // Remove any internal state properties used by withOnyx // that should not be passed to a wrapped component - let stateToPass = _.omit(this.state, 'loading'); - stateToPass = _.omit(stateToPass, _.isNull); - const stateToPassWithoutNestedNulls = utils.removeNestedNullValues(stateToPass); + const stateToPass = utils.omit(this.state as WithOnyxState, ([stateKey, stateValue]) => stateKey === 'loading' || stateValue === null); // Spreading props and state is necessary in an HOC where the data cannot be predicted return ( ); } } - withOnyx.propTypes = { - forwardedRef: PropTypes.oneOfType([ - PropTypes.func, - // eslint-disable-next-line react/forbid-prop-types - PropTypes.shape({current: PropTypes.object}), - ]), - }; - withOnyx.defaultProps = { - forwardedRef: undefined, - }; withOnyx.displayName = `withOnyx(${displayName})`; + return React.forwardRef((props, ref) => { const Component = withOnyx; return ( diff --git a/lib/withOnyx.d.ts b/lib/withOnyx/types.ts similarity index 73% rename from lib/withOnyx.d.ts rename to lib/withOnyx/types.ts index f665cea9..4b8b85ec 100644 --- a/lib/withOnyx.d.ts +++ b/lib/withOnyx/types.ts @@ -1,5 +1,6 @@ -import {IsEqual} from 'type-fest'; -import {CollectionKeyBase, ExtractOnyxCollectionValue, KeyValueMapping, OnyxCollection, OnyxEntry, OnyxKey, Selector} from './types'; +import type {ForwardedRef} from 'react'; +import type {IsEqual} from 'type-fest'; +import type {CollectionKeyBase, ExtractOnyxCollectionValue, KeyValueMapping, OnyxCollection, OnyxEntry, OnyxKey, OnyxValue, Selector} from '../types'; /** * Represents the base mapping options between an Onyx key and the component's prop. @@ -10,10 +11,16 @@ type BaseMapping = { allowStaleData?: boolean; }; +/** + * Represents the base mapping options when an Onyx collection key is supplied. + */ type CollectionBaseMapping = { initialValue?: OnyxCollection; }; +/** + * Represents the base mapping options when an Onyx non-collection key is supplied. + */ type EntryBaseMapping = { initialValue?: OnyxEntry; }; @@ -61,6 +68,7 @@ type BaseMappingKey = { key: TOnyxKey; selector: Selector; @@ -97,6 +105,14 @@ type Mapping ); +/** + * Represents a superset of `Mapping` type with internal properties included. + */ +type WithOnyxMapping = Mapping & { + connectionID: number; + previousKey?: OnyxKey; +}; + /** * Represents the mapping options between an Onyx collection key without suffix and the component's prop with all its possibilities. */ @@ -125,17 +141,30 @@ type OnyxPropCollectionMapping = { + [TOnyxProp in keyof TOnyxProps]: OnyxPropMapping | OnyxPropCollectionMapping; +}; + +/** + * Represents the `withOnyx` internal component props. + */ +type WithOnyxProps = Omit & {forwardedRef?: ForwardedRef}; + +/** + * Represents the `withOnyx` internal component state. */ -declare function withOnyx( - mapping: { - [TOnyxProp in keyof TOnyxProps]: OnyxPropMapping | OnyxPropCollectionMapping; - }, - shouldDelayUpdates?: boolean, -): (component: React.ComponentType) => React.ComponentType>; - -export default withOnyx; +type WithOnyxState = TOnyxProps & { + loading: boolean; +}; + +/** + * Represents the `withOnyx` internal component instance. + */ +type WithOnyxInstance = React.Component> & { + setStateProxy: (modifier: Record> | ((state: Record>) => OnyxValue)) => void; + setWithOnyxState: (statePropertyName: OnyxKey, value: OnyxValue) => void; +}; + +export type {WithOnyxMapping, MapOnyxToState, WithOnyxProps, WithOnyxInstance, WithOnyxState}; diff --git a/package-lock.json b/package-lock.json index f0e686bb..afa18e8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-native-onyx", - "version": "2.0.41", + "version": "2.0.47", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "react-native-onyx", - "version": "2.0.41", + "version": "2.0.47", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", @@ -59,8 +59,8 @@ "typescript": "^5.4.5" }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=20.14.0", + "npm": ">=10.7.0" }, "peerDependencies": { "idb-keyval": "^6.2.1", diff --git a/package.json b/package.json index e47231ca..2234ca31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-onyx", - "version": "2.0.41", + "version": "2.0.47", "author": "Expensify, Inc.", "homepage": "https://expensify.com", "description": "State management for React Native", @@ -31,7 +31,7 @@ "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "jest", - "build": "tsc -p tsconfig.build.json && cp ./lib/*.d.ts ./dist", + "build": "tsc -p tsconfig.build.json", "build:watch": "nodemon --watch lib --ext js,json,ts,tsx --exec \"npm run build && npm pack\"", "prebuild:docs": "npm run build", "build:docs": "ts-node buildDocs.ts", @@ -111,8 +111,8 @@ } }, "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "node": ">=20.14.0", + "npm": ">=10.7.0" }, "sideEffects": false } diff --git a/tests/unit/onyxCacheTest.tsx b/tests/unit/onyxCacheTest.tsx index 28ca1fa7..9487b0b4 100644 --- a/tests/unit/onyxCacheTest.tsx +++ b/tests/unit/onyxCacheTest.tsx @@ -79,7 +79,7 @@ describe('Onyx', () => { // Given empty cache // When a value is retrieved - const result = cache.getValue('mockKey'); + const result = cache.get('mockKey'); // Then it should be undefined expect(result).not.toBeDefined(); @@ -92,8 +92,8 @@ describe('Onyx', () => { // When a value is retrieved // Then it should be the correct value - expect(cache.getValue('mockKey')).toEqual({items: ['mockValue', 'mockValue2']}); - expect(cache.getValue('mockKey2')).toEqual('mockValue3'); + expect(cache.get('mockKey')).toEqual({items: ['mockValue', 'mockValue2']}); + expect(cache.get('mockKey2')).toEqual('mockValue3'); }); }); @@ -155,7 +155,7 @@ describe('Onyx', () => { cache.set('mockKey', {value: 'mockValue'}); // Then data should be cached - const data = cache.getValue('mockKey'); + const data = cache.get('mockKey'); expect(data).toEqual({value: 'mockValue'}); }); @@ -178,7 +178,7 @@ describe('Onyx', () => { cache.set('mockKey2', {value: []}); // Then the value should be overwritten - expect(cache.getValue('mockKey2')).toEqual({value: []}); + expect(cache.get('mockKey2')).toEqual({value: []}); }); }); @@ -193,7 +193,7 @@ describe('Onyx', () => { // Then a value should not be available in cache expect(cache.hasCacheForKey('mockKey')).toBe(false); - expect(cache.getValue('mockKey')).not.toBeDefined(); + expect(cache.get('mockKey')).not.toBeDefined(); expect(cache.getAllKeys().has('mockKey')).toBe(false); }); }); @@ -209,8 +209,8 @@ describe('Onyx', () => { }); // Then data should be created in cache - expect(cache.getValue('mockKey')).toEqual({value: 'mockValue'}); - expect(cache.getValue('mockKey2')).toEqual({value: 'mockValue2'}); + expect(cache.get('mockKey')).toEqual({value: 'mockValue'}); + expect(cache.get('mockKey2')).toEqual({value: 'mockValue2'}); }); it('Should merge data to existing cache value', () => { @@ -225,12 +225,12 @@ describe('Onyx', () => { }); // Then the values should be merged together in cache - expect(cache.getValue('mockKey')).toEqual({ + expect(cache.get('mockKey')).toEqual({ value: 'mockValue', mockItems: [], }); - expect(cache.getValue('mockKey2')).toEqual({ + expect(cache.get('mockKey2')).toEqual({ other: 'overwrittenMockValue', items: [1, 2], mock: 'mock', @@ -247,7 +247,7 @@ describe('Onyx', () => { }); // Then the values should be merged together in cache - expect(cache.getValue('mockKey')).toEqual({ + expect(cache.get('mockKey')).toEqual({ value: 'mockValue', mockItems: [], otherValue: 'overwritten', @@ -264,7 +264,7 @@ describe('Onyx', () => { }); // Then the arrays should be replaced as expected - expect(cache.getValue('mockKey')).toEqual([{ID: 3}, {added: 'field'}, {}, {ID: 1000}]); + expect(cache.get('mockKey')).toEqual([{ID: 3}, {added: 'field'}, {}, {ID: 1000}]); }); it('Should merge arrays inside objects correctly', () => { @@ -277,7 +277,7 @@ describe('Onyx', () => { }); // Then the first array is completely replaced by the second array - expect(cache.getValue('mockKey')).toEqual({ID: [2]}); + expect(cache.get('mockKey')).toEqual({ID: [2]}); }); it('Should work with primitive values', () => { @@ -288,31 +288,31 @@ describe('Onyx', () => { cache.merge({mockKey: false}); // Then the object should be overwritten with a bool value - expect(cache.getValue('mockKey')).toEqual(false); + expect(cache.get('mockKey')).toEqual(false); // When merge is called with number cache.merge({mockKey: 0}); // Then the value should be overwritten - expect(cache.getValue('mockKey')).toEqual(0); + expect(cache.get('mockKey')).toEqual(0); // When merge is called with string cache.merge({mockKey: '123'}); // Then the value should be overwritten - expect(cache.getValue('mockKey')).toEqual('123'); + expect(cache.get('mockKey')).toEqual('123'); // When merge is called with string again cache.merge({mockKey: '123'}); // Then strings should not have been concatenated - expect(cache.getValue('mockKey')).toEqual('123'); + expect(cache.get('mockKey')).toEqual('123'); // When merge is called with an object cache.merge({mockKey: {value: 'myMockObject'}}); // Then the old primitive value should be overwritten with the object - expect(cache.getValue('mockKey')).toEqual({value: 'myMockObject'}); + expect(cache.get('mockKey')).toEqual({value: 'myMockObject'}); }); it('Should ignore `undefined` values', () => { @@ -323,12 +323,12 @@ describe('Onyx', () => { cache.merge({mockKey: {ID: undefined}}); // Then the key should still be in cache and the value unchanged - expect(cache.getValue('mockKey')).toEqual({ID: 5}); + expect(cache.get('mockKey')).toEqual({ID: 5}); cache.merge({mockKey: undefined}); // Then the key should still be in cache and the value unchanged - expect(cache.getValue('mockKey')).toEqual({ID: 5}); + expect(cache.get('mockKey')).toEqual({ID: 5}); }); it('Should update storageKeys when new keys are created', () => { @@ -357,16 +357,14 @@ describe('Onyx', () => { expect(() => cache.merge({})).not.toThrow(); }); - it('Should merge `null` values', () => { - // `null` values can override existing values and should also - // be preserved during merges. + it('Should remove `null` values when merging', () => { cache.set('mockKey', {ID: 5}); cache.set('mockNullKey', null); cache.merge({mockKey: null}); - expect(cache.getValue('mockKey')).toEqual(null); - expect(cache.getValue('mockNullKey')).toEqual(null); + expect(cache.get('mockKey')).toEqual(undefined); + expect(cache.get('mockNullKey')).toEqual(undefined); }); }); diff --git a/tests/unit/onyxClearNativeStorageTest.ts b/tests/unit/onyxClearNativeStorageTest.ts index 090a7f78..28605adc 100644 --- a/tests/unit/onyxClearNativeStorageTest.ts +++ b/tests/unit/onyxClearNativeStorageTest.ts @@ -54,7 +54,7 @@ describe('Set data while storage is clearing', () => { return waitForPromisesToResolve().then(() => { // Then the value in Onyx, the cache, and Storage is the default key state expect(onyxValue).toBe(DEFAULT_VALUE); - const cachedValue = cache.getValue(ONYX_KEYS.DEFAULT_KEY); + const cachedValue = cache.get(ONYX_KEYS.DEFAULT_KEY); expect(cachedValue).toBe(DEFAULT_VALUE); return StorageMock.getItem(ONYX_KEYS.DEFAULT_KEY).then((storedValue) => expect(parseInt(storedValue as string, 10)).toBe(DEFAULT_VALUE)); }); @@ -70,7 +70,7 @@ describe('Set data while storage is clearing', () => { return waitForPromisesToResolve().then(() => { // Then the value in Onyx, the cache, and Storage is the default key state expect(onyxValue).toBe(DEFAULT_VALUE); - const cachedValue = cache.getValue(ONYX_KEYS.DEFAULT_KEY); + const cachedValue = cache.get(ONYX_KEYS.DEFAULT_KEY); expect(cachedValue).toBe(DEFAULT_VALUE); return StorageMock.getItem(ONYX_KEYS.DEFAULT_KEY).then((storedValue) => expect(parseInt(storedValue as string, 10)).toBe(DEFAULT_VALUE)); }); @@ -94,7 +94,7 @@ describe('Set data while storage is clearing', () => { return waitForPromisesToResolve().then(() => { // Then the value of the preserved key is also still set in both the cache and storage expect(regularKeyOnyxValue).toBe(SET_VALUE); - expect(cache.getValue(ONYX_KEYS.REGULAR_KEY)).toBe(SET_VALUE); + expect(cache.get(ONYX_KEYS.REGULAR_KEY)).toBe(SET_VALUE); return StorageMock.getItem(ONYX_KEYS.REGULAR_KEY).then((storedValue) => expect(parseInt(storedValue as string, 10)).toBe(SET_VALUE)); }); }); @@ -111,7 +111,7 @@ describe('Set data while storage is clearing', () => { return waitForPromisesToResolve().then(() => { // Then the value in Onyx, the cache, and Storage is the set value expect(onyxValue).toBe(SET_VALUE); - const cachedValue = cache.getValue(ONYX_KEYS.DEFAULT_KEY); + const cachedValue = cache.get(ONYX_KEYS.DEFAULT_KEY); expect(cachedValue).toBe(SET_VALUE); return StorageMock.getItem(ONYX_KEYS.DEFAULT_KEY).then((storedValue) => expect(parseInt(storedValue as string, 10)).toBe(SET_VALUE)); }); diff --git a/tests/unit/onyxClearWebStorageTest.ts b/tests/unit/onyxClearWebStorageTest.ts index d9117612..6fb52fbc 100644 --- a/tests/unit/onyxClearWebStorageTest.ts +++ b/tests/unit/onyxClearWebStorageTest.ts @@ -56,7 +56,7 @@ describe('Set data while storage is clearing', () => { return waitForPromisesToResolve().then(() => { // Then the value in Onyx, the cache, and Storage is the default key state expect(onyxValue).toBe(DEFAULT_VALUE); - const cachedValue = cache.getValue(ONYX_KEYS.DEFAULT_KEY); + const cachedValue = cache.get(ONYX_KEYS.DEFAULT_KEY); expect(cachedValue).toBe(DEFAULT_VALUE); const storedValue = StorageMock.getItem(ONYX_KEYS.DEFAULT_KEY); return expect(storedValue).resolves.toBe(DEFAULT_VALUE); @@ -73,7 +73,7 @@ describe('Set data while storage is clearing', () => { return waitForPromisesToResolve().then(() => { // Then the value in Onyx, the cache, and Storage is the default key state expect(onyxValue).toBe(DEFAULT_VALUE); - const cachedValue = cache.getValue(ONYX_KEYS.DEFAULT_KEY); + const cachedValue = cache.get(ONYX_KEYS.DEFAULT_KEY); expect(cachedValue).toBe(DEFAULT_VALUE); const storedValue = StorageMock.getItem(ONYX_KEYS.DEFAULT_KEY); return expect(storedValue).resolves.toBe(DEFAULT_VALUE); @@ -98,7 +98,7 @@ describe('Set data while storage is clearing', () => { return waitForPromisesToResolve().then(() => { // Then the value of the preserved key is also still set in both the cache and storage expect(regularKeyOnyxValue).toBe(SET_VALUE); - const regularKeyCachedValue = cache.getValue(ONYX_KEYS.REGULAR_KEY); + const regularKeyCachedValue = cache.get(ONYX_KEYS.REGULAR_KEY); expect(regularKeyCachedValue).toBe(SET_VALUE); const regularKeyStoredValue = StorageMock.getItem(ONYX_KEYS.REGULAR_KEY); return expect(regularKeyStoredValue).resolves.toBe(SET_VALUE); @@ -117,7 +117,7 @@ describe('Set data while storage is clearing', () => { return waitForPromisesToResolve().then(() => { // Then the value in Onyx, the cache, and Storage is the set value expect(onyxValue).toBe(SET_VALUE); - const cachedValue = cache.getValue(ONYX_KEYS.DEFAULT_KEY); + const cachedValue = cache.get(ONYX_KEYS.DEFAULT_KEY); expect(cachedValue).toBe(SET_VALUE); const storedValue = StorageMock.getItem(ONYX_KEYS.DEFAULT_KEY); return expect(storedValue).resolves.toBe(SET_VALUE); @@ -158,7 +158,7 @@ describe('Set data while storage is clearing', () => { expect(collectionCallback).toHaveBeenCalledTimes(3); // And it should be called with the expected parameters each time - expect(collectionCallback).toHaveBeenNthCalledWith(1, null, undefined); + expect(collectionCallback).toHaveBeenNthCalledWith(1, undefined, undefined); expect(collectionCallback).toHaveBeenNthCalledWith(2, { test_1: 1, test_2: 2, diff --git a/tests/unit/onyxMultiMergeWebStorageTest.ts b/tests/unit/onyxMultiMergeWebStorageTest.ts index a9984caa..c3f939c7 100644 --- a/tests/unit/onyxMultiMergeWebStorageTest.ts +++ b/tests/unit/onyxMultiMergeWebStorageTest.ts @@ -45,9 +45,9 @@ describe('Onyx.mergeCollection() and WebStorage', () => { expect(StorageMock.getMockStore().test_3).toEqual(initialTestObject); // And an empty cache values for the collection keys - expect(OnyxCache.getValue('test_1')).not.toBeDefined(); - expect(OnyxCache.getValue('test_2')).not.toBeDefined(); - expect(OnyxCache.getValue('test_3')).not.toBeDefined(); + expect(OnyxCache.get('test_1')).not.toBeDefined(); + expect(OnyxCache.get('test_2')).not.toBeDefined(); + expect(OnyxCache.get('test_3')).not.toBeDefined(); // When we merge additional data const additionalDataOne = {b: 'b', c: 'c', e: [1, 2]}; @@ -75,9 +75,9 @@ describe('Onyx.mergeCollection() and WebStorage', () => { }; // Then our new data should merge with the existing data in the cache - expect(OnyxCache.getValue('test_1')).toEqual(finalObject); - expect(OnyxCache.getValue('test_2')).toEqual(finalObject); - expect(OnyxCache.getValue('test_3')).toEqual(finalObject); + expect(OnyxCache.get('test_1')).toEqual(finalObject); + expect(OnyxCache.get('test_2')).toEqual(finalObject); + expect(OnyxCache.get('test_3')).toEqual(finalObject); // And the storage should reflect the same state expect(StorageMock.getMockStore().test_1).toEqual(finalObject); @@ -93,9 +93,9 @@ describe('Onyx.mergeCollection() and WebStorage', () => { expect(StorageMock.getMockStore().test_3).toBeFalsy(); // And an empty cache values for the collection keys - expect(OnyxCache.getValue('test_1')).toBeFalsy(); - expect(OnyxCache.getValue('test_2')).toBeFalsy(); - expect(OnyxCache.getValue('test_3')).toBeFalsy(); + expect(OnyxCache.get('test_1')).toBeFalsy(); + expect(OnyxCache.get('test_2')).toBeFalsy(); + expect(OnyxCache.get('test_3')).toBeFalsy(); // When we merge additional data and wait for the change const data = {a: 'a', b: 'b'}; @@ -108,9 +108,9 @@ describe('Onyx.mergeCollection() and WebStorage', () => { return waitForPromisesToResolve() .then(() => { // Then the cache and storage should match - expect(OnyxCache.getValue('test_1')).toEqual(data); - expect(OnyxCache.getValue('test_2')).toEqual(data); - expect(OnyxCache.getValue('test_3')).toEqual(data); + expect(OnyxCache.get('test_1')).toEqual(data); + expect(OnyxCache.get('test_2')).toEqual(data); + expect(OnyxCache.get('test_3')).toEqual(data); expect(StorageMock.getMockStore().test_1).toEqual(data); expect(StorageMock.getMockStore().test_2).toEqual(data); expect(StorageMock.getMockStore().test_3).toEqual(data); @@ -137,9 +137,9 @@ describe('Onyx.mergeCollection() and WebStorage', () => { }; // Then our new data should merge with the existing data in the cache - expect(OnyxCache.getValue('test_1')).toEqual(finalObject); - expect(OnyxCache.getValue('test_2')).toEqual(finalObject); - expect(OnyxCache.getValue('test_3')).toEqual(finalObject); + expect(OnyxCache.get('test_1')).toEqual(finalObject); + expect(OnyxCache.get('test_2')).toEqual(finalObject); + expect(OnyxCache.get('test_3')).toEqual(finalObject); // And the storage should reflect the same state expect(StorageMock.getMockStore().test_1).toEqual(finalObject); @@ -178,7 +178,7 @@ describe('Onyx.mergeCollection() and WebStorage', () => { f: 'f', }; - expect(OnyxCache.getValue('test_1')).toEqual(finalObject); + expect(OnyxCache.get('test_1')).toEqual(finalObject); expect(StorageMock.getMockStore().test_1).toEqual(finalObject); }); }); diff --git a/tests/unit/onyxTest.ts b/tests/unit/onyxTest.ts index c4debb9b..ead31d3a 100644 --- a/tests/unit/onyxTest.ts +++ b/tests/unit/onyxTest.ts @@ -167,7 +167,7 @@ describe('Onyx', () => { }) .then(() => { // Test key should be cleared - expect(testKeyValue).toBeNull(); + expect(testKeyValue).toBeUndefined(); // Other test key should be returned to its default state expect(otherTestValue).toBe(42); @@ -251,27 +251,6 @@ describe('Onyx', () => { }); }); - it('should ignore top-level undefined values', () => { - let testKeyValue: unknown; - - connectionID = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - initWithStoredValues: false, - callback: (value) => { - testKeyValue = value; - }, - }); - - return Onyx.set(ONYX_KEYS.TEST_KEY, {test1: 'test1'}) - .then(() => { - expect(testKeyValue).toEqual({test1: 'test1'}); - return Onyx.merge(ONYX_KEYS.TEST_KEY, undefined); - }) - .then(() => { - expect(testKeyValue).toEqual({test1: 'test1'}); - }); - }); - it('should remove keys that are set to null when merging', () => { let testKeyValue: unknown; @@ -300,6 +279,7 @@ describe('Onyx', () => { }, }, }); + return Onyx.merge(ONYX_KEYS.TEST_KEY, { test1: { test3: { @@ -315,6 +295,7 @@ describe('Onyx', () => { test3: {}, }, }); + return Onyx.merge(ONYX_KEYS.TEST_KEY, { test1: { test3: null, @@ -323,6 +304,7 @@ describe('Onyx', () => { }) .then(() => { expect(testKeyValue).toEqual({test1: {test2: 'test2'}}); + return Onyx.merge(ONYX_KEYS.TEST_KEY, {test1: null}); }) .then(() => { @@ -330,7 +312,114 @@ describe('Onyx', () => { }); }); - it('should ignore `undefined` values when merging', () => { + it('should ignore top-level and remove nested `undefined` values in Onyx.set', () => { + let testKeyValue: unknown; + + connectionID = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + initWithStoredValues: false, + callback: (value) => { + testKeyValue = value; + }, + }); + + return Onyx.set(ONYX_KEYS.TEST_KEY, { + test1: { + test2: 'test2', + test3: 'test3', + }, + }) + .then(() => { + expect(testKeyValue).toEqual({ + test1: { + test2: 'test2', + test3: 'test3', + }, + }); + + return Onyx.set(ONYX_KEYS.TEST_KEY, { + test1: { + test2: undefined, + test3: 'test3', + }, + }); + }) + .then(() => { + expect(testKeyValue).toEqual({test1: {test3: 'test3'}}); + + return Onyx.set(ONYX_KEYS.TEST_KEY, {test1: undefined}); + }) + .then(() => { + expect(testKeyValue).toEqual({}); + + return Onyx.set(ONYX_KEYS.TEST_KEY, undefined); + }) + .then(() => { + expect(testKeyValue).toEqual({}); + + return Onyx.set(ONYX_KEYS.TEST_KEY, {test1: undefined}); + }); + }); + + it('should ignore top-level and remove nested `undefined` values in Onyx.multiSet', () => { + let testKeyValue: unknown; + connectionID = Onyx.connect({ + key: ONYX_KEYS.TEST_KEY, + initWithStoredValues: false, + callback: (value) => { + testKeyValue = value; + }, + }); + + let otherTestKeyValue: unknown; + connectionID = Onyx.connect({ + key: ONYX_KEYS.OTHER_TEST, + initWithStoredValues: false, + callback: (value) => { + otherTestKeyValue = value; + }, + }); + + return Onyx.multiSet({ + [ONYX_KEYS.TEST_KEY]: { + test1: 'test1', + test2: 'test2', + }, + [ONYX_KEYS.OTHER_TEST]: 'otherTest', + }) + .then(() => { + expect(testKeyValue).toEqual({ + test1: 'test1', + test2: 'test2', + }); + expect(otherTestKeyValue).toEqual('otherTest'); + + return Onyx.multiSet({ + [ONYX_KEYS.TEST_KEY]: { + test1: 'test1', + test2: undefined, + }, + [ONYX_KEYS.OTHER_TEST]: undefined, + }); + }) + .then(() => { + expect(testKeyValue).toEqual({ + test1: 'test1', + }); + expect(otherTestKeyValue).toEqual('otherTest'); + + return Onyx.multiSet({ + [ONYX_KEYS.TEST_KEY]: null, + [ONYX_KEYS.OTHER_TEST]: null, + }); + }) + .then(() => { + expect(testKeyValue).toEqual(undefined); + expect(otherTestKeyValue).toEqual(undefined); + }); + }); + + it('should ignore top-level and remove nested `undefined` values in Onyx.merge', () => { let testKeyValue: unknown; connectionID = Onyx.connect({ @@ -366,7 +455,51 @@ describe('Onyx', () => { }) .then(() => { expect(testKeyValue).toEqual({test1: {test2: 'test2', test3: 'test3'}}); + return Onyx.merge(ONYX_KEYS.TEST_KEY, undefined); + }) + .then(() => { + expect(testKeyValue).toEqual({test1: {test2: 'test2', test3: 'test3'}}); + }); + }); + + it('should ignore top-level and remove nested `undefined` values in Onyx.mergeCollection', () => { + let result: OnyxCollection; + + const routineRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}routine`; + const holidayRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}holiday`; + const workRoute = `${ONYX_KEYS.COLLECTION.TEST_KEY}work`; + + connectionID = Onyx.connect({ + key: ONYX_KEYS.COLLECTION.TEST_KEY, + initWithStoredValues: false, + callback: (value) => (result = value), + waitForCollectionCallback: true, + }); + + return Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { + [routineRoute]: { + waypoints: { + 1: 'Home', + 2: 'Work', + 3: undefined, + }, + }, + [holidayRoute]: { + waypoints: undefined, + }, + [workRoute]: undefined, + } as GenericCollection).then(() => { + expect(result).toEqual({ + [routineRoute]: { + waypoints: { + 1: 'Home', + 2: 'Work', + }, + }, + [holidayRoute]: {}, + [workRoute]: undefined, }); + }); }); it('should overwrite an array key nested inside an object', () => { @@ -818,7 +951,7 @@ describe('Onyx', () => { expect(mockCallback).toHaveBeenCalledTimes(2); // AND the value for the first call should be null since the collection was not initialized at that point - expect(mockCallback).toHaveBeenNthCalledWith(1, null, undefined); + expect(mockCallback).toHaveBeenNthCalledWith(1, undefined, undefined); // AND the value for the second call should be collectionUpdate since the collection was updated expect(mockCallback).toHaveBeenNthCalledWith(2, collectionUpdate); @@ -847,7 +980,7 @@ describe('Onyx', () => { expect(mockCallback).toHaveBeenCalledTimes(2); // AND the value for the first call should be null since the collection was not initialized at that point - expect(mockCallback).toHaveBeenNthCalledWith(1, null, undefined); + expect(mockCallback).toHaveBeenNthCalledWith(1, undefined, undefined); // AND the value for the second call should be collectionUpdate since the collection was updated expect(mockCallback).toHaveBeenNthCalledWith(2, collectionUpdate.testPolicy_1, 'testPolicy_1'); @@ -989,11 +1122,11 @@ describe('Onyx', () => { {onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYX_KEYS.COLLECTION.TEST_UPDATE, value: {[itemKey]: {a: 'a'}}}, ]).then(() => { expect(collectionCallback).toHaveBeenCalledTimes(2); - expect(collectionCallback).toHaveBeenNthCalledWith(1, null, undefined); + expect(collectionCallback).toHaveBeenNthCalledWith(1, undefined, undefined); expect(collectionCallback).toHaveBeenNthCalledWith(2, {[itemKey]: {a: 'a'}}); expect(testCallback).toHaveBeenCalledTimes(2); - expect(testCallback).toHaveBeenNthCalledWith(1, null, undefined); + expect(testCallback).toHaveBeenNthCalledWith(1, undefined, undefined); expect(testCallback).toHaveBeenNthCalledWith(2, 'taco', ONYX_KEYS.TEST_KEY); expect(otherTestCallback).toHaveBeenCalledTimes(2); @@ -1081,7 +1214,7 @@ describe('Onyx', () => { return waitForPromisesToResolve(); }) .then(() => { - expect(testKeyValue).toEqual(null); + expect(testKeyValue).toEqual(undefined); return Onyx.merge(ONYX_KEYS.TEST_KEY, 2); }) .then(() => { @@ -1089,7 +1222,7 @@ describe('Onyx', () => { }); }); - it('should merge a non-existing key with a nested null removed', () => { + it("should not set null values in Onyx.merge, when the key doesn't exist yet", () => { let testKeyValue: unknown; connectionID = Onyx.connect({ @@ -1144,37 +1277,10 @@ describe('Onyx', () => { return waitForPromisesToResolve(); }) .then(() => { - expect(testKeyValue).toEqual(null); + expect(testKeyValue).toEqual(undefined); }); }); - it('should merge a non-existing key with a nested null removed', () => { - let testKeyValue: unknown; - - connectionID = Onyx.connect({ - key: ONYX_KEYS.TEST_KEY, - initWithStoredValues: false, - callback: (value) => { - testKeyValue = value; - }, - }); - - return Onyx.merge(ONYX_KEYS.TEST_KEY, { - waypoints: { - 1: 'Home', - 2: 'Work', - 3: null, - }, - }).then(() => { - expect(testKeyValue).toEqual({ - waypoints: { - 1: 'Home', - 2: 'Work', - }, - }); - }); - }); - it('mergeCollection should omit nested null values', () => { let result: OnyxCollection; diff --git a/tests/unit/withOnyxTest.tsx b/tests/unit/withOnyxTest.tsx index 34eb9de7..b29e8f40 100644 --- a/tests/unit/withOnyxTest.tsx +++ b/tests/unit/withOnyxTest.tsx @@ -810,7 +810,7 @@ describe('withOnyxTest', () => { // Correct data has been passed to the selector expect(selector).not.toHaveBeenCalledWith('world', expect.anything()); expect(selector).not.toHaveBeenCalledWith('dougal', expect.anything()); - expect(selector).toHaveBeenLastCalledWith(sourceData, {loading: false, text: 'world'}); + expect(selector).toHaveBeenLastCalledWith(undefined, {loading: false, text: 'world'}); // Default text has been rendered expect(onRender).toHaveBeenCalledTimes(1);