react-native-onyx
Version:
State management for React Native
323 lines (322 loc) • 20.6 kB
JavaScript
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
/**
* 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.
*/
const react_1 = __importDefault(require("react"));
const OnyxUtils_1 = __importDefault(require("../OnyxUtils"));
const Str = __importStar(require("../Str"));
const utils_1 = __importDefault(require("../utils"));
const OnyxCache_1 = __importDefault(require("../OnyxCache"));
const OnyxConnectionManager_1 = __importDefault(require("../OnyxConnectionManager"));
// 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'];
/**
* Returns the display name of a component
*/
function getDisplayName(component) {
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 state of the component
* @param onyxToStateMapping the object holding all of the mapping configuration for the component
*/
function getOnyxDataFromState(state, onyxToStateMapping) {
return utils_1.default.pick(state, Object.keys(onyxToStateMapping));
}
/**
* Utility function to return the properly typed entries of the `withOnyx` mapping object.
*/
function mapOnyxToStateEntries(mapOnyxToState) {
return Object.entries(mapOnyxToState);
}
/**
* @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.
*/
function default_1(mapOnyxToState, shouldDelayUpdates = false) {
// A list of keys that must be present in tempState before we can render the WrappedComponent
const requiredKeysForInit = Object.keys(utils_1.default.omit(mapOnyxToState, ([, options]) => options.initWithStoredValues === false));
return (WrappedComponent) => {
const displayName = getDisplayName(WrappedComponent);
class withOnyx extends react_1.default.Component {
constructor(props) {
super(props);
this.pendingSetStates = [];
this.shouldDelayUpdates = shouldDelayUpdates;
this.setWithOnyxState = this.setWithOnyxState.bind(this);
this.flushPendingSetStates = this.flushPendingSetStates.bind(this);
// This stores all the Onyx connections to be used when the component unmounts so everything can be
// disconnected. It is a key value store with the format {[mapping.key]: connection metadata object}.
this.activeConnections = {};
const cachedState = mapOnyxToStateEntries(mapOnyxToState).reduce((resultObj, [propName, mapping]) => {
const key = Str.result(mapping.key, props);
let value = OnyxUtils_1.default.tryGetCachedValue(key, mapping);
const hasCacheForKey = OnyxCache_1.default.hasCacheForKey(key);
if (!hasCacheForKey && !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.
*/
const hasPendingMergeForKey = OnyxUtils_1.default.hasPendingMergeForKey(key);
const hasValueInCache = hasCacheForKey || value !== undefined;
const shouldSetState = mapping.initWithStoredValues !== false && ((hasValueInCache && !hasPendingMergeForKey) || !!mapping.allowStaleData);
if (shouldSetState) {
// eslint-disable-next-line no-param-reassign
resultObj[propName] = value;
}
return resultObj;
}, {});
// If we have all the data we need, then we can render the component immediately
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;
this.state = cachedState;
}
componentDidMount() {
const onyxDataFromState = getOnyxDataFromState(this.state, mapOnyxToState);
// Subscribe each of the state properties to the proper Onyx key
mapOnyxToStateEntries(mapOnyxToState).forEach(([propName, mapping]) => {
if (mappingPropertiesToIgnoreChangesTo.includes(propName)) {
return;
}
const key = Str.result(mapping.key, Object.assign(Object.assign({}, this.props), onyxDataFromState));
this.connectMappingToOnyx(mapping, propName, key);
});
this.checkEvictableKeys();
}
componentDidUpdate(prevProps, prevState) {
// 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.
const isFirstTimeUpdatingAfterLoading = prevState.loading && !this.state.loading;
const onyxDataFromState = getOnyxDataFromState(this.state, mapOnyxToState);
const prevOnyxDataFromState = getOnyxDataFromState(prevState, mapOnyxToState);
mapOnyxToStateEntries(mapOnyxToState).forEach(([propName, mapping]) => {
// Some properties can be ignored because they aren't related to onyx keys and they will never change
if (mappingPropertiesToIgnoreChangesTo.includes(propName)) {
return;
}
// The previous key comes from either:
// 1) The initial key that was connected to (ie. set from `componentDidMount()`)
// 2) The updated props which caused `componentDidUpdate()` to run
// The first case cannot be used all the time because of race conditions where `componentDidUpdate()` can be triggered before connectingMappingToOnyx() is done
// (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, Object.assign(Object.assign({}, prevProps), prevOnyxDataFromState));
const newKey = Str.result(mapping.key, Object.assign(Object.assign({}, this.props), onyxDataFromState));
if (previousKey !== newKey) {
OnyxConnectionManager_1.default.disconnect(this.activeConnections[previousKey]);
delete this.activeConnections[previousKey];
this.connectMappingToOnyx(mapping, propName, newKey);
}
});
this.checkEvictableKeys();
}
componentWillUnmount() {
// Disconnect everything from Onyx
mapOnyxToStateEntries(mapOnyxToState).forEach(([, mapping]) => {
const key = Str.result(mapping.key, Object.assign(Object.assign({}, this.props), getOnyxDataFromState(this.state, mapOnyxToState)));
OnyxConnectionManager_1.default.disconnect(this.activeConnections[key]);
});
}
setStateProxy(modifier) {
if (this.shouldDelayUpdates) {
this.pendingSetStates.push(modifier);
}
else {
this.setState(modifier);
}
}
/**
* This method is used by the internal raw Onyx `sendDataToConnection`, it is designed to prevent unnecessary renders while a component
* still in a "loading" (read "mounting") state. The temporary initial state is saved to the HOC instance and setState()
* only called once all the necessary data has been collected.
*
* There is however the possibility the component could have been updated by a call to setState()
* before the data was "initially" collected. A race condition.
* For example some update happened on some key, while onyx was still gathering the initial hydration data.
* This update is disptached directly to setStateProxy and therefore the component has the most up-to-date data
*
* This is a design flaw in Onyx itself as dispatching updates before initial hydration is not a correct event flow.
* 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.
*/
setWithOnyxState(statePropertyName, val) {
const prevVal = this.state[statePropertyName];
// If the component is not loading (read "mounting"), then we can just update the state
// There is a small race condition.
// When calling setWithOnyxState we delete the tempState object that is used to hold temporary state updates while the HOC is gathering data.
// However the loading flag is only set on the setState callback down below. setState however is an async operation that is also batched,
// therefore there is a small window of time where the loading flag is not false but the tempState is already gone
// (while the update is queued and waiting to be applied).
// This simply bypasses the loading check if the tempState is gone and the update can be safely queued with a normal setStateProxy.
if (!this.state.loading || !this.tempState) {
// Performance optimization, do not trigger update with same values
if (prevVal === val || (utils_1.default.isEmptyObject(prevVal) && utils_1.default.isEmptyObject(val))) {
return;
}
const valueWithoutNull = val === null ? undefined : val;
this.setStateProxy({ [statePropertyName]: valueWithoutNull });
return;
}
this.tempState[statePropertyName] = val;
// If some key does not have a value yet, do not update the state yet
const tempStateIsMissingKey = requiredKeysForInit.some((key) => { var _a; return !(key in ((_a = this.tempState) !== null && _a !== void 0 ? _a : {})); });
if (tempStateIsMissingKey) {
return;
}
const stateUpdate = Object.assign({}, this.tempState);
delete this.tempState;
// Full of hacky workarounds to prevent the race condition described above.
this.setState((prevState) => {
const finalState = Object.keys(stateUpdate).reduce((result, _key) => {
const key = _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 (initialValue !== undefined && prevState[key] !== undefined && prevState[key] !== initialValue && prevState[key] !== null) {
// eslint-disable-next-line no-param-reassign
result[key] = prevState[key];
}
else if (prevState[key] !== undefined && prevState[key] !== null) {
// if value is already there (without initial value) then we can discard the value we are trying to hydrate
// eslint-disable-next-line no-param-reassign
result[key] = prevState[key];
}
else if (stateUpdate[key] !== null) {
// eslint-disable-next-line no-param-reassign
result[key] = stateUpdate[key];
}
return result;
}, {});
finalState.loading = false;
return finalState;
});
}
/**
* Makes sure each Onyx key we requested has been set to state with a value of some kind.
* We are doing this so that the wrapped component will only render when all the data
* it needs is available to it.
*/
checkEvictableKeys() {
// 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.
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);
if (!OnyxCache_1.default.isEvictableKey(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({evictableKeys: []}).`);
}
if (canEvict) {
OnyxConnectionManager_1.default.removeFromEvictionBlockList(this.activeConnections[key]);
}
else {
OnyxConnectionManager_1.default.addToEvictionBlockList(this.activeConnections[key]);
}
});
}
/**
* Takes a single mapping and binds the state of the component to the store
*
* @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 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 key to connect to Onyx with
*/
connectMappingToOnyx(mapping, statePropertyName, key) {
const onyxMapping = mapOnyxToState[statePropertyName];
// 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
onyxMapping.previousKey = key;
// eslint-disable-next-line rulesdir/prefer-onyx-connect-in-libs
this.activeConnections[key] = OnyxConnectionManager_1.default.connect(Object.assign(Object.assign({}, mapping), { key, statePropertyName: statePropertyName, withOnyxInstance: this, displayName }));
}
flushPendingSetStates() {
if (!this.shouldDelayUpdates) {
return;
}
this.shouldDelayUpdates = false;
this.pendingSetStates.forEach((modifier) => {
this.setState(modifier);
});
this.pendingSetStates = [];
}
render() {
// Remove any null values so that React replaces them with default props
const propsToPass = utils_1.default.omit(this.props, ([, propValue]) => propValue === null);
if (this.state.loading) {
return null;
}
// Remove any internal state properties used by withOnyx
// that should not be passed to a wrapped component
const stateToPass = utils_1.default.omit(this.state, ([stateKey, stateValue]) => stateKey === 'loading' || stateValue === null);
// Spreading props and state is necessary in an HOC where the data cannot be predicted
return (react_1.default.createElement(WrappedComponent, Object.assign({ markReadyForHydration: this.flushPendingSetStates }, propsToPass, stateToPass, { ref: this.props.forwardedRef })));
}
}
withOnyx.displayName = `withOnyx(${displayName})`;
return react_1.default.forwardRef((props, ref) => {
const Component = withOnyx;
return (react_1.default.createElement(Component
// eslint-disable-next-line react/jsx-props-no-spreading
, Object.assign({}, props, { forwardedRef: ref })));
});
};
}
exports.default = default_1;
;