diff --git a/src/components/connect.js b/src/components/connect.js index 5b31632d1..2f29aa8c1 100644 --- a/src/components/connect.js +++ b/src/components/connect.js @@ -1,238 +1,4 @@ -import React, { Component } from 'react' -import storeShape from '../utils/storeShape' import shallowEqual from '../utils/shallowEqual' -import isPlainObject from '../utils/isPlainObject' -import wrapActionCreators from '../utils/wrapActionCreators' -import hoistStatics from 'hoist-non-react-statics' -import invariant from 'invariant' +import createConnect from './createConnect' -const defaultMapStateToProps = () => ({}) -const defaultMapDispatchToProps = dispatch => ({ dispatch }) -const defaultMergeProps = (stateProps, dispatchProps, parentProps) => ({ - ...parentProps, - ...stateProps, - ...dispatchProps -}) - -function getDisplayName(WrappedComponent) { - return WrappedComponent.displayName || WrappedComponent.name || 'Component' -} - -// Helps track hot reloading. -let nextVersion = 0 - -export default function connect(mapStateToProps, mapDispatchToProps, mergeProps, options = {}) { - const shouldSubscribe = Boolean(mapStateToProps) - const finalMapStateToProps = mapStateToProps || defaultMapStateToProps - const finalMapDispatchToProps = isPlainObject(mapDispatchToProps) ? - wrapActionCreators(mapDispatchToProps) : - mapDispatchToProps || defaultMapDispatchToProps - const finalMergeProps = mergeProps || defaultMergeProps - const shouldUpdateStateProps = finalMapStateToProps.length > 1 - const shouldUpdateDispatchProps = finalMapDispatchToProps.length > 1 - const { pure = true, withRef = false } = options - - // Helps track hot reloading. - const version = nextVersion++ - - function computeStateProps(store, props) { - const state = store.getState() - const stateProps = shouldUpdateStateProps ? - finalMapStateToProps(state, props) : - finalMapStateToProps(state) - - invariant( - isPlainObject(stateProps), - '`mapStateToProps` must return an object. Instead received %s.', - stateProps - ) - return stateProps - } - - function computeDispatchProps(store, props) { - const { dispatch } = store - const dispatchProps = shouldUpdateDispatchProps ? - finalMapDispatchToProps(dispatch, props) : - finalMapDispatchToProps(dispatch) - - invariant( - isPlainObject(dispatchProps), - '`mapDispatchToProps` must return an object. Instead received %s.', - dispatchProps - ) - return dispatchProps - } - - function computeNextState(stateProps, dispatchProps, parentProps) { - const mergedProps = finalMergeProps(stateProps, dispatchProps, parentProps) - invariant( - isPlainObject(mergedProps), - '`mergeProps` must return an object. Instead received %s.', - mergedProps - ) - return mergedProps - } - - return function wrapWithConnect(WrappedComponent) { - class Connect extends Component { - shouldComponentUpdate(nextProps, nextState) { - if (!pure) { - this.updateStateProps(nextProps) - this.updateDispatchProps(nextProps) - this.updateState(nextProps) - return true - } - - const storeChanged = nextState.storeState !== this.state.storeState - const propsChanged = !shallowEqual(nextProps, this.props) - let mapStateProducedChange = false - let dispatchPropsChanged = false - - if (storeChanged || (propsChanged && shouldUpdateStateProps)) { - mapStateProducedChange = this.updateStateProps(nextProps) - } - - if (propsChanged && shouldUpdateDispatchProps) { - dispatchPropsChanged = this.updateDispatchProps(nextProps) - } - - if (propsChanged || mapStateProducedChange || dispatchPropsChanged) { - this.updateState(nextProps) - return true - } - - return false - } - - 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 "${this.constructor.displayName}". ` + - `Either wrap the root component in a , ` + - `or explicitly pass "store" as a prop to "${this.constructor.displayName}".` - ) - - this.stateProps = computeStateProps(this.store, props) - this.dispatchProps = computeDispatchProps(this.store, props) - this.state = { storeState: null } - this.updateState() - } - - computeNextState(props = this.props) { - return computeNextState( - this.stateProps, - this.dispatchProps, - props - ) - } - - updateStateProps(props = this.props) { - const nextStateProps = computeStateProps(this.store, props) - if (shallowEqual(nextStateProps, this.stateProps)) { - return false - } - - this.stateProps = nextStateProps - return true - } - - updateDispatchProps(props = this.props) { - const nextDispatchProps = computeDispatchProps(this.store, props) - if (shallowEqual(nextDispatchProps, this.dispatchProps)) { - return false - } - - this.dispatchProps = nextDispatchProps - return true - } - - updateState(props = this.props) { - this.nextState = this.computeNextState(props) - } - - isSubscribed() { - return typeof this.unsubscribe === 'function' - } - - trySubscribe() { - if (shouldSubscribe && !this.unsubscribe) { - this.unsubscribe = this.store.subscribe(::this.handleChange) - this.handleChange() - } - } - - tryUnsubscribe() { - if (this.unsubscribe) { - this.unsubscribe() - this.unsubscribe = null - } - } - - componentDidMount() { - this.trySubscribe() - } - - componentWillUnmount() { - this.tryUnsubscribe() - } - - handleChange() { - if (!this.unsubscribe) { - return - } - - this.setState({ - storeState: this.store.getState() - }) - } - - 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 ref = withRef ? 'wrappedInstance' : null - return ( - - ) - } - } - - Connect.displayName = `Connect(${getDisplayName(WrappedComponent)})` - 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 - - // Update the state and bindings. - this.trySubscribe() - this.updateStateProps() - this.updateDispatchProps() - this.updateState() - } - } - - return hoistStatics(Connect, WrappedComponent) - } -} +export default createConnect(shallowEqual) diff --git a/src/components/createConnect.js b/src/components/createConnect.js new file mode 100644 index 000000000..5fd32b29f --- /dev/null +++ b/src/components/createConnect.js @@ -0,0 +1,244 @@ +import React, { Component } from 'react' +import storeShape from '../utils/storeShape' + +import isPlainObject from '../utils/isPlainObject' +import wrapActionCreators from '../utils/wrapActionCreators' +import hoistStatics from 'hoist-non-react-statics' +import invariant from 'invariant' + +const defaultMapStateToProps = () => ({}) +const defaultMapDispatchToProps = dispatch => ({ dispatch }) +const defaultMergeProps = (stateProps, dispatchProps, parentProps) => ({ + ...parentProps, + ...stateProps, + ...dispatchProps +}) + +function getDisplayName(WrappedComponent) { + return WrappedComponent.displayName || WrappedComponent.name || 'Component' +} + +// Helps track hot reloading. +let nextVersion = 0 + +export default function createConnector(shallowEqual) { + + function connect(mapStateToProps, mapDispatchToProps, mergeProps, options = {}) { + const shouldSubscribe = Boolean(mapStateToProps) + const finalMapStateToProps = mapStateToProps || defaultMapStateToProps + const finalMapDispatchToProps = isPlainObject(mapDispatchToProps) ? + wrapActionCreators(mapDispatchToProps) : + mapDispatchToProps || defaultMapDispatchToProps + const finalMergeProps = mergeProps || defaultMergeProps + const shouldUpdateStateProps = finalMapStateToProps.length > 1 + const shouldUpdateDispatchProps = finalMapDispatchToProps.length > 1 + const { pure = true, withRef = false } = options + + // Helps track hot reloading. + const version = nextVersion++ + + function computeStateProps(store, props) { + const state = store.getState() + const stateProps = shouldUpdateStateProps ? + finalMapStateToProps(state, props) : + finalMapStateToProps(state) + + invariant( + isPlainObject(stateProps), + '`mapStateToProps` must return an object. Instead received %s.', + stateProps + ) + return stateProps + } + + function computeDispatchProps(store, props) { + const { dispatch } = store + const dispatchProps = shouldUpdateDispatchProps ? + finalMapDispatchToProps(dispatch, props) : + finalMapDispatchToProps(dispatch) + + invariant( + isPlainObject(dispatchProps), + '`mapDispatchToProps` must return an object. Instead received %s.', + dispatchProps + ) + return dispatchProps + } + + function computeNextState(stateProps, dispatchProps, parentProps) { + const mergedProps = finalMergeProps(stateProps, dispatchProps, parentProps) + invariant( + isPlainObject(mergedProps), + '`mergeProps` must return an object. Instead received %s.', + mergedProps + ) + return mergedProps + } + + return function wrapWithConnect(WrappedComponent) { + class Connect extends Component { + shouldComponentUpdate(nextProps, nextState) { + if (!pure) { + this.updateStateProps(nextProps) + this.updateDispatchProps(nextProps) + this.updateState(nextProps) + return true + } + + const storeChanged = nextState.storeState !== this.state.storeState + const propsChanged = !shallowEqual(nextProps, this.props) + let mapStateProducedChange = false + let dispatchPropsChanged = false + + if (storeChanged || (propsChanged && shouldUpdateStateProps)) { + mapStateProducedChange = this.updateStateProps(nextProps) + } + + if (propsChanged && shouldUpdateDispatchProps) { + dispatchPropsChanged = this.updateDispatchProps(nextProps) + } + + if (propsChanged || mapStateProducedChange || dispatchPropsChanged) { + this.updateState(nextProps) + return true + } + + return false + } + + 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 "${this.constructor.displayName}". ` + + `Either wrap the root component in a , ` + + `or explicitly pass "store" as a prop to "${this.constructor.displayName}".` + ) + + this.stateProps = computeStateProps(this.store, props) + this.dispatchProps = computeDispatchProps(this.store, props) + this.state = { storeState: null } + this.updateState() + } + + computeNextState(props = this.props) { + return computeNextState( + this.stateProps, + this.dispatchProps, + props + ) + } + + updateStateProps(props = this.props) { + const nextStateProps = computeStateProps(this.store, props) + if (shallowEqual(nextStateProps, this.stateProps)) { + return false + } + + this.stateProps = nextStateProps + return true + } + + updateDispatchProps(props = this.props) { + const nextDispatchProps = computeDispatchProps(this.store, props) + if (shallowEqual(nextDispatchProps, this.dispatchProps)) { + return false + } + + this.dispatchProps = nextDispatchProps + return true + } + + updateState(props = this.props) { + this.nextState = this.computeNextState(props) + } + + isSubscribed() { + return typeof this.unsubscribe === 'function' + } + + trySubscribe() { + if (shouldSubscribe && !this.unsubscribe) { + this.unsubscribe = this.store.subscribe(::this.handleChange) + this.handleChange() + } + } + + tryUnsubscribe() { + if (this.unsubscribe) { + this.unsubscribe() + this.unsubscribe = null + } + } + + componentDidMount() { + this.trySubscribe() + } + + componentWillUnmount() { + this.tryUnsubscribe() + } + + handleChange() { + if (!this.unsubscribe) { + return + } + + this.setState({ + storeState: this.store.getState() + }) + } + + 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 ref = withRef ? 'wrappedInstance' : null + return ( + + ) + } + } + + Connect.displayName = `Connect(${getDisplayName(WrappedComponent)})` + 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 + + // Update the state and bindings. + this.trySubscribe() + this.updateStateProps() + this.updateDispatchProps() + this.updateState() + } + } + + return hoistStatics(Connect, WrappedComponent) + } + } + + return connect + +} diff --git a/src/index.js b/src/index.js index 046685da4..9d11ce671 100644 --- a/src/index.js +++ b/src/index.js @@ -1,2 +1,3 @@ export { default as Provider } from './components/Provider' export { default as connect } from './components/connect' +export { default as createConnect } from './components/createConnect' diff --git a/test/components/createConnect.spec.js b/test/components/createConnect.spec.js new file mode 100644 index 000000000..45508dbf5 --- /dev/null +++ b/test/components/createConnect.spec.js @@ -0,0 +1,94 @@ +import expect from 'expect' +import React, { Children, PropTypes, Component } from 'react' +import TestUtils from 'react-addons-test-utils' +import { createStore } from 'redux' +import { createConnect } from '../../src/index' + +describe('React', () => { + describe('connect', () => { + class Passthrough extends Component { + render() { + return
+ } + } + + class ProviderMock extends Component { + static childContextTypes = { + store: PropTypes.object.isRequired + } + + getChildContext() { + return { store: this.props.store } + } + + render() { + return Children.only(this.props.children) + } + } + + function stringBuilder(prev = '', action) { + return action.type === 'APPEND' + ? prev + action.body + : prev + } + + it('is possible to create a custom connect with a configurable equals', () => { + + function makeDeeperShallowEqual(maxDepth) { + return function deeperShallowEqual(objA, objB, depth = 0) { + if (objA === objB) { + return true + } + if (depth > maxDepth) { + return objA === objB + } + const keysA = Object.keys(objA) + const keysB = Object.keys(objB) + if (keysA.length !== keysB.length) { + return false + } + // 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]) || !deeperShallowEqual(objA[keysA[i]], objB[keysA[i]], depth + 1)) { + return false + } + } + return true + } + } + + const connect = createConnect(makeDeeperShallowEqual(1)) + const store = createStore(stringBuilder) + let renderCalls = 0 + let mapStateCalls = 0 + + @connect((state, props) => { + mapStateCalls++ + return { a: [ 1, 2, 3, 4 ], name: props.name } // no change with new comparison! + }) + class Container extends Component { + render() { + renderCalls++ + return + } + } + + TestUtils.renderIntoDocument( + + + + ) + + expect(renderCalls).toBe(1) + expect(mapStateCalls).toBe(2) + + store.dispatch({ type: 'APPEND', body: 'a' }) + + // After store a change mapState has been called + expect(mapStateCalls).toBe(3) + // But render is not because it did not make any actual changes + expect(renderCalls).toBe(1) + }) + }) +})