diff --git a/README.md b/README.md index d1060f4f1..d5c7723f9 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,30 @@ -react-redux +React Redux ========================= -Higher-order React components for [Redux](https://github.com/gaearon/redux). +Official React bindings for [Redux](https://github.com/gaearon/redux). +Performant and flexible. + +[![npm version](https://img.shields.io/npm/v/react-redux.svg?style=flat-square)](https://www.npmjs.com/package/react-redux) +[![npm downloads](https://img.shields.io/npm/dm/react-redux.svg?style=flat-square)](https://www.npmjs.com/package/react-redux) +[![redux channel on slack](https://img.shields.io/badge/slack-redux@reactiflux-61DAFB.svg?style=flat-square)](http://www.reactiflux.com) -What you get from `react-redux` is for React. -For React Native, import from `react-redux/native` instead. ->**Note: There is a project called “redux-react” on NPM that is completely unrelated to the official bindings. This documentation (and any other official Redux documentation) is for `react-redux`.** +>**Note: There is a project called `redux-react` on NPM that is [completely unrelated](https://github.com/cgarvis/redux-react/issues/1) to the official bindings. This documentation (and any other official Redux documentation) is for `react-redux`.** ## Table of Contents +- [React Native](#react-native) - [Quick Start](#quick-start) - [API](#api) - - [`connect`](#connect) - - [`Provider`](#provider) + - [``](#provider-store) + - [`connect([mapState], [mapDispatch], [mergeProps])(Component)`](#connectmapstate-mapdispatch-mergeprops-component) - [License](#license) +## React Native + +What you get from `react-redux` is for React. +For React Native, import from `react-redux/native` instead. + ## Quick Start React bindings for Redux embrace the idea of [dividing “smart” and “dumb” components](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0). @@ -74,103 +83,62 @@ export default class Counter extends Component { Here’s how we hook it up to the Redux Store. -We will use `connect()` function provided by `react-redux` to turn a “dumb” `Counter` into a smart component. With the current API, we’ll need to add an intermediate `CounterContainer` component, but we will soon make `connect` API more powerful so this won’t be required. The `connect()` function lets you specify *which exactly* state from the Redux store your component wants to track. This lets you subscribe on any level of granularity. +We will use `connect()` function provided by `react-redux` to turn a “dumb” `Counter` into a smart component. The `connect()` function lets you specify *which exactly* state from the Redux store your component wants to track. This lets you subscribe on any level of granularity. + +Passing action creator functions as the second parameter will bind them to the specific store instance, and they will be injected as props with the same names they were exported with. + +Why don’t we bind action creators to a store right away? This is because of the so-called “universal” apps that need to render on the server. They would have a different store instance for every request, so we don’t know the store instance during the definition! -Our `CounterContainer` that’s necessary to hook `Counter` up to a Redux store looks like this: -(This will be much less verbose in the next versions.) +##### `containers/CounterContainer.js` ```js import { Component } from 'react'; import { connect } from 'react-redux'; -// Assuming this is our “dumb” counter +// Action creators: +import { increment } from '../actionsCreators'; +// “Dumb” component: import Counter from '../components/Counter'; -// Assuming these are Redux action creators -import { increment } from './actionCreators'; - -function select(state) { - // Which part of the Redux global state does our component want to receive as props? +// Which part of the Redux global state does our component want to receive as props? +function mapState(state) { return { counter: state.counter }; } -class CounterContainer extends Component { - render() { - // connect() call below will inject `dispatch` and - // every key returned by `select` as props into our container: - const { dispatch, counter } = this.props; - - // render our “dumb” component, hooking up state to data props - // and using “dispatch action produced by this action creator” as callbacks. - // this is a “bridge” between a Redux-aware world above and Redux-unaware world below. - - return ( - dispatch(increment())} /> - ); - } -} - -// Don't forget to actually use connect! -export default connect(select)(CounterContainer); +// First argument tells which state fields it’s interested in. +// Second argument tells which action creators to bind and inject. +// You may also pass a `dispatch` => Object function as a second argument. -// You might have noticed that we used parens twice. -// This is called partial applications, and it lets people -// use ES7 decorator proposal syntax: -// -// @connect(select) -// export default class CounterContainer { ... } -// -// Don’t forget decorators are experimental! And they -// desugar to function calls anyway as example above demonstrates. +export default connect(mapState, { increment })(CounterContainer); ``` -As you can see, action creators in Redux just return actions, but we need to manually “bind” them to the `dispatch` function for our Redux store. Why don’t we bind action creators to a store right away? This is because of the so-called “universal” apps that need to render on the server. They would have a different store instance for every request, so we don’t know the store instance during the definition! +Whether to put `connect()` call in the same file as the “dumb” component, or separately, is up to you. +Ask yourself whether you'd want to reuse this component but bind it to different data, or not. -### Binding many action creators +### Nesting -Binding can get cumbersome, so Redux provides a `bindActionCreators` helper to turn many action creator methods into an object with methods called the same, but bound to a particular `dispatch` function: +You can have many `connect()`-ed components in your app at any depth, and you can even nest them. It is however preferable that you try to only `connect()` top-level components such as route handlers, so the data flow in your application stays predictable. -```js +### Support for Decorators -import { Component } from 'react'; -import { connect } from 'react-redux'; +You might have noticed that we used parens twice. This is called partial applications, and it lets people +use ES7 decorator proposal syntax: -// A helper provided by Redux! -import { bindActionCreators } from 'redux'; -// Import many action creators as a single object (like `require('./actionCreators')` in CommonJS) -import * as CounterActionCreators from './actionCreators'; -import Counter from '../components/Counter'; - -function select(state) { - return { - counter: state.counter - }; -} - -class CounterContainer extends Component { - render() { - const { dispatch, counter } = this.props; - - // This time, we use `bindActionCreators` to bind many action creators - // to a particular dispatch function from our Redux store. +```js +// Unstable syntax! It might change or break in production. +@connect(mapState) +export default class CounterContainer { ... } +``` - return ( - - ); - } -} +Don’t forget decorators are experimental! And they desugar to function calls anyway as example above demonstrates. -// Don't forget to actually use connect! -export default connect(select)(CounterContainer); -``` +### Additional Flexibility -You can have many `connect()`-ed components in your app at any depth, and you can even nest them. It is however preferable that you try to only `connect()` top-level components such as route handlers, so the data flow in your application stays predictable. +This the most basic usage, but `connect()` supports many other different patterns: just passing the vanilla `dispatch()` function down, binding multiple action creators, putting them as `actions` prop, selecting parts of state and binding action creators depending on `props`, and so on. Check out `connect()` docs below to learn more. -### Injecting Redux store +### Injecting Redux Store Finally, how do we actually hook it up to a Redux store? We need to create the store somewhere at the root of our component hierarchy. For client apps, the root component is a good place. For server rendering, you can do this in the request handler. @@ -193,60 +161,225 @@ React.render(( {() => } ), targetEl); +``` + +## API + +### `` + +Makes Redux store available to the `connect()` calls in the component hierarchy below. +You can’t use `connect()` without wrapping the root component in ``. -// or, if you use React Router 0.13, +#### Props -// Router.run(routes, Router.HistoryLocation, (Handler) => { -// React.render( -// -// {() => } -// , -// targetEl -// ); -// }); +* `store`: (*[Redux Store](http://gaearon.github.io/redux/docs/api/Store.html)*): The single Redux store in your application. +* `children`: (*Function*): Unlike most React components, `` accepts a [function as a child](#child-must-be-a-function) with your root component. This is a temporary workaround for a React 0.13 context issue, which will be fixed when React 0.14 comes out. -// or, if you use React Router 1.0, -// React.render( -// -// {() => ...} -// , -// targetEl -// ); +#### Example + +##### Vanilla React + +```js +React.render( + + {() => } + , + rootEl +); ``` -## API +##### React Router 0.13 + +```js +Router.run(routes, Router.HistoryLocation, (Handler) => { + React.render( + + {() => } + , + targetEl + ); +}); +``` + +##### React Router 1.0 + +```js +React.render( + + {() => ...} + , + targetEl +); +``` + +### `connect([mapState], [mapDispatch], [mergeProps])(Component)` + +Connects a React component to a Redux store. + +#### Arguments + +* [`mapState`] \(*Function*): If specified, the component will subscribe to Redux store updates. Any time it updates, `mapState` will be called. Its result must be a plain object, and it will be merged into the component’s props. If you omit it, the component will not be subscribed to the Redux store. + +* [`mapDispatch`] \(*Object* or *Function*): If an object is passed, each function inside it will be assumed to be a Redux action creator, and an object with the same function names, but bound to a Redux store, will be merged into the component’s props. If a function is passed, it will be given `dispatch`. It’s up to you to return an object that somehow uses `dispatch` to bind action creators in your own way. (Tip: you may use [`bindActionCreators()`](http://gaearon.github.io/redux/docs/api/bindActionCreators.html) helper from Redux.) If you omit it, the default implementation just injects `dispatch` into your component’s props. + +* [`mergeProps`] \(*Function*): If specified, it is passed the result of `mapState()`, `mapDispatch()`, and the parent `props`. The plain object you return from it will be passed as props to the wrapped component. You may specify this function to select a slice of the state based on props, or to bind action creators to a particular variable from props. If you omit it, `{ ...props, ...mapStateResult, ...mapDispatchResult }` is used by default. + +#### Returns + +A React component class that injects state and action creators into your component according to the specified options. + +#### Remarks + +* The `mapState` function takes a single argument of the entire Redux store’s state and returns an object to be passed as props. It is often called a **selector**. Use [reselect](https://github.com/faassen/reselect) to efficiently compose selectors and [compute derived data](http://gaearon.github.io/redux/docs/recipes/ComputingDerivedData.html). + +* **To use `connect()`, the root component of your app must be wrapped into `{() => ... }` before being rendered.** + +#### Examples -### `connect` +##### Inject just `dispatch` and don't listen to store ```js -export default connect(select)(MyComponent); +export default connect()(TodoApp); ``` -Returns a component class that injects the Redux Store’s `dispatch` as a prop into `Component` so it can dispatch Redux actions. +##### Inject `dispatch` and every field in the global state (SLOW!) +```js +export default connect(state => state)(TodoApp); +``` + +##### Inject `dispatch` and `todos` + +```js +function mapState(state) { + return { todos: state.todos }; +} + +export default connect(mapState)(TodoApp); +``` + +##### Inject `todos` and all action creators (`addTodo`, `completeTodo`, ...) + +```js +import * as actionCreators from './actionCreators'; + +function mapState(state) { + return { todos: state.todos }; +} + +export default connect(mapState, actionCreators)(TodoApp); +``` + +##### Inject `todos` and all action creators (`addTodo`, `completeTodo`, ...) as `actions` + +```js +import * as actionCreators from './actionCreators'; +import { bindActionCreators } from 'redux'; + +function mapState(state) { + return { todos: state.todos }; +} + +function mapDispatch(dispatch) { + return { actions: bindActionCreators(actionCreators, dispatch) }; +} + +export default connect(mapState, actionCreators)(TodoApp); +``` + +##### Inject `todos` and a specific action creator (`addTodo`) + +```js +import { addTodo } from './actionCreators'; +import { bindActionCreators } from 'redux'; + +function mapState(state) { + return { todos: state.todos }; +} + +function mapDispatch(dispatch) { + return { addTodo: bindActionCreators(addTodo, dispatch) }; +} + +export default connect(mapState, mapDispatch)(TodoApp); +``` -The returned component also subscribes to the updates of Redux store. Any time the state changes, it calls the `select` function passed to it. The selector function takes a single argument of the entire Redux store’s state and returns an object to be passed as props. Use [reselect](https://github.com/faassen/reselect) to efficiently compose selectors and memoize derived data. +##### Inject `todos`, todoActionCreators as `todoActions`, and counterActionCreators as `counterActions` -Both `dispatch` and every property returned by `select` will be provided to your `Component` as `props`. +```js +import * as todoActionCreators from './todoActionCreators'; +import * as counterActionCreators from './counterActionCreators'; +import { bindActionCreators } from 'redux'; -It is the responsibility of a Smart Component to bind action creators to the given `dispatch` function and pass those -bound creators to Dumb Components. Redux provides a `bindActionCreators` to streamline the process of binding action -creators to the dispatch function. +function mapState(state) { + return { todos: state.todos }; +} -**To use `connect()`, the root component of your app must be wrapped into `{() => ... }` before being rendered.** +function mapDispatch(dispatch) { + return { + todoActions: bindActionCreators(todoActionCreators, dispatch), + counterActions: bindActionCreators(counterActionCreators, dispatch) + }; +} + +export default connect(mapState, mapDispatch)(TodoApp); +``` + +##### Inject `todos`, and todoActionCreators and counterActionCreators together as `actions` + +```js +import * as todoActionCreators from './todoActionCreators'; +import * as counterActionCreators from './counterActionCreators'; +import { bindActionCreators } from 'redux'; + +function mapState(state) { + return { todos: state.todos }; +} + +function mapDispatch(dispatch) { + return { + actions: bindActionCreators({ ...todoActionCreators, ...counterActionCreators }, dispatch) + }; +} -See the usage example in the quick start above. +export default connect(mapState, mapDispatch)(TodoApp); +``` -### `Provider` +##### Inject `todos`, and all todoActionCreators and counterActionCreators directly as props ```js - - {() => } - +import * as todoActionCreators from './todoActionCreators'; +import * as counterActionCreators from './counterActionCreators'; +import { bindActionCreators } from 'redux'; + +function mapState(state) { + return { todos: state.todos }; +} + +function mapDispatch(dispatch) { + return bindActionCreators(Object.assign({}, todoActionCreators, counterActionCreators), dispatch); +} + +export default connect(mapState, mapDispatch)(TodoApp); ``` -The `Provider` component takes a `store` prop and a [function as a child](#child-must-be-a-function) with your root -component. The `store` is then passed to the child via React's `context`. This is the entry point for Redux and must be -present in order to use the `connect` component. +##### Inject `todos` of a specific user depending on props, and inject `props.userId` into the action + +```js +import * as actionCreators from './actionCreators'; + +function mapState(state) { + return { todos: state.todos }; +} + +function mergeProps(selectedState, boundActions, props) { + return Object.assign({}, props, { + todos: selectedState.todos[props.userId], + addTodo: (text) => boundActions.addTodo(props.userId, text) + }); +} + +export default connect(mapState, actionCreators)(TodoApp); +``` ## License diff --git a/package.json b/package.json index b620d2699..03b8e37e4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "react-redux", - "version": "0.4.0", - "description": "Redux bindings for React", + "version": "0.5.0", + "description": "React bindings for Redux", "main": "./lib/index.js", "scripts": { "build:lib": "babel src --out-dir lib", @@ -60,6 +60,6 @@ "invariant": "^2.0.0" }, "peerDependencies": { - "redux": "^1.0.0 || 1.0.0-alpha || 1.0.0-rc" + "redux": "^1.0.0 || 1.0.0-rc" } } diff --git a/src/components/createAll.js b/src/components/createAll.js index 725f5a901..e83b7c9f6 100644 --- a/src/components/createAll.js +++ b/src/components/createAll.js @@ -1,12 +1,9 @@ import createProvider from './createProvider'; - -import createConnector from './createConnector'; -import createConnectDecorator from './createConnectDecorator'; +import createConnect from './createConnect'; export default function createAll(React) { const Provider = createProvider(React); - const connect = createConnectDecorator(React, createConnector(React)); + const connect = createConnect(React); - // provider and Connector are deprecated and removed from public API return { Provider, connect }; } diff --git a/src/components/createConnect.js b/src/components/createConnect.js new file mode 100644 index 000000000..26606cfa3 --- /dev/null +++ b/src/components/createConnect.js @@ -0,0 +1,144 @@ +import createStoreShape from '../utils/createStoreShape'; +import shallowEqualScalar from '../utils/shallowEqualScalar'; +import shallowEqual from '../utils/shallowEqual'; +import isPlainObject from '../utils/isPlainObject'; +import wrapActionCreators from '../utils/wrapActionCreators'; +import invariant from 'invariant'; + +const defaultMapState = () => ({}); +const defaultMapDispatch = dispatch => ({ dispatch }); +const defaultMergeProps = (stateSlice, actionsCreators, props) => ({ + ...props, + ...stateSlice, + ...actionsCreators +}); + +function getDisplayName(Component) { + return Component.displayName || Component.name || 'Component'; +} + +export default function createConnect(React) { + const { Component, PropTypes } = React; + const storeShape = createStoreShape(PropTypes); + + return function connect( + mapState = defaultMapState, + mapDispatchOrActionCreators = defaultMapDispatch, + mergeProps = defaultMergeProps + ) { + const shouldSubscribe = mapState !== defaultMapState; + const mapDispatch = isPlainObject(mapDispatchOrActionCreators) ? + wrapActionCreators(mapDispatchOrActionCreators) : + mapDispatchOrActionCreators; + + return DecoratedComponent => class ConnectDecorator extends Component { + static displayName = `Connect(${getDisplayName(DecoratedComponent)})`; + static DecoratedComponent = DecoratedComponent; + + static contextTypes = { + store: storeShape.isRequired + }; + + shouldComponentUpdate(nextProps, nextState) { + return (this.subscribed && !this.isSliceEqual(this.state.slice, nextState.slice)) || + !shallowEqualScalar(this.props, nextProps); + } + + isSliceEqual(slice, nextSlice) { + const isRefEqual = slice === nextSlice; + if ( + isRefEqual || + typeof slice !== 'object' || + typeof nextSlice !== 'object' + ) { + return isRefEqual; + } + + return shallowEqual(slice, nextSlice); + } + + constructor(props, context) { + super(props, context); + this.setUnderlyingRef = ::this.setUnderlyingRef; + this.state = { + ...this.mapState(props, context), + ...this.mapDispatch(context) + }; + } + + componentDidMount() { + if (shouldSubscribe) { + this.subscribed = true; + this.unsubscribe = this.context.store.subscribe(::this.handleChange); + } + } + + componentWillUnmount() { + if (shouldSubscribe) { + this.unsubscribe(); + } + } + + handleChange(props = this.props) { + const nextState = this.mapState(props, this.context); + if (!this.isSliceEqual(this.state.slice, nextState.slice)) { + this.setState(nextState); + } + } + + mapState(props = this.props, context = this.context) { + const state = context.store.getState(); + const slice = mapState(state); + + invariant( + isPlainObject(slice), + '`mapState` must return an object. Instead received %s.', + slice + ); + + return { slice }; + } + + mapDispatch(context = this.context) { + const { dispatch } = context.store; + const actionCreators = mapDispatch(dispatch); + + invariant( + isPlainObject(actionCreators), + '`mapDispatch` must return an object. Instead received %s.', + actionCreators + ); + + return { actionCreators }; + } + + merge(props = this.props, state = this.state) { + const { slice, actionCreators } = state; + const merged = mergeProps(slice, actionCreators, props); + + invariant( + isPlainObject(merged), + '`mergeProps` must return an object. Instead received %s.', + merged + ); + + return merged; + } + + getUnderlyingRef() { + return this.underlyingRef; + } + + setUnderlyingRef(instance) { + this.underlyingRef = instance; + } + + render() { + return ( + + ); + } + }; + }; +} diff --git a/src/components/createConnectDecorator.js b/src/components/createConnectDecorator.js deleted file mode 100644 index a3196e1c1..000000000 --- a/src/components/createConnectDecorator.js +++ /dev/null @@ -1,25 +0,0 @@ -import getDisplayName from '../utils/getDisplayName'; -import shallowEqualScalar from '../utils/shallowEqualScalar'; - -export default function createConnectDecorator(React, Connector) { - const { Component } = React; - - return function connect(select) { - return DecoratedComponent => class ConnectorDecorator extends Component { - static displayName = `Connector(${getDisplayName(DecoratedComponent)})`; - static DecoratedComponent = DecoratedComponent; - - shouldComponentUpdate(nextProps) { - return !shallowEqualScalar(this.props, nextProps); - } - - render() { - return ( - select(state, this.props)}> - {stuff => } - - ); - } - }; - }; -} diff --git a/src/components/createConnector.js b/src/components/createConnector.js deleted file mode 100644 index 3e4fb2dd4..000000000 --- a/src/components/createConnector.js +++ /dev/null @@ -1,89 +0,0 @@ -import createStoreShape from '../utils/createStoreShape'; -import shallowEqual from '../utils/shallowEqual'; -import isPlainObject from '../utils/isPlainObject'; -import invariant from 'invariant'; - -// Connector is deprecated and removed from public API -export default function createConnector(React) { - const { Component, PropTypes } = React; - const storeShape = createStoreShape(PropTypes); - - return class Connector extends Component { - static contextTypes = { - store: storeShape.isRequired - }; - - static propTypes = { - children: PropTypes.func.isRequired, - select: PropTypes.func.isRequired - }; - - static defaultProps = { - select: state => state - }; - - shouldComponentUpdate(nextProps, nextState) { - return !this.isSliceEqual(this.state.slice, nextState.slice) || - !shallowEqual(this.props, nextProps); - } - - isSliceEqual(slice, nextSlice) { - const isRefEqual = slice === nextSlice; - if (isRefEqual) { - return true; - } else if (typeof slice !== 'object' || typeof nextSlice !== 'object') { - return isRefEqual; - } - return shallowEqual(slice, nextSlice); - } - - constructor(props, context) { - super(props, context); - this.state = this.selectState(props, context); - } - - componentDidMount() { - this.unsubscribe = this.context.store.subscribe(::this.handleChange); - this.handleChange(); - } - - componentWillReceiveProps(nextProps) { - if (nextProps.select !== this.props.select) { - // Force the state slice recalculation - this.handleChange(nextProps); - } - } - - componentWillUnmount() { - this.unsubscribe(); - } - - handleChange(props = this.props) { - const nextState = this.selectState(props, this.context); - if (!this.isSliceEqual(this.state.slice, nextState.slice)) { - this.setState(nextState); - } - } - - selectState(props, context) { - const state = context.store.getState(); - const slice = props.select(state); - - invariant( - isPlainObject(slice), - 'The return value of `select` prop must be an object. Instead received %s.', - slice - ); - - return { slice }; - } - - render() { - const { children } = this.props; - const { slice } = this.state; - const { store: { dispatch } } = this.context; - - return children({ dispatch, ...slice }); - } - }; -} diff --git a/src/index.js b/src/index.js index b74f00c5e..f6a257dd4 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,4 @@ import React from 'react'; import createAll from './components/createAll'; -// provide and Connector are deprecated and removed from public API export const { Provider, connect } = createAll(React); diff --git a/src/native.js b/src/native.js index b21826040..24a1d67cd 100644 --- a/src/native.js +++ b/src/native.js @@ -1,5 +1,4 @@ import React from 'react-native'; import createAll from './components/createAll'; -// provide and Connector are deprecated and removed from public API export const { Provider, connect } = createAll(React); diff --git a/src/utils/getDisplayName.js b/src/utils/getDisplayName.js deleted file mode 100644 index 512702c87..000000000 --- a/src/utils/getDisplayName.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function getDisplayName(Component) { - return Component.displayName || Component.name || 'Component'; -} diff --git a/src/utils/isPlainObject.js b/src/utils/isPlainObject.js index a5845486c..da6b78cf2 100644 --- a/src/utils/isPlainObject.js +++ b/src/utils/isPlainObject.js @@ -1,3 +1,25 @@ +const fnToString = (fn) => Function.prototype.toString.call(fn); + +/** + * @param {any} obj The object to inspect. + * @returns {boolean} True if the argument appears to be a plain object. + */ export default function isPlainObject(obj) { - return obj ? typeof obj === 'object' && Object.getPrototypeOf(obj) === Object.prototype : false; + if (!obj || typeof obj !== 'object') { + return false; + } + + const proto = typeof obj.constructor === 'function' ? + Object.getPrototypeOf(obj) : + Object.prototype; + + if (proto === null) { + return true; + } + + const constructor = proto.constructor; + + return typeof constructor === 'function' + && constructor instanceof constructor + && fnToString(constructor) === fnToString(Object); } diff --git a/src/utils/wrapActionCreators.js b/src/utils/wrapActionCreators.js new file mode 100644 index 000000000..983fbe606 --- /dev/null +++ b/src/utils/wrapActionCreators.js @@ -0,0 +1,5 @@ +import { bindActionCreators } from 'redux'; + +export default function wrapActionCreators(actionCreators) { + return dispatch => bindActionCreators(actionCreators, dispatch); +} diff --git a/test/components/Connector.spec.js b/test/components/Connector.spec.js deleted file mode 100644 index 4f1141abb..000000000 --- a/test/components/Connector.spec.js +++ /dev/null @@ -1,296 +0,0 @@ -import expect from 'expect'; -import jsdomReact from './jsdomReact'; -import React, { PropTypes, Component } from 'react/addons'; -import { createStore } from 'redux'; -import createConnector from '../../src/components/createConnector'; - -const Connector = createConnector(React); -const { TestUtils } = React.addons; - -describe('React', () => { - describe('Connector', () => { - jsdomReact(); - - // Mock minimal Provider interface - class Provider extends Component { - static childContextTypes = { - store: PropTypes.object.isRequired - } - - getChildContext() { - return { store: this.props.store }; - } - - render() { - return this.props.children(); - } - } - - function stringBuilder(prev = '', action) { - return action.type === 'APPEND' - ? prev + action.body - : prev; - } - - it('should receive the store in the context', () => { - const store = createStore({}); - - const tree = TestUtils.renderIntoDocument( - - {() => ( - - {() =>
} - - )} - - ); - - const connector = TestUtils.findRenderedComponentWithType(tree, Connector); - expect(connector.context.store).toBe(store); - }); - - it('should subscribe to the store changes', () => { - const store = createStore(stringBuilder); - - const tree = TestUtils.renderIntoDocument( - - {() => ( - ({ string })}> - {({ string }) =>
} - - )} - - ); - - const div = TestUtils.findRenderedDOMComponentWithTag(tree, 'div'); - expect(div.props.string).toBe(''); - store.dispatch({ type: 'APPEND', body: 'a'}); - expect(div.props.string).toBe('a'); - store.dispatch({ type: 'APPEND', body: 'b'}); - expect(div.props.string).toBe('ab'); - }); - - it('should unsubscribe before unmounting', () => { - const store = createStore(stringBuilder); - const subscribe = store.subscribe; - - // Keep track of unsubscribe by wrapping subscribe() - const spy = expect.createSpy(() => {}); - store.subscribe = (listener) => { - const unsubscribe = subscribe(listener); - return () => { - spy(); - return unsubscribe(); - }; - }; - - const tree = TestUtils.renderIntoDocument( - - {() => ( - ({ string })}> - {({ string }) =>
} - - )} - - ); - - const connector = TestUtils.findRenderedComponentWithType(tree, Connector); - expect(spy.calls.length).toBe(0); - connector.componentWillUnmount(); - expect(spy.calls.length).toBe(1); - }); - - it('should shallowly compare the selected state to prevent unnecessary updates', () => { - const store = createStore(stringBuilder); - const spy = expect.createSpy(() => {}); - function render({ string }) { - spy(); - return
; - } - - const tree = TestUtils.renderIntoDocument( - - {() => ( - ({ string })}> - {render} - - )} - - ); - - const div = TestUtils.findRenderedDOMComponentWithTag(tree, 'div'); - expect(spy.calls.length).toBe(1); - expect(div.props.string).toBe(''); - store.dispatch({ type: 'APPEND', body: 'a'}); - expect(spy.calls.length).toBe(2); - store.dispatch({ type: 'APPEND', body: 'b'}); - expect(spy.calls.length).toBe(3); - store.dispatch({ type: 'APPEND', body: ''}); - expect(spy.calls.length).toBe(3); - }); - - it('should recompute the state slice when the select prop changes', () => { - const store = createStore({ - a: () => 42, - b: () => 72 - }); - - function selectA(state) { - return { result: state.a }; - } - - function selectB(state) { - return { result: state.b }; - } - - function render({ result }) { - return
{result}
; - } - - class Container extends Component { - constructor() { - super(); - this.state = { select: selectA }; - } - - render() { - return ( - - {() => - - {render} - - } - - ); - } - } - - let tree = TestUtils.renderIntoDocument(); - let div = TestUtils.findRenderedDOMComponentWithTag(tree, 'div'); - expect(div.props.children).toBe(42); - - tree.setState({ select: selectB }); - expect(div.props.children).toBe(72); - }); - - it('should pass dispatch() to the child function', () => { - const store = createStore({}); - - const tree = TestUtils.renderIntoDocument( - - {() => ( - - {({ dispatch }) =>
} - - )} - - ); - - const div = TestUtils.findRenderedDOMComponentWithTag(tree, 'div'); - expect(div.props.dispatch).toBe(store.dispatch); - }); - - it('should throw an error if select returns anything but a plain object', () => { - const store = createStore({}); - - expect(() => { - TestUtils.renderIntoDocument( - - {() => ( - 1}> - {() =>
} - - )} - - ); - }).toThrow(/select/); - - expect(() => { - TestUtils.renderIntoDocument( - - {() => ( - 'hey'}> - {() =>
} - - )} - - ); - }).toThrow(/select/); - - function AwesomeMap() { } - - expect(() => { - TestUtils.renderIntoDocument( - - {() => ( - new AwesomeMap()}> - {() =>
} - - )} - - ); - }).toThrow(/select/); - }); - - it('should not setState when renderToString is called on the server', () => { - const { renderToString } = React; - const store = createStore(stringBuilder); - - class TestComp extends Component { - componentWillMount() { - store.dispatch({ - type: 'APPEND', - body: 'a' - }); - } - - render() { - return
{this.props.string}
; - } - } - - const el = ( - - {() => ( - ({ string })}> - {({ string }) => } - - )} - - ); - - expect(() => renderToString(el)).toNotThrow(); - }); - - it('should handle dispatch inside componentDidMount', () => { - const store = createStore(stringBuilder); - - class TestComp extends Component { - componentDidMount() { - store.dispatch({ - type: 'APPEND', - body: 'a' - }); - } - - render() { - return
{this.props.string}
; - } - } - - const tree = TestUtils.renderIntoDocument( - - {() => ( - ({ string })}> - {({ string }) => } - - )} - - ); - - const testComp = TestUtils.findRenderedComponentWithType(tree, TestComp); - expect(testComp.props.string).toBe('a'); - }); - }); -}); diff --git a/test/components/Provider.spec.js b/test/components/Provider.spec.js index d0daf08f1..a85f1b2b7 100644 --- a/test/components/Provider.spec.js +++ b/test/components/Provider.spec.js @@ -21,7 +21,7 @@ describe('React', () => { } it('should add the store to the child context', () => { - const store = createStore({}); + const store = createStore(() => ({})); const tree = TestUtils.renderIntoDocument( @@ -36,7 +36,7 @@ describe('React', () => { it('should replace just the reducer when receiving a new store in props', () => { const store1 = createStore((state = 10) => state + 1); const store2 = createStore((state = 10) => state * 2); - const spy = expect.createSpy(() => {}); + const spy = expect.createSpy(() => ({})); class ProviderContainer extends Component { state = { store: store1 }; diff --git a/test/components/connect.spec.js b/test/components/connect.spec.js index 57e22ab1c..790e1a7bb 100644 --- a/test/components/connect.spec.js +++ b/test/components/connect.spec.js @@ -1,6 +1,6 @@ import expect from 'expect'; import jsdomReact from './jsdomReact'; -import React, { PropTypes, Component } from 'react/addons'; +import React, { createClass, PropTypes, Component } from 'react/addons'; import { createStore } from 'redux'; import { connect } from '../../src/index'; @@ -25,12 +25,42 @@ describe('React', () => { } } - it('should wrap the component into Provider', () => { + function stringBuilder(prev = '', action) { + return action.type === 'APPEND' + ? prev + action.body + : prev; + } + + it('should receive the store in the context', () => { + const store = createStore(() => ({})); + + @connect() + class Container extends Component { + render() { + return
; + } + } + + const tree = TestUtils.renderIntoDocument( + + {() => ( + + )} + + ); + + const container = TestUtils.findRenderedComponentWithType(tree, Container); + expect(container.context.store).toBe(store); + }); + + it('should pass state and props to the given component', () => { const store = createStore(() => ({ - foo: 'bar' + foo: 'bar', + baz: 42, + hello: 'world' })); - @connect(state => state) + @connect(({ foo, baz }) => ({ foo, baz })) class Container extends Component { render() { return
; @@ -39,17 +69,44 @@ describe('React', () => { const container = TestUtils.renderIntoDocument( - {() => } + {() => } ); const div = TestUtils.findRenderedDOMComponentWithTag(container, 'div'); expect(div.props.pass).toEqual('through'); expect(div.props.foo).toEqual('bar'); + expect(div.props.baz).toEqual(42); + expect(div.props.hello).toEqual(undefined); + expect(() => + TestUtils.findRenderedComponentWithType(container, Container) + ).toNotThrow(); + }); + + it('should subscribe to the store changes', () => { + const store = createStore(stringBuilder); - // Connector is deprecated and removed from public API - // expect(() => - // TestUtils.findRenderedComponentWithType(container, Connector) - // ).toNotThrow(); + @connect(state => ({ string: state }) ) + class Container extends Component { + render() { + return
; + } + } + + const tree = TestUtils.renderIntoDocument( + + {() => ( + + )} + + ); + + const div = TestUtils.findRenderedDOMComponentWithTag(tree, 'div'); + + expect(div.props.string).toBe(''); + store.dispatch({ type: 'APPEND', body: 'a'}); + expect(div.props.string).toBe('a'); + store.dispatch({ type: 'APPEND', body: 'b'}); + expect(div.props.string).toBe('ab'); }); it('should handle additional prop changes in addition to slice', () => { @@ -75,14 +132,15 @@ describe('React', () => { } }; } - componentDidMount() { + componentDidMount() { // Simulate deep object mutation this.state.bar.baz = 'through'; this.setState({ bar: this.state.bar }); } + render() { return ( @@ -98,17 +156,100 @@ describe('React', () => { expect(div.props.pass).toEqual('through'); }); - it('should pass the only argument as the select prop down', () => { + it('should allow for merge to incorporate state and prop changes', () => { + const store = createStore(stringBuilder); + + function doSomething(thing) { + return { + type: 'APPEND', + body: thing + }; + } + + @connect( + state => ({stateThing: state}), + dispatch => ({ + doSomething: (whatever) => dispatch(doSomething(whatever)) + }), + (stateProps, actionProps, parentProps) => ({ + ...stateProps, + ...actionProps, + mergedDoSomething(thing) { + const seed = stateProps.stateThing === '' ? 'HELLO ' : ''; + actionProps.doSomething(seed + thing + parentProps.extra); + } + }) + ) + class Container extends Component { + render() { + return
; + }; + } + + class OuterContainer extends Component { + constructor() { + super(); + this.state = { extra: 'z' }; + } + + render() { + return ( + + {() => } + + ); + } + } + + const tree = TestUtils.renderIntoDocument(); + const div = TestUtils.findRenderedDOMComponentWithTag(tree, 'div'); + + expect(div.props.stateThing).toBe(''); + div.props.mergedDoSomething('a'); + expect(div.props.stateThing).toBe('HELLO az'); + div.props.mergedDoSomething('b'); + expect(div.props.stateThing).toBe('HELLO azbz'); + tree.setState({extra: 'Z'}); + div.props.mergedDoSomething('c'); + expect(div.props.stateThing).toBe('HELLO azbzcZ'); + }); + + it('should merge actionProps into DecoratedComponent', () => { const store = createStore(() => ({ - foo: 'baz', - bar: 'baz' + foo: 'bar' })); - function select({ foo }) { - return { foo }; + @connect( + state => state, + dispatch => ({ dispatch }) + ) + class Container extends Component { + render() { + return
; + } } - @connect(select) + const container = TestUtils.renderIntoDocument( + + {() => } + + ); + const div = TestUtils.findRenderedDOMComponentWithTag(container, 'div'); + expect(div.props.dispatch).toEqual(store.dispatch); + expect(div.props.foo).toEqual('bar'); + expect(() => + TestUtils.findRenderedComponentWithType(container, Container) + ).toNotThrow(); + const decorated = TestUtils.findRenderedComponentWithType(container, Container); + expect(decorated.subscribed).toBe(true); + }); + + it('should not subscribe to stores if mapState argument is falsy', () => { + const store = createStore(() => ({ + foo: 'bar' + })); + + @connect() class Container extends Component { render() { return
; @@ -120,26 +261,205 @@ describe('React', () => { {() => } ); + const div = TestUtils.findRenderedDOMComponentWithTag(container, 'div'); + expect(div.props.dispatch).toEqual(store.dispatch); + expect(div.props.foo).toBe(undefined); + expect(() => + TestUtils.findRenderedComponentWithType(container, Container) + ).toNotThrow(); + const decorated = TestUtils.findRenderedComponentWithType(container, Container); + expect(decorated.subscribed).toNotBe(true); + }); + + it('should unsubscribe before unmounting', () => { + const store = createStore(stringBuilder); + const subscribe = store.subscribe; + + // Keep track of unsubscribe by wrapping subscribe() + const spy = expect.createSpy(() => ({})); + store.subscribe = (listener) => { + const unsubscribe = subscribe(listener); + return () => { + spy(); + return unsubscribe(); + }; + }; + + @connect( + state => ({string: state}), + dispatch => ({ dispatch }) + ) + class Container extends Component { + render() { + return
; + } + } + + const tree = TestUtils.renderIntoDocument( + + {() => ( + + )} + + ); - // Connector is deprecated and removed from public API - // const connector = TestUtils.findRenderedComponentWithType(container, Connector); - // expect(connector.props.select({ - // foo: 5, - // bar: 7 - // })).toEqual({ - // foo: 5 - // }); + const connector = TestUtils.findRenderedComponentWithType(tree, Container); + expect(spy.calls.length).toBe(0); + connector.componentWillUnmount(); + expect(spy.calls.length).toBe(1); }); - it('should set the displayName correctly', () => { - @connect(state => state) + it('should shallowly compare the selected state to prevent unnecessary updates', () => { + const store = createStore(stringBuilder); + const spy = expect.createSpy(() => ({})); + function render({ string }) { + spy(); + return
; + } + + @connect( + state => ({string: state}), + dispatch => ({ dispatch }) + ) class Container extends Component { render() { - return
; + return render(this.props); } } - expect(Container.displayName).toBe('Connector(Container)'); + const tree = TestUtils.renderIntoDocument( + + {() => ( + + )} + + ); + + const div = TestUtils.findRenderedDOMComponentWithTag(tree, 'div'); + expect(spy.calls.length).toBe(1); + expect(div.props.string).toBe(''); + store.dispatch({ type: 'APPEND', body: 'a'}); + expect(spy.calls.length).toBe(2); + store.dispatch({ type: 'APPEND', body: 'b'}); + expect(spy.calls.length).toBe(3); + store.dispatch({ type: 'APPEND', body: ''}); + expect(spy.calls.length).toBe(3); + }); + + it('should throw an error if mapState, mapDispatch, or mergeProps returns anything but a plain object', () => { + const store = createStore(() => ({})); + + function makeContainer(mapState, mapDispatch, mergeProps) { + return React.createElement( + @connect(mapState, mapDispatch, mergeProps) + class Container extends Component { + render() { + return
; + } + } + ); + } + + function AwesomeMap() { } + + expect(() => { + TestUtils.renderIntoDocument( + + { () => makeContainer(() => 1, () => ({}), () => ({})) } + + ); + }).toThrow(/mapState/); + + expect(() => { + TestUtils.renderIntoDocument( + + { () => makeContainer(() => 'hey', () => ({}), () => ({})) } + + ); + }).toThrow(/mapState/); + + expect(() => { + TestUtils.renderIntoDocument( + + { () => makeContainer(() => new AwesomeMap(), () => ({}), () => ({})) } + + ); + }).toThrow(/mapState/); + + expect(() => { + TestUtils.renderIntoDocument( + + { () => makeContainer(() => ({}), () => 1, () => ({})) } + + ); + }).toThrow(/mapDispatch/); + + expect(() => { + TestUtils.renderIntoDocument( + + { () => makeContainer(() => ({}), () => 'hey', () => ({})) } + + ); + }).toThrow(/mapDispatch/); + + expect(() => { + TestUtils.renderIntoDocument( + + { () => makeContainer(() => ({}), () => new AwesomeMap(), () => ({})) } + + ); + }).toThrow(/mapDispatch/); + + expect(() => { + TestUtils.renderIntoDocument( + + { () => makeContainer(() => ({}), () => ({}), () => 1) } + + ); + }).toThrow(/mergeProps/); + + expect(() => { + TestUtils.renderIntoDocument( + + { () => makeContainer(() => ({}), () => ({}), () => 'hey') } + + ); + }).toThrow(/mergeProps/); + + expect(() => { + TestUtils.renderIntoDocument( + + { () => makeContainer(() => ({}), () => ({}), () => new AwesomeMap()) } + + ); + }).toThrow(/mergeProps/); + }); + + it('should set the displayName correctly', () => { + expect(connect(state => state)( + class Foo extends Component { + render() { + return
; + } + } + ).displayName).toBe('Connect(Foo)'); + + expect(connect(state => state)( + createClass({ + displayName: 'Bar', + render() { + return
; + } + }) + ).displayName).toBe('Connect(Bar)'); + + expect(connect(state => state)( + createClass({ + render() { + return
; + } + }) + ).displayName).toBe('Connect(Component)'); }); it('should expose the wrapped component as DecoratedComponent', () => { @@ -154,5 +474,39 @@ describe('React', () => { expect(decorated.DecoratedComponent).toBe(Container); }); + + it('should return the instance of the wrapped component for use in calling child methods', () => { + const store = createStore(() => ({})); + + const someData = { + some: 'data' + }; + + class Container extends Component { + someInstanceMethod() { + return someData; + } + + render() { + return
; + } + } + + const decorator = connect(state => state); + const Decorated = decorator(Container); + + const tree = TestUtils.renderIntoDocument( + + {() => ( + + )} + + ); + + const decorated = TestUtils.findRenderedComponentWithType(tree, Decorated); + + expect(() => decorated.someInstanceMethod()).toThrow(); + expect(decorated.getUnderlyingRef().someInstanceMethod()).toBe(someData); + }); }); }); diff --git a/test/utils/getDisplayName.spec.js b/test/utils/getDisplayName.spec.js deleted file mode 100644 index 6a1740d76..000000000 --- a/test/utils/getDisplayName.spec.js +++ /dev/null @@ -1,17 +0,0 @@ -import expect from 'expect'; -import { createClass, Component } from 'react'; -import getDisplayName from '../../src/utils/getDisplayName'; - -describe('Utils', () => { - describe('getDisplayName', () => { - it('should extract the component class name', () => { - const names = [ - createClass({ displayName: 'Foo', render() {} }), - class Bar extends Component {}, - createClass({ render() {} }) - ].map(getDisplayName); - - expect(names).toEqual(['Foo', 'Bar', 'Component']); - }); - }); -}); diff --git a/test/utils/wrapActionCreators.js b/test/utils/wrapActionCreators.js new file mode 100644 index 000000000..af31ce4d6 --- /dev/null +++ b/test/utils/wrapActionCreators.js @@ -0,0 +1,31 @@ +import expect from 'expect'; +import wrapActionCreators from '../../src/utils/wrapActionCreators'; + +describe('Utils', () => { + describe('wrapActionCreators', () => { + it('should return a function that wraps argument in a call to bindActionCreators', () => { + + function dispatch(action) { + return { + dispatched: action + }; + } + + const actionResult = {an: 'action'}; + + const actionCreators = { + action: () => actionResult + }; + + const wrapped = wrapActionCreators(actionCreators); + expect(wrapped).toBeA(Function); + expect(() => wrapped(dispatch)).toNotThrow(); + expect(() => wrapped().action()).toThrow(); + + const bound = wrapped(dispatch); + expect(bound.action).toNotThrow(); + expect(bound.action().dispatched).toBe(actionResult); + + }); + }); +});