diff --git a/package.json b/package.json index 576522b83..e0bac92c5 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,8 @@ "hoist-non-react-statics": "^1.0.3", "invariant": "^2.0.0", "lodash": "^4.2.0", - "loose-envify": "^1.1.0" + "loose-envify": "^1.1.0", + "reselect": "^2.5.1" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.0-0", diff --git a/src/components/connect.js b/src/components/connect.js index 3b60ebbce..0879dbc58 100644 --- a/src/components/connect.js +++ b/src/components/connect.js @@ -1,367 +1,116 @@ -import { Component, createElement } from 'react' -import storeShape from '../utils/storeShape' -import shallowEqual from '../utils/shallowEqual' -import wrapActionCreators from '../utils/wrapActionCreators' -import warning from '../utils/warning' import isPlainObject from 'lodash/isPlainObject' -import hoistStatics from 'hoist-non-react-statics' -import invariant from 'invariant' +import { bindActionCreators } from 'redux' +import { createSelector } from 'reselect' +import warning from '../utils/warning' + +import connectToStore, { createShallowEqualSelector } from './connectToStore' -const defaultMapStateToProps = state => ({}) // eslint-disable-line no-unused-vars -const defaultMapDispatchToProps = dispatch => ({ dispatch }) -const defaultMergeProps = (stateProps, dispatchProps, parentProps) => ({ - ...parentProps, +const defaultMergeProps = (stateProps, dispatchProps, ownProps) => ({ + ...ownProps, ...stateProps, ...dispatchProps }) -function getDisplayName(WrappedComponent) { - return WrappedComponent.displayName || WrappedComponent.name || 'Component' -} - -let errorObject = { value: null } -function tryCatch(fn, ctx) { - try { - return fn.apply(ctx) - } catch (e) { - errorObject.value = e - return errorObject - } -} - -// Helps track hot reloading. -let nextVersion = 0 - -export default function connect(mapStateToProps, mapDispatchToProps, mergeProps, options = {}) { - const shouldSubscribe = Boolean(mapStateToProps) - const mapState = mapStateToProps || defaultMapStateToProps - - let mapDispatch - if (typeof mapDispatchToProps === 'function') { - mapDispatch = mapDispatchToProps - } else if (!mapDispatchToProps) { - mapDispatch = defaultMapDispatchToProps - } else { - mapDispatch = wrapActionCreators(mapDispatchToProps) - } - - const finalMergeProps = mergeProps || defaultMergeProps - const { pure = true, withRef = false } = options - const checkMergedEquals = pure && finalMergeProps !== defaultMergeProps - - // Helps track hot reloading. - const version = nextVersion++ - - return function wrapWithConnect(WrappedComponent) { - const connectDisplayName = `Connect(${getDisplayName(WrappedComponent)})` - +const empty = {} + +export default function connect( + mapStateToProps, + mapDispatchToProps, + mergeProps, + { + pure = true, + withRef = false + } = {} +) { + function selectorFactory({ displayName }) { function checkStateShape(props, methodName) { if (!isPlainObject(props)) { warning( - `${methodName}() in ${connectDisplayName} must return a plain object. ` + + `${methodName}() in ${displayName} must return a plain object. ` + `Instead received ${props}.` ) } } - function computeMergedProps(stateProps, dispatchProps, parentProps) { - const mergedProps = finalMergeProps(stateProps, dispatchProps, parentProps) - if (process.env.NODE_ENV !== 'production') { - checkStateShape(mergedProps, 'mergeProps') + function verify(methodName, selector) { + return (...args) => { + const result = selector(...args) + checkStateShape(result, methodName) + return result } - return mergedProps } - class Connect extends Component { - shouldComponentUpdate() { - return !pure || this.haveOwnPropsChanged || this.hasStoreStateChanged - } - - constructor(props, context) { - super(props, context) - this.version = version - this.store = props.store || context.store - - invariant(this.store, - `Could not find "store" in either the context or ` + - `props of "${connectDisplayName}". ` + - `Either wrap the root component in a , ` + - `or explicitly pass "store" as a prop to "${connectDisplayName}".` - ) - - const storeState = this.store.getState() - this.state = { storeState } - this.clearCache() - } - - computeStateProps(store, props) { - if (!this.finalMapStateToProps) { - return this.configureFinalMapState(store, props) - } - - const state = store.getState() - const stateProps = this.doStatePropsDependOnOwnProps ? - this.finalMapStateToProps(state, props) : - this.finalMapStateToProps(state) - - if (process.env.NODE_ENV !== 'production') { - checkStateShape(stateProps, 'mapStateToProps') - } - return stateProps - } - - configureFinalMapState(store, props) { - const mappedState = mapState(store.getState(), props) - const isFactory = typeof mappedState === 'function' - - this.finalMapStateToProps = isFactory ? mappedState : mapState - this.doStatePropsDependOnOwnProps = this.finalMapStateToProps.length !== 1 - - if (isFactory) { - return this.computeStateProps(store, props) - } - - if (process.env.NODE_ENV !== 'production') { - checkStateShape(mappedState, 'mapStateToProps') - } - return mappedState - } - - computeDispatchProps(store, props) { - if (!this.finalMapDispatchToProps) { - return this.configureFinalMapDispatch(store, props) - } - - const { dispatch } = store - const dispatchProps = this.doDispatchPropsDependOnOwnProps ? - this.finalMapDispatchToProps(dispatch, props) : - this.finalMapDispatchToProps(dispatch) - - if (process.env.NODE_ENV !== 'production') { - checkStateShape(dispatchProps, 'mapDispatchToProps') - } - return dispatchProps - } - - configureFinalMapDispatch(store, props) { - const mappedDispatch = mapDispatch(store.dispatch, props) - const isFactory = typeof mappedDispatch === 'function' - - this.finalMapDispatchToProps = isFactory ? mappedDispatch : mapDispatch - this.doDispatchPropsDependOnOwnProps = this.finalMapDispatchToProps.length !== 1 - - if (isFactory) { - return this.computeDispatchProps(store, props) - } - - if (process.env.NODE_ENV !== 'production') { - checkStateShape(mappedDispatch, 'mapDispatchToProps') - } - return mappedDispatch - } - - updateStatePropsIfNeeded() { - const nextStateProps = this.computeStateProps(this.store, this.props) - if (this.stateProps && shallowEqual(nextStateProps, this.stateProps)) { - return false - } - - this.stateProps = nextStateProps - return true - } - - updateDispatchPropsIfNeeded() { - const nextDispatchProps = this.computeDispatchProps(this.store, this.props) - if (this.dispatchProps && shallowEqual(nextDispatchProps, this.dispatchProps)) { - return false - } + const ownPropsSelector = createShallowEqualSelector( + (_, props) => props, + props => props + ) - this.dispatchProps = nextDispatchProps - return true - } - - updateMergedPropsIfNeeded() { - const nextMergedProps = computeMergedProps(this.stateProps, this.dispatchProps, this.props) - if (this.mergedProps && checkMergedEquals && shallowEqual(nextMergedProps, this.mergedProps)) { - return false - } + function getStatePropsSelector() { + if (!mapStateToProps) return () => empty - this.mergedProps = nextMergedProps - return true + if (!pure) { + return (state, props) => mapStateToProps(state, props) } - isSubscribed() { - return typeof this.unsubscribe === 'function' - } - - trySubscribe() { - if (shouldSubscribe && !this.unsubscribe) { - this.unsubscribe = this.store.subscribe(this.handleChange.bind(this)) - this.handleChange() - } - } - - tryUnsubscribe() { - if (this.unsubscribe) { - this.unsubscribe() - this.unsubscribe = null - } - } - - componentDidMount() { - this.trySubscribe() + if (mapStateToProps.length === 1) { + return createSelector( + state => state, + mapStateToProps + ) } - componentWillReceiveProps(nextProps) { - if (!pure || !shallowEqual(nextProps, this.props)) { - this.haveOwnPropsChanged = true - } - } + return createSelector( + state => state, + ownPropsSelector, + mapStateToProps + ) + } - componentWillUnmount() { - this.tryUnsubscribe() - this.clearCache() - } + function getDispatchPropsSelector() { + if (!mapDispatchToProps) return (_, __, dispatch) => ({ dispatch }) - clearCache() { - this.dispatchProps = null - this.stateProps = null - this.mergedProps = null - this.haveOwnPropsChanged = true - this.hasStoreStateChanged = true - this.haveStatePropsBeenPrecalculated = false - this.statePropsPrecalculationError = null - this.renderedElement = null - this.finalMapDispatchToProps = null - this.finalMapStateToProps = null + if (typeof mapDispatchToProps !== 'function') { + return createSelector( + (_, __, dispatch) => dispatch, + dispatch => bindActionCreators(mapDispatchToProps, dispatch) + ) } - handleChange() { - if (!this.unsubscribe) { - return - } - - const storeState = this.store.getState() - const prevStoreState = this.state.storeState - if (pure && prevStoreState === storeState) { - return - } - - if (pure && !this.doStatePropsDependOnOwnProps) { - const haveStatePropsChanged = tryCatch(this.updateStatePropsIfNeeded, this) - if (!haveStatePropsChanged) { - return - } - if (haveStatePropsChanged === errorObject) { - this.statePropsPrecalculationError = errorObject.value - } - this.haveStatePropsBeenPrecalculated = true - } - - this.hasStoreStateChanged = true - this.setState({ storeState }) + if (!pure) { + return (_, props, dispatch) => mapDispatchToProps(dispatch, props) } - getWrappedInstance() { - invariant(withRef, - `To access the wrapped instance, you need to specify ` + - `{ withRef: true } as the fourth argument of the connect() call.` + if (mapDispatchToProps.length === 1) { + return createSelector( + (_, __, dispatch) => dispatch, + mapDispatchToProps ) - - return this.refs.wrappedInstance } - render() { - const { - haveOwnPropsChanged, - hasStoreStateChanged, - haveStatePropsBeenPrecalculated, - statePropsPrecalculationError, - renderedElement - } = this - - this.haveOwnPropsChanged = false - this.hasStoreStateChanged = false - this.haveStatePropsBeenPrecalculated = false - this.statePropsPrecalculationError = null - - if (statePropsPrecalculationError) { - throw statePropsPrecalculationError - } - - let shouldUpdateStateProps = true - let shouldUpdateDispatchProps = true - if (pure && renderedElement) { - shouldUpdateStateProps = hasStoreStateChanged || ( - haveOwnPropsChanged && this.doStatePropsDependOnOwnProps - ) - shouldUpdateDispatchProps = - haveOwnPropsChanged && this.doDispatchPropsDependOnOwnProps - } - - let haveStatePropsChanged = false - let haveDispatchPropsChanged = false - if (haveStatePropsBeenPrecalculated) { - haveStatePropsChanged = true - } else if (shouldUpdateStateProps) { - haveStatePropsChanged = this.updateStatePropsIfNeeded() - } - if (shouldUpdateDispatchProps) { - haveDispatchPropsChanged = this.updateDispatchPropsIfNeeded() - } - - let haveMergedPropsChanged = true - if ( - haveStatePropsChanged || - haveDispatchPropsChanged || - haveOwnPropsChanged - ) { - haveMergedPropsChanged = this.updateMergedPropsIfNeeded() - } else { - haveMergedPropsChanged = false - } - - if (!haveMergedPropsChanged && renderedElement) { - return renderedElement - } - - if (withRef) { - this.renderedElement = createElement(WrappedComponent, { - ...this.mergedProps, - ref: 'wrappedInstance' - }) - } else { - this.renderedElement = createElement(WrappedComponent, - this.mergedProps - ) - } - - return this.renderedElement - } - } - - Connect.displayName = connectDisplayName - Connect.WrappedComponent = WrappedComponent - Connect.contextTypes = { - store: storeShape - } - Connect.propTypes = { - store: storeShape + return createSelector( + (_, __, dispatch) => dispatch, + ownPropsSelector, + mapDispatchToProps + ) } - if (process.env.NODE_ENV !== 'production') { - Connect.prototype.componentWillUpdate = function componentWillUpdate() { - if (this.version === version) { - return - } + return verify('mergeProps', createShallowEqualSelector( + verify('mapStateToProps', getStatePropsSelector()), + verify('mapDispatchToProps', getDispatchPropsSelector()), + ownPropsSelector, + mergeProps || defaultMergeProps + )) + } - // We are hot reloading! - this.version = version - this.trySubscribe() - this.clearCache() - } + return connectToStore( + selectorFactory, + { + pure, + withRef, + getDisplayName: name => `Connect(${name})`, + recomputationsProp: null, + shouldIncludeOriginalProps: !mergeProps, + shouldUseState: Boolean(mapStateToProps) } - - return hoistStatics(Connect, WrappedComponent) - } + ) } diff --git a/src/components/connectToStore.js b/src/components/connectToStore.js new file mode 100644 index 000000000..90b34bf5e --- /dev/null +++ b/src/components/connectToStore.js @@ -0,0 +1,222 @@ +import hoistStatics from 'hoist-non-react-statics' +import invariant from 'invariant' +import { Component, createElement } from 'react' +import { + createSelector, + createSelectorCreator, + createStructuredSelector, + defaultMemoize +} from 'reselect' + +import shallowEqual from '../utils/shallowEqual' +import storeShape from '../utils/storeShape' + +export { createSelector as createSelector } +export { createStructuredSelector as createStructuredSelector } +export const createShallowEqualSelector = createSelectorCreator(defaultMemoize, shallowEqual) + +export const selectDispatch = (_, __, dispatch) => dispatch + +export function dispatchable(actionCreator, ...selectorsToPartiallyApply) { + if (selectorsToPartiallyApply.length === 0) { + return createSelector( + selectDispatch, + dispatch => (...args) => dispatch(actionCreator(...args)) + ) + } + + return createSelector( + selectDispatch, + ...selectorsToPartiallyApply, + (dispatch, ...partialArgs) => (...args) => dispatch(actionCreator(...partialArgs, ...args)) + ) +} + +let hotReloadingVersion = 0 + +export default function connectToStore( + /* + this func is responsible for returning the selector function used to compute new props from + state, props, and dispatch. For example: + + export default connectToStore(() => (state, props, dispatch) => ({ + thing: state.things[props.thingId], + saveThing: fields => dispatch(actionCreators.saveThing(props.thingId, fields)), + }))(YourComponent) + + Alternatively, it can return a plain object which will be passed to reselect's + 'createStructuredSelector' function to create the selector. For example: + + return connectToStore(() => ({ + thing: (state, props) => state.things[props.thingId], + saveThing: (_, props, dispatch) => fields => ( + dispatch(actionCreators.saveThing(props.thingId, fields)) + ), + }))(YourComponent) + + This is equivalent to wrapping the returned object in a call to `createStructuredSelector`, + but is supported as a convenience; This is the recommended approach to defining your + selectorFactory methods. The above example can be simplfied by using the `dispatchable` helper + method provided with connectToStore: + + connectToStore(() => ({ + thing: (state, props) => state.things[props.thingId], + saveThing: dispatchable(actionCreators.saveThing, (_, props) => props.thingId), + }))(YourComponent) + + A verbose but descriptive name for `dispatchable` would be `createBoundActionCreatorSelector`. + `dispatchable` will return a selector that binds the passed action creator arg to dispatch. Any + additional args given will be treated as selectors whose results should be partially applied to + the action creator. + */ + selectorFactory, + // options object: + { + // the func used to compute this HOC's displayName from the wrapped component's displayName. + getDisplayName = name => `connectToStore(${name})`, + + // if true, shouldComponentUpdate will only be true of the selector recomputes for nextProps. + // if false, shouldComponentUpdate will always be true. + pure = true, + + // the name of the property passed to the wrapped element indicating the number of. + // recomputations since it was mounted. useful for watching for unnecessary re-renders. + recomputationsProp = process.env.NODE_ENV !== 'production' ? '__recomputations' : null, + + // if true, the props passed to this HOC are merged with the results of the selector; in the + // case of key collision, selector value is kept and prop is discared. if false, only the + // selector results are passed to the wrapped element. + shouldIncludeOriginalProps = true, + + // if true, the selector receieves the current store state as the first arg, and this HOC + // subscribes to store changes. if false, null is passed as the first arg of selector. + shouldUseState = true, + + // the key of props/context to get the store + storeKey = 'store', + + // if true, the wrapped element is exposed by this HOC via the getWrappedInstance() function. + withRef = false + } = {} +) { + function buildSelector() { + const { displayName, store } = this + const factoryResult = selectorFactory({ displayName }) + const ref = withRef ? 'wrappedInstance' : undefined + + const selector = createShallowEqualSelector( + // original props selector: + shouldIncludeOriginalProps + ? ((_, props) => props) + : (() => null), + + // sourceSelector + typeof factoryResult === 'function' + ? factoryResult + : createStructuredSelector(factoryResult), + + // combine original props + selector props + ref + (props, sourceSelectorResults) => ({ + ...props, + ...sourceSelectorResults, + ref + })) + + return function runSelector(props) { + const recomputationsBefore = selector.recomputations() + const storeState = shouldUseState ? store.getState() : null + const selectorResults = selector(storeState, props, store.dispatch) + const recomputationsAfter = selector.recomputations() + + const finalProps = recomputationsProp + ? { ...selectorResults, [recomputationsProp]: recomputationsAfter } + : selectorResults + + return { + props: finalProps, + shouldUpdate: recomputationsBefore !== recomputationsAfter + } + } + } + + const version = hotReloadingVersion++ + + return function wrapWithConnect(WrappedComponent) { + class Connect extends Component { + componentWillMount() { + this.version = version + this.displayName = Connect.displayName + this.store = this.props[storeKey] || this.context[storeKey] + + invariant(this.store, + `Could not find "store" in either the context or ` + + `props of "${Connect.displayName}". ` + + `Either wrap the root component in a , ` + + `or explicitly pass "store" as a prop to "${Connect.displayName}".` + ) + + this.selector = buildSelector.call(this) + this.trySubscribe() + } + + shouldComponentUpdate(nextProps) { + return !pure || this.selector(nextProps).shouldUpdate + } + + componentWillUnmount() { + if (this.unsubscribe) this.unsubscribe() + this.unsubscribe = null + this.selector = () => ({ props: {}, shouldUpdate: false }) + this.store = null + } + + getWrappedInstance() { + invariant(withRef, + `To access the wrapped instance, you need to specify ` + + `{ withRef: true } as the fourth argument of the connect() call.` + ) + + return this.refs.wrappedInstance + } + + trySubscribe() { + if (!shouldUseState || this.unsubscribe) return + + this.unsubscribe = this.store.subscribe(() => { + if (this.selector(this.props).shouldUpdate) this.forceUpdate() + }) + } + + isSubscribed() { + return typeof this.unsubscribe === 'function' + } + + render() { + const { props } = this.selector(this.props) + return createElement(WrappedComponent, props) + } + } + + const wrappedComponentName = WrappedComponent.displayName + || WrappedComponent.name + || 'Component' + + Connect.displayName = getDisplayName(wrappedComponentName) + Connect.WrappedComponent = WrappedComponent + Connect.contextTypes = { [storeKey]: storeShape } + Connect.propTypes = { [storeKey]: storeShape } + + if (process.env.NODE_ENV !== 'production') { + Connect.prototype.componentWillUpdate = function componentWillUpdate() { + if (this.version === version) return + + // We are hot reloading! + this.version = version + this.trySubscribe() + this.selector = buildSelector.call(this) + } + } + + return hoistStatics(Connect, WrappedComponent) + } +}