diff --git a/API-INTERNAL.md b/API-INTERNAL.md index aae82ccc7..db3e45f5f 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/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index e06496851..68054766e 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -3,32 +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, + DeepRecord, + DefaultConnectCallback, + DefaultConnectOptions, + KeyValueMapping, + Mapping, NullableKeyValueMapping, + OnyxCollection, + OnyxEntry, OnyxKey, OnyxValue, Selector, - WithOnyxInstanceState, - OnyxCollection, WithOnyxConnectOptions, - DefaultConnectOptions, - OnyxEntry, - KeyValueMapping, - DefaultConnectCallback, } from './types'; -import type Onyx from './Onyx'; +import utils from './utils'; +import type {WithOnyxState} from './withOnyx/types'; // Method constants const METHOD = { @@ -195,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 diff --git a/lib/index.ts b/lib/index.ts index e7e818893..79bcf8567 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,25 +1,26 @@ +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} 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, + OnyxUpdate, OnyxValue, - FetchStatus, ResultMetadata, + Selector, + UseOnyxResult, + WithOnyxState, }; diff --git a/lib/types.ts b/lib/types.ts index 72c6afbb4..7ea33e52f 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`. @@ -148,7 +148,7 @@ 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. @@ -252,11 +252,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. * @@ -274,11 +269,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; @@ -400,6 +390,9 @@ type InitOptions = { debugSetState?: boolean; }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type GenericFunction = (...args: any[]) => any; + export type { BaseConnectOptions, Collection, @@ -413,6 +406,7 @@ export type { DefaultConnectCallback, DefaultConnectOptions, ExtractOnyxCollectionValue, + GenericFunction, InitOptions, Key, KeyValueMapping, @@ -428,6 +422,4 @@ export type { OnyxValue, Selector, WithOnyxConnectOptions, - WithOnyxInstance, - WithOnyxInstanceState, }; diff --git a/lib/types/modules/react.d.ts b/lib/types/modules/react.d.ts new file mode 100644 index 000000000..3e2e8fb37 --- /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/utils.ts b/lib/utils.ts index 64c56c0da..fb64cea7e 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -138,4 +138,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 56% rename from lib/withOnyx.js rename to lib/withOnyx/index.tsx index cd862637c..3245bc8d4 100644 --- a/lib/withOnyx.js +++ b/lib/withOnyx/index.tsx @@ -3,50 +3,76 @@ * 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'; // 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 +81,34 @@ 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>); + if (!value && mapping.initialValue) { + value = mapping.initialValue; + } - /** - * 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 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[propName] = value as WithOnyxState[keyof TOnyxProps]; + } - return resultObj; - }, - {}, - ); + 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 +120,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 +140,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 +153,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,11 +195,8 @@ 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) { + setWithOnyxState(statePropertyName: T, val: WithOnyxState[T]) { const prevValue = this.state[statePropertyName]; // If the component is not loading (read "mounting"), then we can just update the state @@ -190,14 +212,14 @@ export default function (mapOnyxToState, shouldDelayUpdates = false) { return; } - this.setStateProxy({[statePropertyName]: val}); + this.setStateProxy({[statePropertyName]: val} 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) => this.tempState?.[key as keyof TOnyxProps] === undefined); if (tempStateIsMissingKey) { return; } @@ -207,35 +229,35 @@ 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) { + // 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 (prevState[key] !== undefined) { + // eslint-disable-next-line no-param-reassign + result[key] = prevState[key]; + } else { + // eslint-disable-next-line no-param-reassign + result[key] = stateUpdate[key]; + } + + return result; + }, {} as WithOnyxState); finalState.loading = false; + return finalState; }); } @@ -249,13 +271,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 +294,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 +327,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,8 +343,7 @@ 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 stateToPass = utils.omit(this.state as WithOnyxState, ([stateKey, stateValue]) => stateKey === 'loading' || stateValue === null); const stateToPassWithoutNestedNulls = utils.removeNestedNullValues(stateToPass); // Spreading props and state is necessary in an HOC where the data cannot be predicted @@ -328,7 +351,7 @@ export default function (mapOnyxToState, shouldDelayUpdates = false) { { 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 f665cea9f..06145551a 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, NullableKeyValueMapping, 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.json b/package.json index e47231ca3..777e3aa01 100644 --- a/package.json +++ b/package.json @@ -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",