UNPKG

lucid-ui

Version:

A UI component library from AppNexus.

224 lines (222 loc) 10.4 kB
import React, { isValidElement } from 'react'; import _ from 'lodash'; import { logger } from './logger'; import { createSelector } from 'reselect'; import createClass from 'create-react-class'; import hoistNonReactStatics from 'hoist-non-react-statics'; /* Returns an array of paths for each reducer function */ export function getDeepPaths(obj = null, path = []) { return _.reduce(obj, (terminalKeys, value, key) => isPlainObjectOrEsModule(value) ? //getDeepPaths if value is a module or object (another Reducers) terminalKeys.concat(getDeepPaths(value, path.concat(key))) : //add key to terminalKeys (probably a Reducer (function)) terminalKeys.concat([path.concat(key)]), []); } export function isPlainObjectOrEsModule(obj) { return _.isPlainObject(obj) || _.get(obj, '__esModule', false); } /** Recursively removes function type properties from obj */ export function omitFunctionPropsDeep(obj = null) { return _.reduce(obj, (memo, value, key) => { if (isPlainObjectOrEsModule(value)) { memo[key] = omitFunctionPropsDeep(value); } else if (!_.isFunction(value)) { memo[key] = value; } return memo; }, {}); } export function bindReducerToState(reducerFunction, { getState, setState }, path = []) { const localPath = _.take(path, _.size(path) - 1); return _.assign(function (...args) { if (_.isEmpty(localPath)) { // Source of bug, `reducerFunction` returns undefined setState(reducerFunction(getState(), ...args)); } else { const localNextState = reducerFunction(_.get(getState(), localPath), ...args); setState(_.set(_.clone(getState()), localPath, localNextState)); } }, { path }); } export function bindReducersToState(reducers, { getState, setState }) { return _.reduce(getDeepPaths(reducers), (memo, path) => { return _.set(memo, path, bindReducerToState(_.get(reducers, path), { getState, setState }, path)); }, {}); } /* */ export function getStatefulPropsContext(reducers, { getState, setState }) { const boundReducers = bindReducersToState(reducers, { getState, setState }); const combineFunctionsCustomizer = (objValue, srcValue) => { if (_.isFunction(srcValue) && _.isFunction(objValue)) { return function (...args) { objValue(...args); return srcValue(...args); }; } return safeMerge(objValue, srcValue); }; const bindFunctionOverwritesCustomizer = (objValue, srcValue) => { if (_.isFunction(srcValue) && _.isFunction(objValue)) { return bindReducerToState(srcValue, { getState, setState }, objValue.path); } return safeMerge(objValue, srcValue); }; return { getPropReplaceReducers(props) { return _.mergeWith({}, boundReducers, getState(), props, bindFunctionOverwritesCustomizer); }, getProps(props) { return _.mergeWith({}, boundReducers, getState(), props, combineFunctionsCustomizer); }, }; } /** * reduceSelectors * * Generates a root selector from a tree of selectors * @param {Object} selectors - a tree of selectors * @returns {function} root selector that when called with state, calls each of * the selectors in the tree with the state local to that selector. * * This function is memoized because it's recursive, and we want it to reuse * the functions created in the recursive reduce because those functions are * also memoized (reselect selectors are memoized with a cache of 1) and we want * to maintain their caches. * * TODO: the types suck on this function but we spent a couple hours trying to * get them to work and we couldn't figure out how to get generics to pass * through _.memoize correctly. ¯\_(ツ)_/¯ */ export const reduceSelectors = _.memoize((selectors) => { if (!isPlainObjectOrEsModule(selectors)) { throw new Error('Selectors must be a plain object with function or plain object values'); } /** * For each iteration of `reduceSelectors`, we return a memoized selector so * that individual branches maintain reference equality if they haven't been * modified, even if a sibling (and therefore the parent) has been modified. */ return createSelector(_.identity, (state) => _.reduce(selectors, (acc, selector, key) => ({ ...acc, [key]: _.isFunction(selector) ? selector(state) : reduceSelectors(selector)(state[key]), }), state)); }); export function safeMerge(objValue, srcValue) { // don't merge arrays if (_.isArray(srcValue) && _.isArray(objValue)) { return srcValue; } // guards against traversing react elements which can cause cyclical recursion // If we don't have this clause, lodash (as of 4.7.0) will attempt to // deeply clone the react children, which is really freaking slow. if (isValidElement(srcValue) || (_.isArray(srcValue) && _.some(srcValue, isValidElement)) || (_.isArray(srcValue) && _.isUndefined(objValue))) { return srcValue; } } export function buildHybridComponent(baseComponent, { replaceEvents = false, // if true, function props replace the existing reducers, else they are invoked *after* state reducer returns reducers = _.get(baseComponent, 'definition.statics.reducers', {}), selectors = _.get(baseComponent, 'definition.statics.selectors', {}), } = {}) { const { _isLucidHybridComponent, displayName, propTypes, definition: { statics = {} } = {}, defaultProps, } = baseComponent; if (_isLucidHybridComponent) { logger.warnOnce(displayName, `Lucid: you are trying to apply buildHybridComponent to ${displayName}, which is already a hybrid component. Lucid exports hybrid components by default. To access the dumb components, use the -Dumb suffix, e.g. "ComponentDumb"`); return baseComponent; } const selector = reduceSelectors(selectors); return createClass({ propTypes, statics: { _isLucidHybridComponent: true, peekDefaultProps: defaultProps, ...statics, }, displayName, getInitialState() { const { initialState } = this.props; //initial state overrides return _.mergeWith({}, omitFunctionPropsDeep(baseComponent.defaultProps), initialState, safeMerge); }, UNSAFE_componentWillMount() { let synchronousState = this.state; //store reference to state, use in place of `this.state` in `getState` this.boundContext = getStatefulPropsContext(reducers, { getState: () => _.mergeWith({}, omitFunctionPropsDeep(synchronousState), omitFunctionPropsDeep(this.props), safeMerge), setState: (state) => { synchronousState = state; //synchronously update the state reference this.setState(state); }, }); }, render() { if (replaceEvents) { return React.createElement(baseComponent, selector(this.boundContext.getPropReplaceReducers(this.props)), this.props.children); } return React.createElement(baseComponent, selector(this.boundContext.getProps(this.props)), this.props.children); }, }); } export function buildModernHybridComponent(BaseComponent, { replaceEvents = false, reducers = {}, selectors = {}, }) { // TODO: make sure hybrid components don't get double wrapped. Maybe use a type guard? const selector = reduceSelectors(selectors); class HybridComponent extends React.Component { // Note: we purposefully *do not* set defaultProps here as that would // effectively eliminate our ability to distinguish what props the user // explicity included. constructor(props) { super(props); const { initialState } = props; // initial state overrides this.state = _.mergeWith({}, omitFunctionPropsDeep(BaseComponent.defaultProps), initialState, safeMerge); } UNSAFE_componentWillMount() { // store reference to state, use in place of `this.state` in `getState` let synchronousState = this.state; this.boundContext = getStatefulPropsContext(reducers, { getState: () => _.mergeWith({}, omitFunctionPropsDeep(synchronousState), omitFunctionPropsDeep(this.props), safeMerge), setState: (state) => { synchronousState = state; //synchronously update the state reference this.setState(state); }, }); } render() { if (this.boundContext === undefined) { return null; } if (replaceEvents) { return React.createElement(BaseComponent, selector(this.boundContext.getPropReplaceReducers(this.props)), this.props.children); } return React.createElement(BaseComponent, selector(this.boundContext.getProps(this.props)), this.props.children); } } // It would be nice to prepend "Hybrid" to this but some of our component // sadly rely on the displayName remaining unchanged. E.g. `VerticalListMenu`. HybridComponent.displayName = BaseComponent.displayName; HybridComponent.propTypes = BaseComponent.propTypes; HybridComponent.reducers = reducers; HybridComponent.selectors = selectors; HybridComponent.peekDefaultProps = BaseComponent.defaultProps; // I used a type cast and intersection with `BaseType` here because I // couldn't figure out any other way to generate a valid type signuture to // reflected all the statics on the unerlying base component. @jondlm 2019-11-27 // @ts-ignore return hoistNonReactStatics(HybridComponent, BaseComponent); } /* export function buildStatefulComponent(...args: any[]) { logger.warnOnce( 'buildHybridComponent-once', 'Lucid: buildStatefulComponent has been renamed to buildHybridComponent.' ); // We don't really care about type checking our legacy buildHybridComponent // @ts-ignore return buildHybridComponent(...args); } */ //# sourceMappingURL=state-management.js.map