diff --git a/src/components/connect.js b/src/components/connect.js deleted file mode 100644 index 3b60ebbce..000000000 --- a/src/components/connect.js +++ /dev/null @@ -1,367 +0,0 @@ -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' - -const defaultMapStateToProps = state => ({}) // eslint-disable-line no-unused-vars -const defaultMapDispatchToProps = dispatch => ({ dispatch }) -const defaultMergeProps = (stateProps, dispatchProps, parentProps) => ({ - ...parentProps, - ...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)})` - - function checkStateShape(props, methodName) { - if (!isPlainObject(props)) { - warning( - `${methodName}() in ${connectDisplayName} 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') - } - 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 - } - - 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 - } - - this.mergedProps = nextMergedProps - return true - } - - 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() - } - - componentWillReceiveProps(nextProps) { - if (!pure || !shallowEqual(nextProps, this.props)) { - this.haveOwnPropsChanged = true - } - } - - componentWillUnmount() { - this.tryUnsubscribe() - this.clearCache() - } - - 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 - } - - 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 }) - } - - 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 - } - - 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 - } - - 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.clearCache() - } - } - - return hoistStatics(Connect, WrappedComponent) - } -} diff --git a/src/components/connectAdvanced.js b/src/components/connectAdvanced.js new file mode 100644 index 000000000..6736e7a9d --- /dev/null +++ b/src/components/connectAdvanced.js @@ -0,0 +1,253 @@ +import hoistStatics from 'hoist-non-react-statics' +import invariant from 'invariant' +import { Component, PropTypes, createElement } from 'react' + +import Subscription from '../utils/Subscription' +import storeShape from '../utils/storeShape' + +let hotReloadingVersion = 0 +export default function connectAdvanced( + /* + selectorFactory is a func that is responsible for returning the selector function used to + compute new props from state, props, and dispatch. For example: + + export default connectAdvanced((dispatch, options) => (state, props) => ({ + thing: state.things[props.thingId], + saveThing: fields => dispatch(actionCreators.saveThing(props.thingId, fields)), + }))(YourComponent) + + Access to dispatch is provided to the factory so selectorFactories can bind actionCreators + outside of their selector as an optimization. Options passed to connectAdvanced are passed to + the selectorFactory, along with displayName and WrappedComponent, as the second argument. + + Note that selectorFactory is responsible for all caching/memoization of inbound and outbound + props. Do not use connectAdvanced directly without memoizing results between calls to your + selector, otherwise the Connect component will re-render on every state or props change. + */ + selectorFactory, + // options object: + { + // the func used to compute this HOC's displayName from the wrapped component's displayName. + // probably overridden by wrapper functions such as connect() + getDisplayName = name => `ConnectAdvanced(${name})`, + + // shown in error messages + // probably overridden by wrapper functions such as connect() + methodName = 'connectAdvanced', + + // if defined, the name of the property passed to the wrapped element indicating the number of + // calls to render. useful for watching in react devtools for unnecessary re-renders. + renderCountProp = undefined, + + // determines whether this HOC subscribes to store changes + shouldHandleStateChanges = 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, + + // additional options are passed through to the selectorFactory + ...connectOptions + } = {} +) { + const subscriptionKey = storeKey + 'Subscription' + const version = hotReloadingVersion++ + + const contextTypes = { + [storeKey]: storeShape, + [subscriptionKey]: PropTypes.instanceOf(Subscription) + } + const childContextTypes = { + [subscriptionKey]: PropTypes.instanceOf(Subscription) + } + + return function wrapWithConnect(WrappedComponent) { + invariant( + typeof WrappedComponent == 'function', + `You must pass a component to the function returned by ` + + `connect. Instead received ${WrappedComponent}` + ) + + const wrappedComponentName = WrappedComponent.displayName + || WrappedComponent.name + || 'Component' + + const displayName = getDisplayName(wrappedComponentName) + + const selectorFactoryOptions = { + ...connectOptions, + getDisplayName, + methodName, + renderCountProp, + shouldHandleStateChanges, + storeKey, + withRef, + displayName, + WrappedComponent + } + + class Connect extends Component { + constructor(props, context) { + super(props, context) + + this.version = version + this.state = {} + this.renderCount = 0 + this.store = this.props[storeKey] || this.context[storeKey] + this.parentSub = this.props[subscriptionKey] || this.context[subscriptionKey] + this.setWrappedInstance = this.setWrappedInstance.bind(this) + + invariant(this.store, + `Could not find "${storeKey}" in either the context or ` + + `props of "${displayName}". ` + + `Either wrap the root component in a , ` + + `or explicitly pass "${storeKey}" as a prop to "${displayName}".` + ) + + this.initSelector() + this.initSubscription() + } + + getChildContext() { + return { [subscriptionKey]: this.subscription } + } + + componentDidMount() { + if (!shouldHandleStateChanges) return + + // componentWillMount fires during server side rendering, but componentDidMount and + // componentWillUnmount do not. Because of this, trySubscribe happens during ...didMount. + // Otherwise, unsubscription would never take place during SSR, causing a memory leak. + // To handle the case where a child component may have triggered a state change by + // dispatching an action in its componentWillMount, we have to re-run the select and maybe + // re-render. + this.subscription.trySubscribe() + this.selector.run(this.props) + if (this.selector.shouldComponentUpdate) this.forceUpdate() + } + + componentWillReceiveProps(nextProps) { + this.selector.run(nextProps) + } + + shouldComponentUpdate() { + return this.selector.shouldComponentUpdate + } + + componentWillUnmount() { + if (this.subscription) this.subscription.tryUnsubscribe() + // these are just to guard against extra memory leakage if a parent element doesn't + // dereference this instance properly, such as an async callback that never finishes + this.subscription = null + this.store = null + this.parentSub = null + this.selector.run = () => {} + } + + getWrappedInstance() { + invariant(withRef, + `To access the wrapped instance, you need to specify ` + + `{ withRef: true } in the options argument of the ${methodName}() call.` + ) + return this.wrappedInstance + } + + setWrappedInstance(ref) { + this.wrappedInstance = ref + } + + initSelector() { + const { dispatch, getState } = this.store + const sourceSelector = selectorFactory(dispatch, selectorFactoryOptions) + + // wrap the selector in an object that tracks its results between runs + const selector = this.selector = { + shouldComponentUpdate: true, + props: sourceSelector(getState(), this.props), + run: function runComponentSelector(props) { + try { + const nextProps = sourceSelector(getState(), props) + if (selector.error || nextProps !== selector.props) { + selector.shouldComponentUpdate = true + selector.props = nextProps + selector.error = null + } + } catch (error) { + selector.shouldComponentUpdate = true + selector.error = error + } + } + } + } + + initSubscription() { + if (shouldHandleStateChanges) { + const subscription = this.subscription = new Subscription(this.store, this.parentSub) + const notifyNestedSubs = subscription.notifyNestedSubs.bind(subscription) + const dummyState = {} + + subscription.onStateChange = function onStateChange() { + this.selector.run(this.props) + + if (!this.selector.shouldComponentUpdate) { + subscription.notifyNestedSubs() + } else { + this.setState(dummyState, notifyNestedSubs) + } + }.bind(this) + } + } + + isSubscribed() { + return Boolean(this.subscription) && this.subscription.isSubscribed() + } + + addExtraProps(props) { + if (!withRef && !renderCountProp) return props + // make a shallow copy so that fields added don't leak to the original selector. + // this is especially important for 'ref' since that's a reference back to the component + // instance. a singleton memoized selector would then be holding a reference to the + // instance, preventing the instance from being garbage collected, and that would be bad + const withExtras = { ...props } + if (withRef) withExtras.ref = this.setWrappedInstance + if (renderCountProp) withExtras[renderCountProp] = this.renderCount++ + return withExtras + } + + render() { + const selector = this.selector + selector.shouldComponentUpdate = false + + if (selector.error) { + throw selector.error + } else { + return createElement(WrappedComponent, this.addExtraProps(selector.props)) + } + } + } + + Connect.WrappedComponent = WrappedComponent + Connect.displayName = displayName + Connect.childContextTypes = childContextTypes + Connect.contextTypes = contextTypes + Connect.propTypes = contextTypes + + if (process.env.NODE_ENV !== 'production') { + Connect.prototype.componentWillUpdate = function componentWillUpdate() { + // We are hot reloading! + if (this.version !== version) { + this.version = version + this.initSelector() + + if (this.subscription) this.subscription.tryUnsubscribe() + this.initSubscription() + if (shouldHandleStateChanges) this.subscription.trySubscribe() + } + } + } + + return hoistStatics(Connect, WrappedComponent) + } +} diff --git a/src/connect/connect.js b/src/connect/connect.js new file mode 100644 index 000000000..9c4611983 --- /dev/null +++ b/src/connect/connect.js @@ -0,0 +1,83 @@ +import connectAdvanced from '../components/connectAdvanced' +import defaultMapDispatchToPropsFactories from './mapDispatchToProps' +import defaultMapStateToPropsFactories from './mapStateToProps' +import defaultMergePropsFactories from './mergeProps' +import defaultSelectorFactory from './selectorFactory' + +/* + connect is a facade over connectAdvanced. It turns its args into a compatible + selectorFactory, which has the signature: + + (dispatch, options) => (nextState, nextOwnProps) => nextFinalProps + + connect passes its args to connectAdvanced as options, which will in turn pass them to + selectorFactory each time a Connect component instance is instantiated or hot reloaded. + + selectorFactory returns a final props selector from its mapStateToProps, + mapStateToPropsFactories, mapDispatchToProps, mapDispatchToPropsFactories, mergeProps, + mergePropsFactories, and pure args. + + The resulting final props selector is called by the Connect component instance whenever + it receives new props or store state. + */ + +function match(arg, factories) { + for (let i = factories.length - 1; i >= 0; i--) { + const result = factories[i](arg) + if (result) return result + } + return undefined +} + +export function buildConnectOptions( + mapStateToProps, + mapDispatchToProps, + mergeProps, + { + mapStateToPropsFactories = defaultMapStateToPropsFactories, + mapDispatchToPropsFactories = defaultMapDispatchToPropsFactories, + mergePropsFactories = defaultMergePropsFactories, + selectorFactory = defaultSelectorFactory, + pure = true, + __ENABLE_SECRET_EXPERIMENTAL_FEATURES_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = false, + ...options + } = {} +) { + if (!__ENABLE_SECRET_EXPERIMENTAL_FEATURES_DO_NOT_USE_OR_YOU_WILL_BE_FIRED) { + mapStateToPropsFactories = defaultMapStateToPropsFactories + mapDispatchToPropsFactories = defaultMapDispatchToPropsFactories + mergePropsFactories = defaultMergePropsFactories + selectorFactory = defaultSelectorFactory + options = { withRef: options.withRef } + } + + const initMapStateToProps = match(mapStateToProps, mapStateToPropsFactories) + const initMapDispatchToProps = match(mapDispatchToProps, mapDispatchToPropsFactories) + const initMergeProps = match(mergeProps, mergePropsFactories) + + return { + // used in error messages + methodName: 'connect', + + // used to compute Connect's displayName from the wrapped component's displayName. + getDisplayName: name => `Connect(${name})`, + + // if mapStateToProps is falsy, the Connect component doesn't subscribe to store state changes + shouldHandleStateChanges: Boolean(mapStateToProps), + + // passed through to selectorFactory + selectorFactory, + initMapStateToProps, + initMapDispatchToProps, + initMergeProps, + pure, + + // any addional options args can override defaults of connect or connectAdvanced + ...options + } +} + +export default function connect(...args) { + const options = buildConnectOptions(...args) + return connectAdvanced(options.selectorFactory, options) +} diff --git a/src/connect/mapDispatchToProps.js b/src/connect/mapDispatchToProps.js new file mode 100644 index 000000000..405004538 --- /dev/null +++ b/src/connect/mapDispatchToProps.js @@ -0,0 +1,26 @@ +import { bindActionCreators } from 'redux' +import { wrapMapToPropsConstant, wrapMapToPropsFunc } from './wrapMapToProps' + +export function whenMapDispatchToPropsIsFunction(mapDispatchToProps) { + return (typeof mapDispatchToProps === 'function') + ? wrapMapToPropsFunc(mapDispatchToProps, 'mapDispatchToProps') + : undefined +} + +export function whenMapDispatchToPropsIsMissing(mapDispatchToProps) { + return (!mapDispatchToProps) + ? wrapMapToPropsConstant(dispatch => ({ dispatch })) + : undefined +} + +export function whenMapDispatchToPropsIsObject(mapDispatchToProps) { + return (mapDispatchToProps && typeof mapDispatchToProps === 'object') + ? wrapMapToPropsConstant(dispatch => bindActionCreators(mapDispatchToProps, dispatch)) + : undefined +} + +export default [ + whenMapDispatchToPropsIsFunction, + whenMapDispatchToPropsIsMissing, + whenMapDispatchToPropsIsObject +] diff --git a/src/connect/mapStateToProps.js b/src/connect/mapStateToProps.js new file mode 100644 index 000000000..039291b0a --- /dev/null +++ b/src/connect/mapStateToProps.js @@ -0,0 +1,18 @@ +import { wrapMapToPropsConstant, wrapMapToPropsFunc } from './wrapMapToProps' + +export function whenMapStateToPropsIsFunction(mapStateToProps) { + return (typeof mapStateToProps === 'function') + ? wrapMapToPropsFunc(mapStateToProps, 'mapStateToProps') + : undefined +} + +export function whenMapStateToPropsIsMissing(mapStateToProps) { + return (!mapStateToProps) + ? wrapMapToPropsConstant(() => ({})) + : undefined +} + +export default [ + whenMapStateToPropsIsFunction, + whenMapStateToPropsIsMissing +] diff --git a/src/connect/mergeProps.js b/src/connect/mergeProps.js new file mode 100644 index 000000000..f2a42c42b --- /dev/null +++ b/src/connect/mergeProps.js @@ -0,0 +1,50 @@ +import shallowEqual from '../utils/shallowEqual' +import verifyPlainObject from '../utils/verifyPlainObject' + +export function defaultMergeProps(stateProps, dispatchProps, ownProps) { + return { ...ownProps, ...stateProps, ...dispatchProps } +} + +export function wrapMergePropsFunc(mergeProps) { + return function initMergePropsProxy( + dispatch, { displayName, pure, areMergedPropsEqual = shallowEqual } + ) { + let hasRunOnce = false + let mergedProps + + return function mergePropsProxy(stateProps, dispatchProps, ownProps) { + const nextMergedProps = mergeProps(stateProps, dispatchProps, ownProps) + + if (hasRunOnce) { + if (!pure || !areMergedPropsEqual(nextMergedProps, mergedProps)) + mergedProps = nextMergedProps + + } else { + hasRunOnce = true + mergedProps = nextMergedProps + + if (process.env.NODE_ENV !== 'production') + verifyPlainObject(mergedProps, displayName, 'mergeProps') + } + + return mergedProps + } + } +} + +export function whenMergePropsIsFunction(mergeProps) { + return (typeof mergeProps === 'function') + ? wrapMergePropsFunc(mergeProps) + : undefined +} + +export function whenMergePropsIsOmitted(mergeProps) { + return (!mergeProps) + ? () => defaultMergeProps + : undefined +} + +export default [ + whenMergePropsIsFunction, + whenMergePropsIsOmitted +] diff --git a/src/connect/selectorFactory.js b/src/connect/selectorFactory.js new file mode 100644 index 000000000..47593aa9b --- /dev/null +++ b/src/connect/selectorFactory.js @@ -0,0 +1,132 @@ +import verifySubselectors from './verifySubselectors' +import shallowEqual from '../utils/shallowEqual' + +export function impureFinalPropsSelectorFactory( + mapStateToProps, + mapDispatchToProps, + mergeProps, + dispatch +) { + return function impureFinalPropsSelector(state, ownProps) { + return mergeProps( + mapStateToProps(state, ownProps), + mapDispatchToProps(dispatch, ownProps), + ownProps + ) + } +} + +function strictEqual(a, b) { return a === b } + +export function pureFinalPropsSelectorFactory( + mapStateToProps, + mapDispatchToProps, + mergeProps, + dispatch, + { + areStatesEqual = strictEqual, + areOwnPropsEqual = shallowEqual, + areStatePropsEqual = shallowEqual + } +) { + let hasRunAtLeastOnce = false + let state + let ownProps + let stateProps + let dispatchProps + let mergedProps + + function handleFirstCall(firstState, firstOwnProps) { + state = firstState + ownProps = firstOwnProps + stateProps = mapStateToProps(state, ownProps) + dispatchProps = mapDispatchToProps(dispatch, ownProps) + mergedProps = mergeProps(stateProps, dispatchProps, ownProps) + hasRunAtLeastOnce = true + return mergedProps + } + + function handleNewPropsAndNewState() { + stateProps = mapStateToProps(state, ownProps) + + if (mapDispatchToProps.dependsOnOwnProps) + dispatchProps = mapDispatchToProps(dispatch, ownProps) + + mergedProps = mergeProps(stateProps, dispatchProps, ownProps) + return mergedProps + } + + function handleNewProps() { + if (mapStateToProps.dependsOnOwnProps) + stateProps = mapStateToProps(state, ownProps) + + if (mapDispatchToProps.dependsOnOwnProps) + dispatchProps = mapDispatchToProps(dispatch, ownProps) + + mergedProps = mergeProps(stateProps, dispatchProps, ownProps) + return mergedProps + } + + function handleNewState() { + const nextStateProps = mapStateToProps(state, ownProps) + const statePropsChanged = !areStatePropsEqual(nextStateProps, stateProps) + stateProps = nextStateProps + + if (statePropsChanged) + mergedProps = mergeProps(stateProps, dispatchProps, ownProps) + + return mergedProps + } + + function handleSubsequentCalls(nextState, nextOwnProps) { + const propsChanged = !areOwnPropsEqual(nextOwnProps, ownProps) + const stateChanged = !areStatesEqual(nextState, state) + state = nextState + ownProps = nextOwnProps + + if (propsChanged && stateChanged) return handleNewPropsAndNewState() + if (propsChanged) return handleNewProps() + if (stateChanged) return handleNewState() + return mergedProps + } + + return function pureFinalPropsSelector(nextState, nextOwnProps) { + return hasRunAtLeastOnce + ? handleSubsequentCalls(nextState, nextOwnProps) + : handleFirstCall(nextState, nextOwnProps) + } +} + +// TODO: Add more comments + +// If pure is true, the selector returned by selectorFactory will memoize its results, +// allowing connectAdvanced's shouldComponentUpdate to return false if final +// props have not changed. If false, the selector will always return a new +// object and shouldComponentUpdate will always return true. + +export default function finalPropsSelectorFactory(dispatch, { + initMapStateToProps, + initMapDispatchToProps, + initMergeProps, + ...options +}) { + const mapStateToProps = initMapStateToProps(dispatch, options) + const mapDispatchToProps = initMapDispatchToProps(dispatch, options) + const mergeProps = initMergeProps(dispatch, options) + + if (process.env.NODE_ENV !== 'production') { + verifySubselectors(mapStateToProps, mapDispatchToProps, mergeProps, options.displayName) + } + + const selectorFactory = options.pure + ? pureFinalPropsSelectorFactory + : impureFinalPropsSelectorFactory + + return selectorFactory( + mapStateToProps, + mapDispatchToProps, + mergeProps, + dispatch, + options + ) +} diff --git a/src/connect/verifySubselectors.js b/src/connect/verifySubselectors.js new file mode 100644 index 000000000..7c6b248b8 --- /dev/null +++ b/src/connect/verifySubselectors.js @@ -0,0 +1,20 @@ +import warning from '../utils/warning' + +function verify(selector, methodName, displayName) { + if (!selector) { + throw new Error(`Unexpected value for ${methodName} in ${displayName}.`) + + } else if (methodName === 'mapStateToProps' || methodName === 'mapDispatchToProps') { + if (!selector.hasOwnProperty('dependsOnOwnProps')) { + warning( + `The selector for ${methodName} of ${displayName} did not specify a value for dependsOnOwnProps.` + ) + } + } +} + +export default function verifySubselectors(mapStateToProps, mapDispatchToProps, mergeProps, displayName) { + verify(mapStateToProps, 'mapStateToProps', displayName) + verify(mapDispatchToProps, 'mapDispatchToProps', displayName) + verify(mergeProps, 'mergeProps', displayName) +} diff --git a/src/connect/wrapMapToProps.js b/src/connect/wrapMapToProps.js new file mode 100644 index 000000000..93eaa80b3 --- /dev/null +++ b/src/connect/wrapMapToProps.js @@ -0,0 +1,66 @@ +import verifyPlainObject from '../utils/verifyPlainObject' + +export function wrapMapToPropsConstant(getConstant) { + return function initConstantSelector(dispatch, options) { + const constant = getConstant(dispatch, options) + + function constantSelector() { return constant } + constantSelector.dependsOnOwnProps = false + return constantSelector + } +} + +// dependsOnOwnProps is used by createMapToPropsProxy to determine whether to pass props as args +// to the mapToProps function being wrapped. It is also used by makePurePropsSelector to determine +// whether mapToProps needs to be invoked when props have changed. +// +// A length of one signals that mapToProps does not depend on props from the parent component. +// A length of zero is assumed to mean mapToProps is getting args via arguments or ...args and +// therefore not reporting its length accurately.. +export function getDependsOnOwnProps(mapToProps) { + return (mapToProps.dependsOnOwnProps !== null && mapToProps.dependsOnOwnProps !== undefined) + ? Boolean(mapToProps.dependsOnOwnProps) + : mapToProps.length !== 1 +} + +// Used by whenMapStateToPropsIsFunction and whenMapDispatchToPropsIsFunction, +// this function wraps mapToProps in a proxy function which does several things: +// +// * Detects whether the mapToProps function being called depends on props, which +// is used by selectorFactory to decide if it should reinvoke on props changes. +// +// * On first call, handles mapToProps if returns another function, and treats that +// new function as the true mapToProps for subsequent calls. +// +// * On first call, verifies the first result is a plain object, in order to warn +// the developer that their mapToProps function is not returning a valid result. +// +export function wrapMapToPropsFunc(mapToProps, methodName) { + return function initProxySelector(dispatch, { displayName }) { + const proxy = function mapToPropsProxy(stateOrDispatch, ownProps) { + return proxy.dependsOnOwnProps + ? proxy.mapToProps(stateOrDispatch, ownProps) + : proxy.mapToProps(stateOrDispatch) + } + + proxy.dependsOnOwnProps = getDependsOnOwnProps(mapToProps) + + proxy.mapToProps = function detectFactoryAndVerify(stateOrDispatch, ownProps) { + proxy.mapToProps = mapToProps + let props = proxy(stateOrDispatch, ownProps) + + if (typeof props === 'function') { + proxy.mapToProps = props + proxy.dependsOnOwnProps = getDependsOnOwnProps(props) + props = proxy(stateOrDispatch, ownProps) + } + + if (process.env.NODE_ENV !== 'production') + verifyPlainObject(props, displayName, methodName) + + return props + } + + return proxy + } +} diff --git a/src/index.js b/src/index.js index ad89eec2d..2384a4428 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,4 @@ import Provider from './components/Provider' -import connect from './components/connect' +import connect from './connect/connect' export { Provider, connect } diff --git a/src/utils/Subscription.js b/src/utils/Subscription.js new file mode 100644 index 000000000..b26615ed2 --- /dev/null +++ b/src/utils/Subscription.js @@ -0,0 +1,61 @@ +// encapsulates the subscription logic for connecting a component to the redux store, as +// well as nesting subscriptions of descendant components, so that we can ensure the +// ancestor components re-render before descendants +export default class Subscription { + constructor(store, parentSub) { + this.subscribe = parentSub + ? parentSub.addNestedSub.bind(parentSub) + : store.subscribe + + this.unsubscribe = null + this.nextListeners = this.currentListeners = [] + } + + ensureCanMutateNextListeners() { + if (this.nextListeners === this.currentListeners) { + this.nextListeners = this.currentListeners.slice() + } + } + + addNestedSub(listener) { + this.trySubscribe() + + let isSubscribed = true + this.ensureCanMutateNextListeners() + this.nextListeners.push(listener) + + return function unsubscribe() { + if (!isSubscribed) return + isSubscribed = false + + this.ensureCanMutateNextListeners() + const index = this.nextListeners.indexOf(listener) + this.nextListeners.splice(index, 1) + } + } + + notifyNestedSubs() { + const listeners = this.currentListeners = this.nextListeners + const length = listeners.length + for (let i = 0; i < length; i++) { + listeners[i]() + } + } + + isSubscribed() { + return Boolean(this.unsubscribe) + } + + trySubscribe() { + if (!this.unsubscribe) { + this.unsubscribe = this.subscribe(this.onStateChange) + } + } + + tryUnsubscribe() { + if (this.unsubscribe) { + this.unsubscribe() + } + this.unsubscribe = null + } +} diff --git a/src/utils/shallowEqual.js b/src/utils/shallowEqual.js index 76df37841..9980e83f5 100644 --- a/src/utils/shallowEqual.js +++ b/src/utils/shallowEqual.js @@ -1,23 +1,19 @@ -export default function shallowEqual(objA, objB) { - if (objA === objB) { - return true - } +const hasOwn = Object.prototype.hasOwnProperty - const keysA = Object.keys(objA) - const keysB = Object.keys(objB) +export default function shallowEqual(a, b) { + if (a === b) return true - if (keysA.length !== keysB.length) { - return false + let countA = 0 + let countB = 0 + + for (let key in a) { + if (hasOwn.call(a, key) && a[key] !== b[key]) return false + countA++ } - // Test for A's keys different from B. - const hasOwn = Object.prototype.hasOwnProperty - for (let i = 0; i < keysA.length; i++) { - if (!hasOwn.call(objB, keysA[i]) || - objA[keysA[i]] !== objB[keysA[i]]) { - return false - } + for (let key in b) { + if (hasOwn.call(b, key)) countB++ } - return true + return countA === countB } diff --git a/src/utils/verifyPlainObject.js b/src/utils/verifyPlainObject.js new file mode 100644 index 000000000..a56e1c6de --- /dev/null +++ b/src/utils/verifyPlainObject.js @@ -0,0 +1,10 @@ +import isPlainObject from 'lodash/isPlainObject' +import warning from './warning' + +export default function verifyPlainObject(value, displayName, methodName) { + if (!isPlainObject(value)) { + warning( + `${methodName}() in ${displayName} must return a plain object. Instead received ${value}.` + ) + } +} diff --git a/test/components/Provider.spec.js b/test/components/Provider.spec.js index affbf81e7..4d4a17df1 100644 --- a/test/components/Provider.spec.js +++ b/test/components/Provider.spec.js @@ -2,7 +2,7 @@ import expect from 'expect' import React, { PropTypes, Component } from 'react' import TestUtils from 'react-addons-test-utils' import { createStore } from 'redux' -import { Provider } from '../../src/index' +import { Provider, connect } from '../../src/index' describe('React', () => { describe('Provider', () => { @@ -108,4 +108,67 @@ describe('React', () => { expect(spy.calls.length).toBe(0) }) }) + + it('should pass state consistently to mapState', () => { + function stringBuilder(prev = '', action) { + return action.type === 'APPEND' + ? prev + action.body + : prev + } + + const store = createStore(stringBuilder) + + store.dispatch({ type: 'APPEND', body: 'a' }) + let childMapStateInvokes = 0 + + @connect(state => ({ state }), null, null, { withRef: true }) + class Container extends Component { + emitChange() { + store.dispatch({ type: 'APPEND', body: 'b' }) + } + + render() { + return ( +
+ + +
+ ) + } + } + + @connect((state, parentProps) => { + childMapStateInvokes++ + // The state from parent props should always be consistent with the current state + expect(state).toEqual(parentProps.parentState) + return {} + }) + class ChildContainer extends Component { + render() { + return
+ } + } + + const tree = TestUtils.renderIntoDocument( + + + + ) + + expect(childMapStateInvokes).toBe(1) + + // The store state stays consistent when setState calls are batched + store.dispatch({ type: 'APPEND', body: 'c' }) + expect(childMapStateInvokes).toBe(2) + + // setState calls DOM handlers are batched + const container = TestUtils.findRenderedComponentWithType(tree, Container) + const node = container.getWrappedInstance().refs.button + TestUtils.Simulate.click(node) + expect(childMapStateInvokes).toBe(3) + + // Provider uses unstable_batchedUpdates() under the hood + store.dispatch({ type: 'APPEND', body: 'd' }) + expect(childMapStateInvokes).toBe(4) + }) }) diff --git a/test/components/connect.spec.js b/test/components/connect.spec.js index 514eea018..3742afd79 100644 --- a/test/components/connect.spec.js +++ b/test/components/connect.spec.js @@ -1017,6 +1017,12 @@ describe('React', () => { expect(stub.props.passVal).toBe('otherval') }) + it('should throw an error if a component is not passed to the function returned by connect', () => { + expect(connect()).toThrow( + /You must pass a component to the function/ + ) + }) + it('should throw an error if mapState, mapDispatch, or mergeProps returns anything but a plain object', () => { const store = createStore(() => ({})) @@ -1334,7 +1340,7 @@ describe('React', () => { const decorated = TestUtils.findRenderedComponentWithType(tree, Decorated) expect(() => decorated.getWrappedInstance()).toThrow( - /To access the wrapped instance, you need to specify \{ withRef: true \} as the fourth argument of the connect\(\) call\./ + /To access the wrapped instance, you need to specify \{ withRef: true \} in the options argument of the connect\(\) call\./ ) }) @@ -1368,7 +1374,7 @@ describe('React', () => { expect(() => decorated.someInstanceMethod()).toThrow() expect(decorated.getWrappedInstance().someInstanceMethod()).toBe(someData) - expect(decorated.refs.wrappedInstance.someInstanceMethod()).toBe(someData) + expect(decorated.wrappedInstance.someInstanceMethod()).toBe(someData) }) it('should wrap impure components without supressing updates', () => { @@ -1538,14 +1544,8 @@ describe('React', () => { TestUtils.Simulate.click(node) expect(childMapStateInvokes).toBe(3) - // In future all setState calls will be batched[1]. Uncomment when it - // happens. For now redux-batched-updates middleware can be used as - // workaround this. - // - // [1]: https://twitter.com/sebmarkbage/status/642366976824864768 - // - // store.dispatch({ type: 'APPEND', body: 'd' }) - // expect(childMapStateInvokes).toBe(4) + store.dispatch({ type: 'APPEND', body: 'd' }) + expect(childMapStateInvokes).toBe(4) }) it('should not render the wrapped component when mapState does not produce change', () => { @@ -1868,5 +1868,41 @@ describe('React', () => { ReactDOM.unmountComponentAtNode(div) }) + + it('should allow custom displayName', () => { + // TODO remove __ENABLE_SECRET_EXPERIMENTAL_FEATURES_DO_NOT_USE_OR_YOU_WILL_BE_FIRED once approved + @connect(null, null, null, { getDisplayName: name => `Custom(${name})`, __ENABLE_SECRET_EXPERIMENTAL_FEATURES_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: true }) + class MyComponent extends React.Component { + render() { + return
+ } + } + + expect(MyComponent.displayName).toEqual('Custom(MyComponent)') + }) + + it('should update impure components whenever the state of the store changes', () => { + const store = createStore(() => ({})) + let renderCount = 0 + + @connect(() => ({}), null, null, { pure: false }) + class ImpureComponent extends React.Component { + render() { + ++renderCount + return
+ } + } + + TestUtils.renderIntoDocument( + + + + ) + + const rendersBeforeStateChange = renderCount + store.dispatch({ type: 'ACTION' }) + expect(renderCount).toBe(rendersBeforeStateChange + 1) + }) }) + })