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';
// TODO: could we somehow type the `...args` with a generic?
export type Reducer = (arg0: S, ...args: any[]) => S;
export type Reducers
= {
//TODO: used any here to cover cases where a component's reducers file also
//exports child component reducers, e.g. SingleSelect/DropMenu
[K in keyof P]?: Reducer | Reducers
| Reducers
;
};
export type Selector = (arg0: S) => any;
export type Selectors
= {
[K in keyof P]?: (arg0: S) => any;
};
interface IStateOptions {
getState: () => S;
setState: (arg0: S) => void;
}
interface IBoundContext
{ getPropReplaceReducers(props: P): {} & S & P; getProps(props: P): {} & S & P; } interface IBuildHybridComponentOptions
{ replaceEvents?: boolean; // TODO: pretty sure this isn't used in anx-react or lucid, I looked through the git history and even when Joe wrote it in 2016 he didn't seem to need it for any concrete use case reducers?: Reducers
; selectors?: Selectors
; } interface IBaseComponentType
{ displayName: string; } /* Returns an array of paths for each reducer function */ export function getDeepPaths( obj: { [k: string]: any } | null = null, path: string[] = [] ): string[][] { return _.reduce( obj, (terminalKeys: string[][], 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: any): boolean { return _.isPlainObject(obj) || _.get(obj, '__esModule', false); } /** Recursively removes function type properties from obj */ export function omitFunctionPropsDeep
(obj: object | P | null = null) { return _.reduce<{ [k: string]: any }, { [k: string]: any }>( obj, (memo, value, key) => { if (isPlainObjectOrEsModule(value)) { memo[key] = omitFunctionPropsDeep(value); } else if (!_.isFunction(value)) { memo[key] = value; } return memo; }, {} ); } export function bindReducerToState
(
reducerFunction: Reducer,
{ getState, setState }: IStateOptions,
path: string[] = []
) {
const localPath = _.take(path, _.size(path) - 1);
return _.assign(
function (...args: any[]) {
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: Reducers
,
{ getState, setState }: IStateOptions
) {
return _.reduce(
getDeepPaths(reducers),
(memo, path) => {
return _.set(
memo,
path,
bindReducerToState(_.get(reducers, path), { getState, setState }, path)
);
},
{}
);
}
/*
*/
export function getStatefulPropsContext
( reducers: Reducers
,
{ getState, setState }: IStateOptions
): IBoundContext
{ const boundReducers = bindReducersToState(reducers, { getState, setState }); const combineFunctionsCustomizer = (objValue: any, srcValue: any) => { if (_.isFunction(srcValue) && _.isFunction(objValue)) { return function (...args: any[]) { objValue(...args); return srcValue(...args); }; } return safeMerge(objValue, srcValue); }; const bindFunctionOverwritesCustomizer = ( objValue: { (...args: any[]): any; path: string[] }, srcValue: any ) => { if (_.isFunction(srcValue) && _.isFunction(objValue)) { return bindReducerToState( srcValue, { getState, setState }, objValue.path ); } return safeMerge(objValue, srcValue); }; return { getPropReplaceReducers(props: P) { return _.mergeWith( {}, boundReducers, getState(), props, bindFunctionOverwritesCustomizer ); }, getProps(props: P) { 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: any = _.memoize((selectors: object) => { 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 as any, (state: { [k: string]: any }) => _.reduce( selectors, (acc: object, selector: any, key: string) => ({ ...acc, [key]: _.isFunction(selector) ? selector(state) : reduceSelectors(selector)(state[key]), }), state ) ); }); export function safeMerge(objValue: any, srcValue: any) { // 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: any, { 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 interface IHybridComponent
{ reducers: Reducers
; selectors: Selectors
; peekDefaultProps: { [key: string]: any }; // not sure how to give this a better type } export function buildModernHybridComponent< P extends object = {}, S extends object = {}, BaseType extends object = {} >( BaseComponent: React.ComponentType
, { replaceEvents = false, reducers = {}, selectors = {}, }: IBuildHybridComponentOptions
) {
// TODO: make sure hybrid components don't get double wrapped. Maybe use a type guard?
type AugmentedProps = P & { initialState?: P & S };
const selector = reduceSelectors(selectors);
class HybridComponent extends React.Component ;
// It would be nice to prepend "Hybrid" to this but some of our component
// sadly rely on the displayName remaining unchanged. E.g. `VerticalListMenu`.
static displayName = BaseComponent.displayName;
static propTypes = BaseComponent.propTypes;
static reducers = reducers;
static selectors = selectors;
static peekDefaultProps = BaseComponent.defaultProps;
// 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: AugmentedProps) {
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: S = this.state;
this.boundContext = getStatefulPropsContext (reducers, {
getState: () =>
_.mergeWith(
{},
omitFunctionPropsDeep(synchronousState),
omitFunctionPropsDeep(this.props),
safeMerge
) as S,
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
);
}
}
// 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) as BaseType &
IHybridComponent ;
}
/*
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);
}
*/