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.
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