diff --git a/docs/api.md b/docs/api.md index cdd40b4c4..590d4c8cd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -52,7 +52,9 @@ It does not modify the component class passed to it; instead, it *returns* a new #### Arguments -* [`mapStateToProps(state, [ownProps]): stateProps`] \(*Function*): If this argument is specified, the new component will subscribe to Redux store updates. This means that any time the store is updated, `mapStateToProps` will be called. The results of `mapStateToProps` must be a plain object*, which will be merged into the component’s props. If you don't want to subscribe to store updates, pass `null` or `undefined` in place of `mapStateToProps`. If `ownProps` is specified as a second argument, its value will be the props passed to your component, and `mapStateToProps` will be additionally re-invoked whenever the component receives new props (e.g. if props received from a parent component have shallowly changed, and you use the ownProps argument, mapStateToProps is re-evaluated). +* [`mapStateToProps(state, [ownProps]): stateProps`] \(*Function* or *Object*): If this argument is specified, the new component will subscribe to Redux store updates. This means that any time the store is updated, `mapStateToProps` will be called. The results of `mapStateToProps` must be a plain object*, which will be merged into the component’s props. If you don't want to subscribe to store updates, pass `null` or `undefined` in place of `mapStateToProps`. If `ownProps` is specified as a second argument, its value will be the props passed to your component, and `mapStateToProps` will be additionally re-invoked whenever the component receives new props (e.g. if props received from a parent component have shallowly changed, and you use the ownProps argument, mapStateToProps is re-evaluated). + +If an object is passed, each function inside it is assumed to be a Redux selector. >Note: in advanced scenarios where you need more control over the rendering performance, `mapStateToProps()` can also return a function. In this case, *that* function will be used as `mapStateToProps()` for a particular component instance. This allows you to do per-instance memoization. You can refer to [#279](https://github.com/reactjs/react-redux/pull/279) and the tests it adds for more details. Most apps never need this. diff --git a/src/connect/mapStateToProps.js b/src/connect/mapStateToProps.js index 039291b0a..6ae957f88 100644 --- a/src/connect/mapStateToProps.js +++ b/src/connect/mapStateToProps.js @@ -6,6 +6,31 @@ export function whenMapStateToPropsIsFunction(mapStateToProps) { : undefined } +function getSelectorFromObject(objectSelectors) { + return function (state) { + const result = {}; + Object.keys(objectSelectors).forEach(function (key) { + result[key] = objectSelectors[key](state); + }); + return result; + }; +} + +function isValidmapStateToPropsObj(mapStateToProps) { + return typeof mapStateToProps === 'object' && Object + .keys(mapStateToProps) + .map(function (key) { return mapStateToProps[key] }) + .every(function (val) { return typeof val === 'function'; }); +} + +export function whenMapStateToPropsIsObject(mapStateToProps) { + return (isValidmapStateToPropsObj(mapStateToProps)) ? + wrapMapToPropsFunc( + getSelectorFromObject(mapStateToProps), 'mapStateToProps' + ) : + undefined +} + export function whenMapStateToPropsIsMissing(mapStateToProps) { return (!mapStateToProps) ? wrapMapToPropsConstant(() => ({})) @@ -14,5 +39,6 @@ export function whenMapStateToPropsIsMissing(mapStateToProps) { export default [ whenMapStateToPropsIsFunction, + whenMapStateToPropsIsObject, whenMapStateToPropsIsMissing ] diff --git a/test/components/connect.spec.js b/test/components/connect.spec.js index 094f4d697..605ef69d6 100644 --- a/test/components/connect.spec.js +++ b/test/components/connect.spec.js @@ -7,6 +7,9 @@ import TestUtils from 'react-addons-test-utils' import { createStore } from 'redux' import { connect } from '../../src/index' +const withMapStateToProps = (...args) => test => + args.forEach((mapStateToProps) => { test(mapStateToProps) }); + describe('React', () => { describe('connect', () => { class Passthrough extends Component { @@ -78,6 +81,10 @@ describe('React', () => { expect(container.context.store).toBe(store) }) + withMapStateToProps( + ({ foo, baz }) => ({ foo, baz }), + ({ foo: state => state.foo, baz: state => state.baz }) + )(mapStateToProps => it('should pass state and props to the given component', () => { const store = createStore(() => ({ foo: 'bar', @@ -85,7 +92,7 @@ describe('React', () => { hello: 'world' })) - @connect(({ foo, baz }) => ({ foo, baz })) + @connect(mapStateToProps) class Container extends Component { render() { return @@ -105,12 +112,16 @@ describe('React', () => { expect(() => TestUtils.findRenderedComponentWithType(container, Container) ).toNotThrow() - }) + })) + withMapStateToProps( + state => ({ string: state }), + { string: state => state } + )(mapStateToProps => it('should subscribe class components to the store changes', () => { const store = createStore(stringBuilder) - @connect(state => ({ string: state }) ) + @connect(mapStateToProps) class Container extends Component { render() { return @@ -129,13 +140,17 @@ describe('React', () => { expect(stub.props.string).toBe('a') store.dispatch({ type: 'APPEND', body: 'b' }) expect(stub.props.string).toBe('ab') - }) + })) + withMapStateToProps( + state => ({ string: state }), + { string: state => state } + )(mapStateToProps => it('should subscribe pure function components to the store changes', () => { const store = createStore(stringBuilder) let Container = connect( - state => ({ string: state }) + mapStateToProps )(function Container(props) { return }) @@ -155,13 +170,17 @@ describe('React', () => { expect(stub.props.string).toBe('a') store.dispatch({ type: 'APPEND', body: 'b' }) expect(stub.props.string).toBe('ab') - }) + })) + withMapStateToProps( + state => ({ string: state }), + { string: state => state } + )(mapStateToProps => it('should retain the store\'s context', () => { const store = new ContextBoundStore(stringBuilder) let Container = connect( - state => ({ string: state }) + mapStateToProps )(function Container(props) { return }) @@ -179,12 +198,16 @@ describe('React', () => { expect(stub.props.string).toBe('') store.dispatch({ type: 'APPEND', body: 'a' }) expect(stub.props.string).toBe('a') - }) + })) + withMapStateToProps( + state => ({ string: state }), + { string: state => state } + )(mapStateToProps => it('should handle dispatches before componentDidMount', () => { const store = createStore(stringBuilder) - @connect(state => ({ string: state }) ) + @connect(mapStateToProps) class Container extends Component { componentWillMount() { store.dispatch({ type: 'APPEND', body: 'a' }) @@ -203,14 +226,18 @@ describe('React', () => { const stub = TestUtils.findRenderedComponentWithType(tree, Passthrough) expect(stub.props.string).toBe('a') - }) + })) + withMapStateToProps( + state => state, + { foo: state => state.foo } + )(mapStateToProps => it('should handle additional prop changes in addition to slice', () => { const store = createStore(() => ({ foo: 'bar' })) - @connect(state => state) + @connect(mapStateToProps) class ConnectContainer extends Component { render() { return ( @@ -248,12 +275,16 @@ describe('React', () => { const stub = TestUtils.findRenderedComponentWithType(container, Passthrough) expect(stub.props.foo).toEqual('bar') expect(stub.props.pass).toEqual('through') - }) + })) + withMapStateToProps( + state => state, + {} + )(mapStateToProps => it('should handle unexpected prop changes with forceUpdate()', () => { const store = createStore(() => ({})) - @connect(state => state) + @connect(mapStateToProps) class ConnectContainer extends Component { render() { return ( @@ -286,14 +317,18 @@ describe('React', () => { const container = TestUtils.renderIntoDocument() const stub = TestUtils.findRenderedComponentWithType(container, Passthrough) expect(stub.props.bar).toEqual('foo') - }) + })) + withMapStateToProps( + () => ({}), + {} + )(mapStateToProps => it('should remove undefined props', () => { const store = createStore(() => ({})) let props = { x: true } let container - @connect(() => ({}), () => ({})) + @connect(mapStateToProps, () => ({})) class ConnectContainer extends Component { render() { return ( @@ -329,14 +364,18 @@ describe('React', () => { expect(propsBefore.x).toEqual(true) expect('x' in propsAfter).toEqual(false, 'x prop must be removed') - }) + })) + withMapStateToProps( + () => ({}), + {} + )(mapStateToProps => it('should remove undefined props without mapDispatch', () => { const store = createStore(() => ({})) let props = { x: true } let container - @connect(() => ({})) + @connect(mapStateToProps) class ConnectContainer extends Component { render() { return ( @@ -372,14 +411,18 @@ describe('React', () => { expect(propsBefore.x).toEqual(true) expect('x' in propsAfter).toEqual(false, 'x prop must be removed') - }) + })) + withMapStateToProps( + state => state, + { foo: state => state.foo } + )(mapStateToProps => it('should ignore deep mutations in props', () => { const store = createStore(() => ({ foo: 'bar' })) - @connect(state => state) + @connect(mapStateToProps) class ConnectContainer extends Component { render() { return ( @@ -420,8 +463,12 @@ describe('React', () => { const stub = TestUtils.findRenderedComponentWithType(container, Passthrough) expect(stub.props.foo).toEqual('bar') expect(stub.props.pass).toEqual('') - }) + })) + withMapStateToProps( + state => ({ stateThing: state }), + { stateThing: state => state } + )(mapStateToProps => it('should allow for merge to incorporate state and prop changes', () => { const store = createStore(stringBuilder) @@ -433,7 +480,7 @@ describe('React', () => { } @connect( - state => ({ stateThing: state }), + mapStateToProps, dispatch => ({ doSomething: (whatever) => dispatch(doSomething(whatever)) }), @@ -477,15 +524,19 @@ describe('React', () => { tree.setState({ extra: 'Z' }) stub.props.mergedDoSomething('c') expect(stub.props.stateThing).toBe('HELLO azbzcZ') - }) + })) + withMapStateToProps( + state => state, + { foo: state => state.foo } + )(mapStateToProps => it('should merge actionProps into WrappedComponent', () => { const store = createStore(() => ({ foo: 'bar' })) @connect( - state => state, + mapStateToProps, dispatch => ({ dispatch }) ) class Container extends Component { @@ -507,7 +558,7 @@ describe('React', () => { ).toNotThrow() const decorated = TestUtils.findRenderedComponentWithType(container, Container) expect(decorated.isSubscribed()).toBe(true) - }) + })) it('should not invoke mapState when props change if it only has one argument', () => { const store = createStore(stringBuilder) @@ -839,6 +890,10 @@ describe('React', () => { runCheck(false, false, false) }) + withMapStateToProps( + state => ({ string: state }), + { string: state => state } + )(mapStateToProps => it('should unsubscribe before unmounting', () => { const store = createStore(stringBuilder) const subscribe = store.subscribe @@ -854,7 +909,7 @@ describe('React', () => { } @connect( - state => ({ string: state }), + mapStateToProps, dispatch => ({ dispatch }) ) class Container extends Component { @@ -874,7 +929,7 @@ describe('React', () => { expect(spy.calls.length).toBe(0) ReactDOM.unmountComponentAtNode(div) expect(spy.calls.length).toBe(1) - }) + })) it('should not attempt to set state after unmounting', () => { const store = createStore(stringBuilder) @@ -909,10 +964,14 @@ describe('React', () => { expect(mapStateToPropsCalls).toBe(1) }) + withMapStateToProps( + (state) => ({ hide: state === 'AB' }), + { hide: state => state === 'AB' } + )(mapStateToProps => it('should not attempt to notify unmounted child of state change', () => { const store = createStore(stringBuilder) - @connect((state) => ({ hide: state === 'AB' })) + @connect(mapStateToProps) class App extends Component { render() { return this.props.hide ? null : @@ -953,7 +1012,7 @@ describe('React', () => { } finally { ReactDOM.unmountComponentAtNode(div) } - }) + })) it('should not attempt to set state after unmounting nested components', () => { const store = createStore(() => ({})) @@ -1070,6 +1129,10 @@ describe('React', () => { expect(mapStateToPropsCalls).toBe(1) }) + withMapStateToProps( + state => ({ string: state }), + { string: state => state } + )(mapStateToProps => it('should shallowly compare the selected state to prevent unnecessary updates', () => { const store = createStore(stringBuilder) const spy = expect.createSpy(() => ({})) @@ -1079,7 +1142,7 @@ describe('React', () => { } @connect( - state => ({ string: state }), + mapStateToProps, dispatch => ({ dispatch }) ) class Container extends Component { @@ -1103,8 +1166,12 @@ describe('React', () => { expect(spy.calls.length).toBe(3) store.dispatch({ type: 'APPEND', body: '' }) expect(spy.calls.length).toBe(3) - }) + })) + withMapStateToProps( + state => ({ string: state }), + { string: state => state } + )(mapStateToProps => it('should shallowly compare the merged state to prevent unnecessary updates', () => { const store = createStore(stringBuilder) const spy = expect.createSpy(() => ({})) @@ -1114,7 +1181,7 @@ describe('React', () => { } @connect( - state => ({ string: state }), + mapStateToProps, dispatch => ({ dispatch }), (stateProps, dispatchProps, parentProps) => ({ ...dispatchProps, @@ -1191,7 +1258,7 @@ describe('React', () => { expect(spy.calls.length).toBe(5) expect(stub.props.string).toBe('a') 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(