From fc41575dc28a4352ec2ecaf97895639080cb07ca Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 30 Jun 2015 23:10:53 +0200 Subject: [PATCH] Breaking API changes for 1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Naming: * “Stateless Stores” are now called reducers. (https://github.com/gaearon/redux/issues/137#issuecomment-114178411) * The “Redux instance” is now called “The Store”. (https://github.com/gaearon/redux/issues/137#issuecomment-113252359) * The dispatcher is removed completely. (https://github.com/gaearon/redux/pull/166#issue-90113962) API changes: * `composeStores` is now `composeReducers`. * `createDispatcher` is gone. * `createRedux` is now `createStore`. * `` now accepts `store` prop instead of `redux`. * The new `createStore` signature is `createStore(reducer: Function | Object, initialState: any, middlewares: Array | ({ getState, dispatch }) => Array)`. * If the first argument to `createStore` is an object, `composeReducers` is automatically applied to it. * The “smart” middleware signature changed. It now accepts an object instead of a single `getState` function. The `dispatch` function lets you “recurse” the middleware chain and is useful for async: https://github.com/gaearon/redux/issues/113#issuecomment-112603386. * The `dispatch` provided by the default thunk middleware now walks the whole middleware chain. * It is enforced now that Actions have to be plain object. Use middleware for transforming anything else into the actions. Internal changes: * The object in React context is renamed from `redux` to `store`. * Some tests are rewritten for clarity, focus and edge cases. * Redux in examples is now aliased to the source code for easier work on master. --- examples/counter/containers/App.js | 8 +- examples/counter/containers/CounterApp.js | 2 +- .../counter/{stores => reducers}/counter.js | 0 .../counter/{stores => reducers}/index.js | 0 examples/counter/webpack.config.js | 3 + examples/todomvc/containers/App.js | 11 +- examples/todomvc/containers/TodoApp.js | 2 +- .../todomvc/{stores => reducers}/index.js | 0 .../todomvc/{stores => reducers}/todos.js | 0 examples/todomvc/webpack.config.js | 3 + src/Redux.js | 53 ----- src/Store.js | 50 +++++ src/components/createConnector.js | 12 +- src/components/createProvideDecorator.js | 4 +- src/components/createProvider.js | 28 ++- src/createDispatcher.js | 26 --- src/createRedux.js | 13 -- src/createStore.js | 45 ++++ src/index.js | 10 +- src/middleware/thunk.js | 14 +- src/utils/composeReducers.js | 12 + src/utils/composeStores.js | 11 - ...reateReduxShape.js => createStoreShape.js} | 2 +- test/Store.spec.js | 209 ++++++++++++++++++ test/_helpers.js | 32 --- test/components/Connector.spec.js | 128 +++++++---- test/components/Provider.spec.js | 35 +-- test/components/connect.spec.js | 64 ++++-- test/components/provide.spec.js | 33 +-- test/composeMiddleware.spec.js | 2 +- test/composeReducers.spec.js | 33 +++ test/composeStores.spec.js | 31 --- test/createDispatcher.spec.js | 45 ---- test/createRedux.spec.js | 72 ------ test/createStore.spec.js | 123 +++++++++++ test/getDisplayName.spec.js | 10 +- test/helpers/actionCreators.js | 20 ++ test/helpers/actionTypes.js | 1 + test/helpers/reducers.js | 25 +++ test/utils/bindActionCreators.spec.js | 44 ++-- test/utils/identity.spec.js | 4 +- test/utils/mapValues.spec.js | 10 +- test/utils/pick.spec.js | 11 +- test/utils/shallowEquality.spec.js | 25 +-- 44 files changed, 791 insertions(+), 475 deletions(-) rename examples/counter/{stores => reducers}/counter.js (100%) rename examples/counter/{stores => reducers}/index.js (100%) rename examples/todomvc/{stores => reducers}/index.js (100%) rename examples/todomvc/{stores => reducers}/todos.js (100%) delete mode 100644 src/Redux.js create mode 100644 src/Store.js delete mode 100644 src/createDispatcher.js delete mode 100644 src/createRedux.js create mode 100644 src/createStore.js create mode 100644 src/utils/composeReducers.js delete mode 100644 src/utils/composeStores.js rename src/utils/{createReduxShape.js => createStoreShape.js} (74%) create mode 100644 test/Store.spec.js delete mode 100644 test/_helpers.js create mode 100644 test/composeReducers.spec.js delete mode 100644 test/composeStores.spec.js delete mode 100644 test/createDispatcher.spec.js delete mode 100644 test/createRedux.spec.js create mode 100644 test/createStore.spec.js create mode 100644 test/helpers/actionCreators.js create mode 100644 test/helpers/actionTypes.js create mode 100644 test/helpers/reducers.js diff --git a/examples/counter/containers/App.js b/examples/counter/containers/App.js index bf9195304bb..3acf7b9a1f4 100644 --- a/examples/counter/containers/App.js +++ b/examples/counter/containers/App.js @@ -1,15 +1,15 @@ import React from 'react'; import CounterApp from './CounterApp'; -import { createRedux } from 'redux'; +import { createStore } from 'redux/index'; import { Provider } from 'redux/react'; -import * as stores from '../stores'; +import * as reducers from '../reducers'; -const redux = createRedux(stores); +const store = createStore(reducers); export default class App { render() { return ( - + {() => } ); diff --git a/examples/counter/containers/CounterApp.js b/examples/counter/containers/CounterApp.js index f60d74c7465..e6c90482e7c 100644 --- a/examples/counter/containers/CounterApp.js +++ b/examples/counter/containers/CounterApp.js @@ -1,5 +1,5 @@ import React from 'react'; -import { bindActionCreators } from 'redux'; +import { bindActionCreators } from 'redux/index'; import { connect } from 'redux/react'; import Counter from '../components/Counter'; import * as CounterActions from '../actions/CounterActions'; diff --git a/examples/counter/stores/counter.js b/examples/counter/reducers/counter.js similarity index 100% rename from examples/counter/stores/counter.js rename to examples/counter/reducers/counter.js diff --git a/examples/counter/stores/index.js b/examples/counter/reducers/index.js similarity index 100% rename from examples/counter/stores/index.js rename to examples/counter/reducers/index.js diff --git a/examples/counter/webpack.config.js b/examples/counter/webpack.config.js index 032d620fd22..33fb9a32910 100644 --- a/examples/counter/webpack.config.js +++ b/examples/counter/webpack.config.js @@ -18,6 +18,9 @@ module.exports = { new webpack.NoErrorsPlugin() ], resolve: { + alias: { + 'redux': path.join(__dirname, '../../src') + }, extensions: ['', '.js'] }, module: { diff --git a/examples/todomvc/containers/App.js b/examples/todomvc/containers/App.js index 253f3260e11..2563bf26693 100644 --- a/examples/todomvc/containers/App.js +++ b/examples/todomvc/containers/App.js @@ -1,16 +1,17 @@ import React from 'react'; import TodoApp from './TodoApp'; -import { createRedux } from 'redux'; +import { createStore, composeReducers } from 'redux/index'; import { Provider } from 'redux/react'; -import * as stores from '../stores'; +import * as reducers from '../reducers'; -const redux = createRedux(stores); +const reducer = composeReducers(reducers); +const store = createStore(reducer); export default class App { render() { return ( - - {() => } + + {() => } ); } diff --git a/examples/todomvc/containers/TodoApp.js b/examples/todomvc/containers/TodoApp.js index 03df600025f..348d51441ae 100644 --- a/examples/todomvc/containers/TodoApp.js +++ b/examples/todomvc/containers/TodoApp.js @@ -1,5 +1,5 @@ import React from 'react'; -import { bindActionCreators } from 'redux'; +import { bindActionCreators } from 'redux/index'; import { Connector } from 'redux/react'; import Header from '../components/Header'; import MainSection from '../components/MainSection'; diff --git a/examples/todomvc/stores/index.js b/examples/todomvc/reducers/index.js similarity index 100% rename from examples/todomvc/stores/index.js rename to examples/todomvc/reducers/index.js diff --git a/examples/todomvc/stores/todos.js b/examples/todomvc/reducers/todos.js similarity index 100% rename from examples/todomvc/stores/todos.js rename to examples/todomvc/reducers/todos.js diff --git a/examples/todomvc/webpack.config.js b/examples/todomvc/webpack.config.js index 679803efdf8..e1e16c04680 100644 --- a/examples/todomvc/webpack.config.js +++ b/examples/todomvc/webpack.config.js @@ -18,6 +18,9 @@ module.exports = { new webpack.NoErrorsPlugin() ], resolve: { + alias: { + 'redux': path.join(__dirname, '../../src') + }, extensions: ['', '.js'] }, module: { diff --git a/src/Redux.js b/src/Redux.js deleted file mode 100644 index 860da8746b4..00000000000 --- a/src/Redux.js +++ /dev/null @@ -1,53 +0,0 @@ -import createDispatcher from './createDispatcher'; -import composeStores from './utils/composeStores'; -import thunkMiddleware from './middleware/thunk'; - -export default class Redux { - constructor(dispatcherOrStores, initialState) { - let finalDispatcher = dispatcherOrStores; - if (typeof dispatcherOrStores === 'object') { - // A shortcut notation to use the default dispatcher - finalDispatcher = createDispatcher( - composeStores(dispatcherOrStores), - (getState) => [thunkMiddleware(getState)] - ); - } - - this.state = initialState; - this.listeners = []; - this.replaceDispatcher(finalDispatcher); - } - - getDispatcher() { - return this.dispatcher; - } - - replaceDispatcher(nextDispatcher) { - this.dispatcher = nextDispatcher; - this.dispatchFn = nextDispatcher(this.state, ::this.setState); - } - - dispatch(action) { - return this.dispatchFn(action); - } - - getState() { - return this.state; - } - - setState(nextState) { - this.state = nextState; - this.listeners.forEach(listener => listener()); - return nextState; - } - - subscribe(listener) { - const { listeners } = this; - listeners.push(listener); - - return function unsubscribe() { - const index = listeners.indexOf(listener); - listeners.splice(index, 1); - }; - } -} diff --git a/src/Store.js b/src/Store.js new file mode 100644 index 00000000000..ed85b987fc2 --- /dev/null +++ b/src/Store.js @@ -0,0 +1,50 @@ +import invariant from 'invariant'; +import isPlainObject from './utils/isPlainObject'; + +export default class Store { + constructor(reducer, initialState) { + invariant( + typeof reducer === 'function', + 'Expected the reducer to be a function.' + ); + + this.state = initialState; + this.listeners = []; + this.replaceReducer(reducer); + } + + getReducer() { + return this.reducer; + } + + replaceReducer(nextReducer) { + this.reducer = nextReducer; + this.dispatch({ type: '@@INIT' }); + } + + dispatch(action) { + invariant( + isPlainObject(action), + 'Actions must be plain objects. Use custom middleware for async actions.' + ); + + const { reducer } = this; + this.state = reducer(this.state, action); + this.listeners.forEach(listener => listener()); + return action; + } + + getState() { + return this.state; + } + + subscribe(listener) { + const { listeners } = this; + listeners.push(listener); + + return function unsubscribe() { + const index = listeners.indexOf(listener); + listeners.splice(index, 1); + }; + } +} diff --git a/src/components/createConnector.js b/src/components/createConnector.js index 58d097ee243..153130000d5 100644 --- a/src/components/createConnector.js +++ b/src/components/createConnector.js @@ -1,4 +1,4 @@ -import createReduxShape from '../utils/createReduxShape'; +import createStoreShape from '../utils/createStoreShape'; import identity from '../utils/identity'; import shallowEqual from '../utils/shallowEqual'; import isPlainObject from '../utils/isPlainObject'; @@ -6,10 +6,11 @@ import invariant from 'invariant'; export default function createConnector(React) { const { Component, PropTypes } = React; + const storeShape = createStoreShape(PropTypes); return class Connector extends Component { static contextTypes = { - redux: createReduxShape(PropTypes).isRequired + store: storeShape.isRequired }; static propTypes = { @@ -38,12 +39,11 @@ export default function createConnector(React) { constructor(props, context) { super(props, context); - this.state = this.selectState(props, context); } componentDidMount() { - this.unsubscribe = this.context.redux.subscribe(::this.handleChange); + this.unsubscribe = this.context.store.subscribe(::this.handleChange); } componentWillReceiveProps(nextProps) { @@ -63,7 +63,7 @@ export default function createConnector(React) { } selectState(props, context) { - const state = context.redux.getState(); + const state = context.store.getState(); const slice = props.select(state); invariant( @@ -78,7 +78,7 @@ export default function createConnector(React) { render() { const { children } = this.props; const { slice } = this.state; - const { redux: { dispatch } } = this.context; + const { store: { dispatch } } = this.context; return children({ dispatch, ...slice }); } diff --git a/src/components/createProvideDecorator.js b/src/components/createProvideDecorator.js index 5c1784513c9..d181865a40a 100644 --- a/src/components/createProvideDecorator.js +++ b/src/components/createProvideDecorator.js @@ -3,14 +3,14 @@ import getDisplayName from '../utils/getDisplayName'; export default function createProvideDecorator(React, Provider) { const { Component } = React; - return function provide(redux) { + return function provide(store) { return DecoratedComponent => class ProviderDecorator extends Component { static displayName = `Provider(${getDisplayName(DecoratedComponent)})`; static DecoratedComponent = DecoratedComponent; render() { return ( - + {() => } ); diff --git a/src/components/createProvider.js b/src/components/createProvider.js index cf988df40af..030c8e2ac72 100644 --- a/src/components/createProvider.js +++ b/src/components/createProvider.js @@ -1,36 +1,34 @@ -import createReduxShape from '../utils/createReduxShape'; +import createStoreShape from '../utils/createStoreShape'; export default function createProvider(React) { const { Component, PropTypes } = React; - - const reduxShapeIsRequired = createReduxShape(PropTypes).isRequired; + const storeShape = createStoreShape(PropTypes); return class Provider extends Component { - static propTypes = { - redux: reduxShapeIsRequired, - children: PropTypes.func.isRequired + static childContextTypes = { + store: storeShape.isRequired }; - static childContextTypes = { - redux: reduxShapeIsRequired + static propTypes = { + children: PropTypes.func.isRequired }; getChildContext() { - return { redux: this.state.redux }; + return { store: this.state.store }; } constructor(props, context) { super(props, context); - this.state = { redux: props.redux }; + this.state = { store: props.store }; } componentWillReceiveProps(nextProps) { - const { redux } = this.state; - const { redux: nextRedux } = nextProps; + const { store } = this.state; + const { store: nextStore } = nextProps; - if (redux !== nextRedux) { - const nextDispatcher = nextRedux.getDispatcher(); - redux.replaceDispatcher(nextDispatcher); + if (store !== nextStore) { + const nextReducer = nextStore.getReducer(); + store.replaceReducer(nextReducer); } } diff --git a/src/createDispatcher.js b/src/createDispatcher.js deleted file mode 100644 index 029b6986975..00000000000 --- a/src/createDispatcher.js +++ /dev/null @@ -1,26 +0,0 @@ -import composeMiddleware from './utils/composeMiddleware'; - -const INIT_ACTION = { - type: '@@INIT' -}; - -export default function createDispatcher(store, middlewares = []) { - return function dispatcher(initialState, setState) { - let state = setState(store(initialState, INIT_ACTION)); - - function dispatch(action) { - state = setState(store(state, action)); - return action; - } - - function getState() { - return state; - } - - const finalMiddlewares = typeof middlewares === 'function' ? - middlewares(getState) : - middlewares; - - return composeMiddleware(...finalMiddlewares, dispatch); - }; -} diff --git a/src/createRedux.js b/src/createRedux.js deleted file mode 100644 index f3ee7c13472..00000000000 --- a/src/createRedux.js +++ /dev/null @@ -1,13 +0,0 @@ -import Redux from './Redux'; - -export default function createRedux(...args) { - const redux = new Redux(...args); - - return { - subscribe: ::redux.subscribe, - dispatch: ::redux.dispatch, - getState: ::redux.getState, - getDispatcher: ::redux.getDispatcher, - replaceDispatcher: ::redux.replaceDispatcher - }; -} diff --git a/src/createStore.js b/src/createStore.js new file mode 100644 index 00000000000..a06c60cf263 --- /dev/null +++ b/src/createStore.js @@ -0,0 +1,45 @@ +import Store from './Store'; +import composeReducers from './utils/composeReducers'; +import composeMiddleware from './utils/composeMiddleware'; +import thunkMiddleware from './middleware/thunk'; + +const defaultMiddlewares = ({ dispatch, getState }) => [ + thunkMiddleware({ dispatch, getState }) +]; + +export default function createStore( + reducer, + initialState, + middlewares = defaultMiddlewares +) { + const finalReducer = typeof reducer === 'function' ? + reducer : + composeReducers(reducer); + + const store = new Store(finalReducer, initialState); + const getState = ::store.getState; + + const rawDispatch = ::store.dispatch; + let cookedDispatch = null; + + function dispatch(action) { + return cookedDispatch(action); + } + + const finalMiddlewares = typeof middlewares === 'function' ? + middlewares({ dispatch, getState }) : + middlewares; + + cookedDispatch = composeMiddleware( + ...finalMiddlewares, + rawDispatch + ); + + return { + dispatch: cookedDispatch, + subscribe: ::store.subscribe, + getState: ::store.getState, + getReducer: ::store.getReducer, + replaceReducer: ::store.replaceReducer + }; +} diff --git a/src/index.js b/src/index.js index 607b83f317c..6ba367c91c3 100644 --- a/src/index.js +++ b/src/index.js @@ -1,16 +1,14 @@ // Core -import createRedux from './createRedux'; -import createDispatcher from './createDispatcher'; +import createStore from './createStore'; // Utilities import composeMiddleware from './utils/composeMiddleware'; -import composeStores from './utils/composeStores'; +import composeReducers from './utils/composeReducers'; import bindActionCreators from './utils/bindActionCreators'; export { - createRedux, - createDispatcher, + createStore, composeMiddleware, - composeStores, + composeReducers, bindActionCreators }; diff --git a/src/middleware/thunk.js b/src/middleware/thunk.js index a80a78159c3..14e92df5c20 100644 --- a/src/middleware/thunk.js +++ b/src/middleware/thunk.js @@ -1,10 +1,6 @@ -export default function thunkMiddleware(getState) { - return (next) => { - const recurse = (action) => - typeof action === 'function' ? - action(recurse, getState) : - next(action); - - return recurse; - }; +export default function thunkMiddleware({ dispatch, getState }) { + return (next) => (action) => + typeof action === 'function' ? + action(dispatch, getState) : + next(action); } diff --git a/src/utils/composeReducers.js b/src/utils/composeReducers.js new file mode 100644 index 00000000000..e93887fabf8 --- /dev/null +++ b/src/utils/composeReducers.js @@ -0,0 +1,12 @@ +import mapValues from '../utils/mapValues'; +import pick from '../utils/pick'; + +export default function composeReducers(reducers) { + const finalReducers = pick(reducers, (val) => typeof val === 'function'); + + return function Composition(atom = {}, action) { + return mapValues(finalReducers, (store, key) => + store(atom[key], action) + ); + }; +} diff --git a/src/utils/composeStores.js b/src/utils/composeStores.js deleted file mode 100644 index d8c4420546d..00000000000 --- a/src/utils/composeStores.js +++ /dev/null @@ -1,11 +0,0 @@ -import mapValues from '../utils/mapValues'; -import pick from '../utils/pick'; - -export default function composeStores(stores) { - const finalStores = pick(stores, (val) => typeof val === 'function'); - return function Composition(atom = {}, action) { - return mapValues(finalStores, (store, key) => - store(atom[key], action) - ); - }; -} diff --git a/src/utils/createReduxShape.js b/src/utils/createStoreShape.js similarity index 74% rename from src/utils/createReduxShape.js rename to src/utils/createStoreShape.js index e3795bedd24..851e7ce8988 100644 --- a/src/utils/createReduxShape.js +++ b/src/utils/createStoreShape.js @@ -1,4 +1,4 @@ -export default function createReduxShape(PropTypes) { +export default function createStoreShape(PropTypes) { return PropTypes.shape({ subscribe: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired, diff --git a/test/Store.spec.js b/test/Store.spec.js new file mode 100644 index 00000000000..e1834db84e7 --- /dev/null +++ b/test/Store.spec.js @@ -0,0 +1,209 @@ +import expect from 'expect'; +import Store from '../src/Store'; +import { todos, todosReverse } from './helpers/reducers'; +import { addTodo } from './helpers/actionCreators'; + +describe('Store', () => { + it('should require a reducer function', () => { + expect(() => + new Store() + ).toThrow(); + + expect(() => + new Store('test') + ).toThrow(); + + expect(() => + new Store({}) + ).toThrow(); + + expect(() => + new Store(() => {}) + ).toNotThrow(); + }); + + it('should apply the reducer to the previous state', () => { + const store = new Store(todos); + expect(store.getState()).toEqual([]); + + store.dispatch({}); + expect(store.getState()).toEqual([]); + + store.dispatch(addTodo('Hello')); + expect(store.getState()).toEqual([{ + id: 1, + text: 'Hello' + }]); + + store.dispatch(addTodo('World')); + expect(store.getState()).toEqual([{ + id: 1, + text: 'Hello' + }, { + id: 2, + text: 'World' + }]); + }); + + it('should apply the reducer to the initial state', () => { + const store = new Store(todos, [{ + id: 1, + text: 'Hello' + }]); + expect(store.getState()).toEqual([{ + id: 1, + text: 'Hello' + }]); + + store.dispatch({}); + expect(store.getState()).toEqual([{ + id: 1, + text: 'Hello' + }]); + + store.dispatch(addTodo('World')); + expect(store.getState()).toEqual([{ + id: 1, + text: 'Hello' + }, { + id: 2, + text: 'World' + }]); + }); + + it('should preserve the state when replacing a reducer', () => { + const store = new Store(todos); + store.dispatch(addTodo('Hello')); + store.dispatch(addTodo('World')); + expect(store.getState()).toEqual([{ + id: 1, + text: 'Hello' + }, { + id: 2, + text: 'World' + }]); + + let nextStore = new Store(todosReverse); + store.replaceReducer(nextStore.getReducer()); + expect(store.getState()).toEqual([{ + id: 1, + text: 'Hello' + }, { + id: 2, + text: 'World' + }]); + + store.dispatch(addTodo('Perhaps')); + expect(store.getState()).toEqual([{ + id: 2, + text: 'Perhaps' + }, { + id: 1, + text: 'Hello' + }, { + id: 2, + text: 'World' + }]); + + nextStore = new Store(todos); + store.replaceReducer(nextStore.getReducer()); + expect(store.getState()).toEqual([{ + id: 2, + text: 'Perhaps' + }, { + id: 1, + text: 'Hello' + }, { + id: 2, + text: 'World' + }]); + + store.dispatch(addTodo('Surely')); + expect(store.getState()).toEqual([{ + id: 2, + text: 'Perhaps' + }, { + id: 1, + text: 'Hello' + }, { + id: 2, + text: 'World' + }, { + id: 3, + text: 'Surely' + }]); + }); + + it('should support multiple subscriptions', () => { + const store = new Store(todos); + const listenerA = expect.createSpy(() => {}); + const listenerB = expect.createSpy(() => {}); + + let unsubscribeA = store.subscribe(listenerA); + store.dispatch({}); + expect(listenerA.calls.length).toBe(1); + expect(listenerB.calls.length).toBe(0); + + store.dispatch({}); + expect(listenerA.calls.length).toBe(2); + expect(listenerB.calls.length).toBe(0); + + const unsubscribeB = store.subscribe(listenerB); + expect(listenerA.calls.length).toBe(2); + expect(listenerB.calls.length).toBe(0); + + store.dispatch({}); + expect(listenerA.calls.length).toBe(3); + expect(listenerB.calls.length).toBe(1); + + unsubscribeA(); + expect(listenerA.calls.length).toBe(3); + expect(listenerB.calls.length).toBe(1); + + store.dispatch({}); + expect(listenerA.calls.length).toBe(3); + expect(listenerB.calls.length).toBe(2); + + unsubscribeB(); + expect(listenerA.calls.length).toBe(3); + expect(listenerB.calls.length).toBe(2); + + store.dispatch({}); + expect(listenerA.calls.length).toBe(3); + expect(listenerB.calls.length).toBe(2); + + unsubscribeA = store.subscribe(listenerA); + expect(listenerA.calls.length).toBe(3); + expect(listenerB.calls.length).toBe(2); + + store.dispatch({}); + expect(listenerA.calls.length).toBe(4); + expect(listenerB.calls.length).toBe(2); + }); + + it('should provide an up-to-date state when a subscriber is notified', done => { + const store = new Store(todos); + store.subscribe(() => { + expect(store.getState()).toEqual([{ + id: 1, + text: 'Hello' + }]); + done(); + }); + store.dispatch(addTodo('Hello')); + }); + + it('should only accept plain object actions', () => { + const store = new Store(todos); + expect(() => + store.dispatch({}) + ).toNotThrow(); + + function AwesomeMap() { } + [null, undefined, 42, 'hey', new AwesomeMap()].forEach(nonObject => + expect(() => + store.dispatch(nonObject) + ).toThrow(/plain/) + ); + }); +}); diff --git a/test/_helpers.js b/test/_helpers.js deleted file mode 100644 index f525518ffea..00000000000 --- a/test/_helpers.js +++ /dev/null @@ -1,32 +0,0 @@ -const ADD_TODO = 'ADD_TODO'; -const ADD_TODO_ASYNC = 'ADD_TODO_ASYNC'; - -export const initialState = []; -export const defaultText = 'Hello World!'; -export const constants = { ADD_TODO, ADD_TODO_ASYNC }; - -export function todoStore(state = initialState, action) { - const { type } = action; - if (type === ADD_TODO || type === ADD_TODO_ASYNC) { - return [{ - id: state[0] ? state[0].id + 1 : 1, - text: action.text - }, ...state]; - } - return state; -} - -export const todoActions = { - addTodo(text) { - return { type: ADD_TODO, text }; - }, - - addTodoAsync(text, cb/* for testing only */) { - return dispatch => { - setImmediate(() => { - dispatch({ type: ADD_TODO_ASYNC, text }); - cb(); - }); - }; - } -}; diff --git a/test/components/Connector.spec.js b/test/components/Connector.spec.js index d9202a8c5c6..27d968ea1a0 100644 --- a/test/components/Connector.spec.js +++ b/test/components/Connector.spec.js @@ -1,7 +1,7 @@ import expect from 'expect'; import jsdomReact from './jsdomReact'; import React, { PropTypes, Component } from 'react/addons'; -import { createRedux } from '../../src'; +import { createStore } from '../../src'; import { Connector } from '../../src/react'; const { TestUtils } = React.addons; @@ -13,11 +13,11 @@ describe('React', () => { // Mock minimal Provider interface class Provider extends Component { static childContextTypes = { - redux: PropTypes.object.isRequired + store: PropTypes.object.isRequired } getChildContext() { - return { redux: this.props.redux }; + return { store: this.props.store }; } render() { @@ -25,17 +25,17 @@ describe('React', () => { } } - const stringBuilder = (prev = '', action) => { + function stringBuilder(prev = '', action) { return action.type === 'APPEND' ? prev + action.body : prev; - }; + } - it('gets Redux from context', () => { - const redux = createRedux({ test: () => 'test' }); + it('should receive the store in the context', () => { + const store = createStore({}); const tree = TestUtils.renderIntoDocument( - + {() => ( {() =>
} @@ -45,16 +45,16 @@ describe('React', () => { ); const connector = TestUtils.findRenderedComponentWithType(tree, Connector); - expect(connector.context.redux).toBe(redux); + expect(connector.context.store).toBe(store); }); - it('subscribes to Redux changes', () => { - const redux = createRedux({ string: stringBuilder }); + it('should subscribe to the store changes', () => { + const store = createStore(stringBuilder); const tree = TestUtils.renderIntoDocument( - + {() => ( - ({ string: state.string })}> + ({ string })}> {({ string }) =>
} )} @@ -63,19 +63,19 @@ describe('React', () => { const div = TestUtils.findRenderedDOMComponentWithTag(tree, 'div'); expect(div.props.string).toBe(''); - redux.dispatch({ type: 'APPEND', body: 'a'}); + store.dispatch({ type: 'APPEND', body: 'a'}); expect(div.props.string).toBe('a'); - redux.dispatch({ type: 'APPEND', body: 'b'}); + store.dispatch({ type: 'APPEND', body: 'b'}); expect(div.props.string).toBe('ab'); }); - it('unsubscribes before unmounting', () => { - const redux = createRedux({ test: () => 'test' }); - const subscribe = redux.subscribe; + it('should unsubscribe before unmounting', () => { + const store = createStore(stringBuilder); + const subscribe = store.subscribe; - // Keep track of unsubscribe by wrapping `subscribe()` + // Keep track of unsubscribe by wrapping subscribe() const spy = expect.createSpy(() => {}); - redux.subscribe = (listener) => { + store.subscribe = (listener) => { const unsubscribe = subscribe(listener); return () => { spy(); @@ -84,9 +84,9 @@ describe('React', () => { }; const tree = TestUtils.renderIntoDocument( - + {() => ( - ({ string: state.string })}> + ({ string })}> {({ string }) =>
} )} @@ -99,8 +99,8 @@ describe('React', () => { expect(spy.calls.length).toBe(1); }); - it('shallow compares selected state to prevent unnecessary updates', () => { - const redux = createRedux({ string: stringBuilder }); + it('should shallowly compare the selected state to prevent unnecessary updates', () => { + const store = createStore(stringBuilder); const spy = expect.createSpy(() => {}); function render({ string }) { spy(); @@ -108,9 +108,9 @@ describe('React', () => { } const tree = TestUtils.renderIntoDocument( - + {() => ( - ({ string: state.string })}> + ({ string })}> {render} )} @@ -120,16 +120,19 @@ describe('React', () => { const div = TestUtils.findRenderedDOMComponentWithTag(tree, 'div'); expect(spy.calls.length).toBe(1); expect(div.props.string).toBe(''); - redux.dispatch({ type: 'APPEND', body: 'a'}); + store.dispatch({ type: 'APPEND', body: 'a'}); expect(spy.calls.length).toBe(2); - redux.dispatch({ type: 'APPEND', body: 'b'}); + store.dispatch({ type: 'APPEND', body: 'b'}); expect(spy.calls.length).toBe(3); - redux.dispatch({ type: 'APPEND', body: ''}); + store.dispatch({ type: 'APPEND', body: ''}); expect(spy.calls.length).toBe(3); }); - it('recomputes the state slice when `select` prop changes', () => { - const redux = createRedux({ a: () => 42, b: () => 72 }); + 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 }; @@ -151,7 +154,7 @@ describe('React', () => { render() { return ( - + {() => {render} @@ -170,11 +173,11 @@ describe('React', () => { expect(div.props.children).toBe(72); }); - it('passes `dispatch()` to child function', () => { - const redux = createRedux({ test: () => 'test' }); + it('should pass dispatch() to the child function', () => { + const store = createStore({}); const tree = TestUtils.renderIntoDocument( - + {() => ( {({ dispatch }) =>
} @@ -184,17 +187,43 @@ describe('React', () => { ); const div = TestUtils.findRenderedDOMComponentWithTag(tree, 'div'); - expect(div.props.dispatch).toBe(redux.dispatch); + expect(div.props.dispatch).toBe(store.dispatch); }); - it('should throw an error if `state` returns anything but a plain object', () => { - const redux = createRedux(() => {}); + 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( - + {() => ( - 1}> + 'hey'}> + {() =>
} + + )} + + ); + }).toThrow(/select/); + + function AwesomeMap() { } + + expect(() => { + TestUtils.renderIntoDocument( + + {() => ( + new AwesomeMap()}> {() =>
} )} @@ -203,29 +232,34 @@ describe('React', () => { }).toThrow(/select/); }); - it('does not throw error when `renderToString` is called on server', () => { + it('should not setState when renderToString is called on the server', () => { const { renderToString } = React; - const redux = createRedux({ string: stringBuilder }); + const store = createStore(stringBuilder); + class TestComp extends Component { componentWillMount() { - // simulate response action on data returning - redux.dispatch({ type: 'APPEND', body: 'a'}); + store.dispatch({ + type: 'APPEND', + body: 'a' + }); } + render() { return (
{this.props.string}
); } } + const el = ( - + {() => ( - ({ string: state.string })}> + ({ string })}> {({ string }) => } )} ); - expect(() => renderToString(el)).toNotThrow(); + expect(() => renderToString(el)).toNotThrow(); }); }); }); diff --git a/test/components/Provider.spec.js b/test/components/Provider.spec.js index a94a9abfe5d..3e3b3c3308f 100644 --- a/test/components/Provider.spec.js +++ b/test/components/Provider.spec.js @@ -1,7 +1,7 @@ import expect from 'expect'; import jsdomReact from './jsdomReact'; import React, { PropTypes, Component } from 'react/addons'; -import { createRedux } from '../../src'; +import { createStore } from '../../src'; import { Provider } from '../../src/react'; const { TestUtils } = React.addons; @@ -12,7 +12,7 @@ describe('React', () => { class Child extends Component { static contextTypes = { - redux: PropTypes.object.isRequired + store: PropTypes.object.isRequired } render() { @@ -20,30 +20,30 @@ describe('React', () => { } } - it('adds Redux to child context', () => { - const redux = createRedux({ test: () => 'test' }); + it('should add the store to the child context', () => { + const store = createStore({}); const tree = TestUtils.renderIntoDocument( - + {() => } ); const child = TestUtils.findRenderedComponentWithType(tree, Child); - expect(child.context.redux).toBe(redux); + expect(child.context.store).toBe(store); }); - it('does not lose subscribers when receiving new props', () => { - const redux1 = createRedux({ test: () => 'test' }); - const redux2 = createRedux({ test: () => 'test' }); + 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(() => {}); class ProviderContainer extends Component { - state = { redux: redux1 }; + state = { store: store1 }; render() { return ( - + {() => } ); @@ -52,15 +52,20 @@ describe('React', () => { const container = TestUtils.renderIntoDocument(); const child = TestUtils.findRenderedComponentWithType(container, Child); + expect(child.context.store.getState()).toEqual(11); - child.context.redux.subscribe(spy); - child.context.redux.dispatch({}); + child.context.store.subscribe(spy); + child.context.store.dispatch({}); expect(spy.calls.length).toEqual(1); + expect(child.context.store.getState()).toEqual(12); - container.setState({ redux: redux2 }); + container.setState({ store: store2 }); expect(spy.calls.length).toEqual(2); - child.context.redux.dispatch({}); + expect(child.context.store.getState()).toEqual(24); + + child.context.store.dispatch({}); expect(spy.calls.length).toEqual(3); + expect(child.context.store.getState()).toEqual(48); }); }); }); diff --git a/test/components/connect.spec.js b/test/components/connect.spec.js index 0fce239d367..6693ef10955 100644 --- a/test/components/connect.spec.js +++ b/test/components/connect.spec.js @@ -1,7 +1,7 @@ import expect from 'expect'; import jsdomReact from './jsdomReact'; import React, { PropTypes, Component } from 'react/addons'; -import { createRedux } from '../../src'; +import { createStore } from '../../src'; import { connect, Connector } from '../../src/react'; const { TestUtils } = React.addons; @@ -13,11 +13,11 @@ describe('React', () => { // Mock minimal Provider interface class Provider extends Component { static childContextTypes = { - redux: PropTypes.object.isRequired + store: PropTypes.object.isRequired } getChildContext() { - return { redux: this.props.redux }; + return { store: this.props.store }; } render() { @@ -25,8 +25,10 @@ describe('React', () => { } } - it('wraps component with Provider', () => { - const redux = createRedux({ test: () => 'test' }); + it('should wrap the component into Provider', () => { + const store = createStore(() => ({ + foo: 'bar' + })); @connect(state => state) class Container extends Component { @@ -36,18 +38,50 @@ describe('React', () => { } const container = TestUtils.renderIntoDocument( - - {() => } + + {() => } ); const div = TestUtils.findRenderedDOMComponentWithTag(container, 'div'); expect(div.props.pass).toEqual('through'); - expect(div.props.test).toEqual('test'); - expect(() => TestUtils.findRenderedComponentWithType(container, Connector)) - .toNotThrow(); + expect(div.props.foo).toEqual('bar'); + expect(() => + TestUtils.findRenderedComponentWithType(container, Connector) + ).toNotThrow(); }); - it('sets displayName correctly', () => { + it('should pass the only argument as the select prop down', () => { + const store = createStore(() => ({ + foo: 'baz', + bar: 'baz' + })); + + function select({ foo }) { + return { foo }; + } + + @connect(select) + class Container extends Component { + render() { + return
; + } + } + + const container = TestUtils.renderIntoDocument( + + {() => } + + ); + const connector = TestUtils.findRenderedComponentWithType(container, Connector); + expect(connector.props.select({ + foo: 5, + bar: 7 + })).toEqual({ + foo: 5 + }); + }); + + it('should set the displayName correctly', () => { @connect(state => state) class Container extends Component { render() { @@ -58,17 +92,17 @@ describe('React', () => { expect(Container.displayName).toBe('Connector(Container)'); }); - it('sets DecoratedComponent to wrapped component', () => { + it('should expose the wrapped component as DecoratedComponent', () => { class Container extends Component { render() { return
; } } - let decorator = connect(state => state); - let ConnectorDecorator = decorator(Container); + const decorator = connect(state => state); + const decorated = decorator(Container); - expect(ConnectorDecorator.DecoratedComponent).toBe(Container); + expect(decorated.DecoratedComponent).toBe(Container); }); }); }); diff --git a/test/components/provide.spec.js b/test/components/provide.spec.js index 53ac234960a..babef47758e 100644 --- a/test/components/provide.spec.js +++ b/test/components/provide.spec.js @@ -1,7 +1,7 @@ import expect from 'expect'; import jsdomReact from './jsdomReact'; import React, { PropTypes, Component } from 'react/addons'; -import { createRedux } from '../../src'; +import { createStore } from '../../src'; import { provide, Provider } from '../../src/react'; const { TestUtils } = React.addons; @@ -12,7 +12,7 @@ describe('React', () => { class Child extends Component { static contextTypes = { - redux: PropTypes.object.isRequired + store: PropTypes.object.isRequired } render() { @@ -20,26 +20,29 @@ describe('React', () => { } } - it('wraps component with Provider', () => { - const redux = createRedux({ test: () => 'test' }); + it('should wrap the component into Provider', () => { + const store = createStore({}); - @provide(redux) + @provide(store) class Container extends Component { render() { return ; } } - const container = TestUtils.renderIntoDocument(); + const container = TestUtils.renderIntoDocument( + + ); const child = TestUtils.findRenderedComponentWithType(container, Child); expect(child.props.pass).toEqual('through'); - expect(() => TestUtils.findRenderedComponentWithType(container, Provider)) - .toNotThrow(); - expect(child.context.redux).toBe(redux); + expect(() => + TestUtils.findRenderedComponentWithType(container, Provider) + ).toNotThrow(); + expect(child.context.store).toBe(store); }); - it('sets displayName correctly', () => { - @provide(createRedux({ test: () => 'test' })) + it('sets the displayName correctly', () => { + @provide(createStore({})) class Container extends Component { render() { return
; @@ -49,17 +52,17 @@ describe('React', () => { expect(Container.displayName).toBe('Provider(Container)'); }); - it('sets DecoratedComponent to wrapped component', () => { + it('should expose the wrapped component as DecoratedComponent', () => { class Container extends Component { render() { return
; } } - let decorator = provide(state => state); - let ProviderDecorator = decorator(Container); + const decorator = provide(state => state); + const decorated = decorator(Container); - expect(ProviderDecorator.DecoratedComponent).toBe(Container); + expect(decorated.DecoratedComponent).toBe(Container); }); }); }); diff --git a/test/composeMiddleware.spec.js b/test/composeMiddleware.spec.js index 95d916afe6f..5e39b08eabf 100644 --- a/test/composeMiddleware.spec.js +++ b/test/composeMiddleware.spec.js @@ -3,7 +3,7 @@ import { composeMiddleware } from '../src'; describe('Utils', () => { describe('composeMiddleware', () => { - it('should return combined middleware that executes from left to right', () => { + it('should return the combined middleware that executes from left to right', () => { const a = next => action => next(action + 'a'); const b = next => action => next(action + 'b'); const c = next => action => next(action + 'c'); diff --git a/test/composeReducers.spec.js b/test/composeReducers.spec.js new file mode 100644 index 00000000000..c0ce3c919ed --- /dev/null +++ b/test/composeReducers.spec.js @@ -0,0 +1,33 @@ +import expect from 'expect'; +import { composeReducers } from '../src'; + +describe('Utils', () => { + describe('composeReducers', () => { + it('should return a composite reducer that maps the state keys to given reducers', () => { + const reducer = composeReducers({ + counter: (state = 0, action) => + action.type === 'increment' ? state + 1 : state, + stack: (state = [], action) => + action.type === 'push' ? [...state, action.value] : state + }); + + const s1 = reducer({}, { type: 'increment' }); + expect(s1).toEqual({ counter: 1, stack: [] }); + const s2 = reducer(s1, { type: 'push', value: 'a' }); + expect(s2).toEqual({ counter: 1, stack: ['a'] }); + }); + + it('ignores all props which are not a function', () => { + const reducer = composeReducers({ + fake: true, + broken: 'string', + another: { nested: 'object' }, + stack: (state = []) => state + }); + + expect( + Object.keys(reducer({}, { type: 'push' })) + ).toEqual(['stack']); + }); + }); +}); diff --git a/test/composeStores.spec.js b/test/composeStores.spec.js deleted file mode 100644 index 551b275e59a..00000000000 --- a/test/composeStores.spec.js +++ /dev/null @@ -1,31 +0,0 @@ -import expect from 'expect'; -import { composeStores } from '../src'; - -describe('Utils', () => { - describe('composeStores', () => { - it('should return a store that maps state keys to reducer functions', () => { - const store = composeStores({ - counter: (state = 0, action) => - action.type === 'increment' ? state + 1 : state, - stack: (state = [], action) => - action.type === 'push' ? [...state, action.value] : state - }); - - const s1 = store({}, { type: 'increment' }); - expect(s1).toEqual({ counter: 1, stack: [] }); - const s2 = store(s1, { type: 'push', value: 'a' }); - expect(s2).toEqual({ counter: 1, stack: ['a'] }); - }); - - it('should ignore all props which are not a function', () => { - const store = composeStores({ - fake: true, - broken: 'string', - another: {nested: 'object'}, - stack: (state = []) => state - }); - - expect(Object.keys(store({}, {type: 'push'}))).toEqual(['stack']); - }); - }); -}); diff --git a/test/createDispatcher.spec.js b/test/createDispatcher.spec.js deleted file mode 100644 index 40991bfceca..00000000000 --- a/test/createDispatcher.spec.js +++ /dev/null @@ -1,45 +0,0 @@ -import expect from 'expect'; -import { createDispatcher, composeStores } from '../src'; -import thunkMiddleware from '../src/middleware/thunk'; -import * as helpers from './_helpers'; - -const { constants, defaultText, todoActions, todoStore } = helpers; -const { addTodo, addTodoAsync } = todoActions; -const { ADD_TODO } = constants; - -describe('createDispatcher', () => { - - it('should handle sync and async dispatches', done => { - const spy = expect.createSpy( - nextState => nextState - ).andCallThrough(); - - const dispatcher = createDispatcher( - composeStores({ todoStore }), - // we need this middleware to handle async actions - getState => [thunkMiddleware(getState)] - ); - - expect(dispatcher).toBeA('function'); - - const dispatchFn = dispatcher(undefined, spy); - expect(spy.calls.length).toBe(1); - expect(spy).toHaveBeenCalledWith({ todoStore: [] }); - - const addTodoAction = dispatchFn(addTodo(defaultText)); - expect(addTodoAction).toEqual({ type: ADD_TODO, text: defaultText }); - expect(spy.calls.length).toBe(2); - expect(spy).toHaveBeenCalledWith({ todoStore: [ - { id: 1, text: defaultText } - ] }); - - dispatchFn(addTodoAsync(('Say hi!'), () => { - expect(spy.calls.length).toBe(3); - expect(spy).toHaveBeenCalledWith({ todoStore: [ - { id: 2, text: 'Say hi!' }, - { id: 1, text: defaultText } - ] }); - done(); - })); - }); -}); diff --git a/test/createRedux.spec.js b/test/createRedux.spec.js deleted file mode 100644 index 1900de351c2..00000000000 --- a/test/createRedux.spec.js +++ /dev/null @@ -1,72 +0,0 @@ -import expect from 'expect'; -import { createRedux } from '../src'; -import * as helpers from './_helpers'; - -const { defaultText, todoActions, todoStore } = helpers; -const { addTodo } = todoActions; - -describe('createRedux', () => { - - let redux; - - beforeEach(() => { - redux = createRedux({ todoStore }); - }); - - it('should expose Redux public API', () => { - const methods = Object.keys(redux); - - expect(methods.length).toBe(5); - expect(methods).toContain('subscribe'); - expect(methods).toContain('dispatch'); - expect(methods).toContain('getState'); - expect(methods).toContain('getDispatcher'); - expect(methods).toContain('replaceDispatcher'); - }); - - it('should subscribe to changes', done => { - let state = redux.getState(); - expect(state.todoStore).toEqual({}); - redux.subscribe(() => { - state = redux.getState(); - expect(state.todoStore).toEqual([{ id: 1, text: 'Hello World!' }]); - done(); - }); - redux.dispatch(addTodo(defaultText)); - }); - - it('should unsubscribe a listener', () => { - const changeListenerSpy = expect.createSpy(() => {}); - const unsubscribe = redux.subscribe(changeListenerSpy); - - expect(changeListenerSpy.calls.length).toBe(0); - - redux.dispatch(addTodo('Hello')); - expect(redux.getState().todoStore).toEqual([{ id: 1, text: 'Hello'}]); - expect(changeListenerSpy.calls.length).toBe(1); - - unsubscribe(); - redux.dispatch(addTodo('World')); - expect(redux.getState().todoStore).toEqual([ - { id: 2, text: 'World'}, - { id: 1, text: 'Hello'} - ]); - expect(changeListenerSpy.calls.length).toBe(1); - }); - - it('should use existing state when replacing the dispatcher', () => { - redux.dispatch(addTodo('Hello')); - - let nextRedux = createRedux({ todoStore }); - redux.replaceDispatcher(nextRedux.getDispatcher()); - - let state; - let action = (_, getState) => { - state = getState().todoStore; - }; - - redux.dispatch(action); - - expect(state).toEqual(redux.getState().todoStore); - }); -}); diff --git a/test/createStore.spec.js b/test/createStore.spec.js new file mode 100644 index 00000000000..70ba29e3927 --- /dev/null +++ b/test/createStore.spec.js @@ -0,0 +1,123 @@ +import expect from 'expect'; +import { createStore } from '../src/index'; +import * as reducers from './helpers/reducers'; +import { addTodo, addTodoIfEmpty, addTodoAsync } from './helpers/actionCreators'; + +describe('createStore', () => { + it('should expose the public API', () => { + const store = createStore(reducers); + const methods = Object.keys(store); + + expect(methods.length).toBe(5); + expect(methods).toContain('subscribe'); + expect(methods).toContain('dispatch'); + expect(methods).toContain('getState'); + expect(methods).toContain('getReducer'); + expect(methods).toContain('replaceReducer'); + }); + + it('should compose the reducers when passed an object', () => { + const store = createStore(reducers); + expect(store.getState()).toEqual({ + todos: [], + todosReverse: [] + }); + }); + + it('should pass the initial action and the initial state', () => { + const store = createStore(reducers.todos, [{ + id: 1, + text: 'Hello' + }]); + expect(store.getState()).toEqual([{ + id: 1, + text: 'Hello' + }]); + }); + + it('should provide the thunk middleware by default', done => { + const store = createStore(reducers.todos); + store.dispatch(addTodoIfEmpty('Hello')); + expect(store.getState()).toEqual([{ + id: 1, + text: 'Hello' + }]); + + store.dispatch(addTodoIfEmpty('Hello')); + expect(store.getState()).toEqual([{ + id: 1, + text: 'Hello' + }]); + + store.dispatch(addTodo('World')); + expect(store.getState()).toEqual([{ + id: 1, + text: 'Hello' + }, { + id: 2, + text: 'World' + }]); + + store.dispatch(addTodoAsync('Maybe')).then(() => { + expect(store.getState()).toEqual([{ + id: 1, + text: 'Hello' + }, { + id: 2, + text: 'World' + }, { + id: 3, + text: 'Maybe' + }]); + done(); + }); + }); + + it('should dispatch the raw action without the middleware', () => { + const store = createStore(reducers.todos, undefined, []); + store.dispatch(addTodo('Hello')); + expect(store.getState()).toEqual([{ + id: 1, + text: 'Hello' + }]); + + expect(() => + store.dispatch(addTodoAsync('World')) + ).toThrow(/plain/); + }); + + it('should support custom dumb middleware', done => { + const doneMiddleware = next => action => { + next(action); + done(); + }; + + const store = createStore( + reducers.todos, + undefined, + [doneMiddleware] + ); + store.dispatch(addTodo('Hello')); + }); + + it('should support custom smart middleware', done => { + function doneMiddleware({ getState, dispatch }) { + return next => action => { + next(action); + + if (getState().length < 10) { + dispatch(action); + } else { + done(); + } + }; + } + + const store = createStore( + reducers.todos, + undefined, + ({ getState, dispatch }) => [doneMiddleware({ getState, dispatch })] + ); + store.dispatch(addTodo('Hello')); + }); +}); diff --git a/test/getDisplayName.spec.js b/test/getDisplayName.spec.js index 8b18e0dad2a..a3a712d3456 100644 --- a/test/getDisplayName.spec.js +++ b/test/getDisplayName.spec.js @@ -1,14 +1,14 @@ import expect from 'expect'; +import { createClass, Component } from 'react'; import getDisplayName from '../src/utils/getDisplayName'; describe('Utils', () => { describe('getDisplayName', () => { - - it('should ensure a name for the given component', () => { + it('should extract the component class name', () => { const names = [ - { displayName: 'Foo'}, - { name: 'Bar' }, - {} + createClass({ displayName: 'Foo', render() {} }), + class Bar extends Component {}, + createClass({ render() {} }) ].map(getDisplayName); expect(names).toEqual(['Foo', 'Bar', 'Component']); diff --git a/test/helpers/actionCreators.js b/test/helpers/actionCreators.js new file mode 100644 index 00000000000..e39dee423ba --- /dev/null +++ b/test/helpers/actionCreators.js @@ -0,0 +1,20 @@ +import { ADD_TODO } from './actionTypes'; + +export function addTodo(text) { + return { type: ADD_TODO, text }; +} + +export function addTodoAsync(text) { + return dispatch => new Promise(resolve => setImmediate(() => { + dispatch(addTodo(text)); + resolve(); + })); +} + +export function addTodoIfEmpty(text) { + return (dispatch, getState) => { + if (!getState().length) { + dispatch(addTodo(text)); + } + }; +} diff --git a/test/helpers/actionTypes.js b/test/helpers/actionTypes.js new file mode 100644 index 00000000000..1d5bfa67c9d --- /dev/null +++ b/test/helpers/actionTypes.js @@ -0,0 +1 @@ +export const ADD_TODO = 'ADD_TODO'; diff --git a/test/helpers/reducers.js b/test/helpers/reducers.js new file mode 100644 index 00000000000..08eab9893bf --- /dev/null +++ b/test/helpers/reducers.js @@ -0,0 +1,25 @@ +import { ADD_TODO } from './actionTypes'; + +export function todos(state = [], action) { + switch (action.type) { + case ADD_TODO: + return [...state, { + id: state.length ? state[state.length - 1].id + 1 : 1, + text: action.text + }]; + default: + return state; + } +} + +export function todosReverse(state = [], action) { + switch (action.type) { + case ADD_TODO: + return [{ + id: state.length ? state[0].id + 1 : 1, + text: action.text + }, ...state]; + default: + return state; + } +} diff --git a/test/utils/bindActionCreators.spec.js b/test/utils/bindActionCreators.spec.js index a4acd6840d5..cfe023dd76c 100644 --- a/test/utils/bindActionCreators.spec.js +++ b/test/utils/bindActionCreators.spec.js @@ -1,37 +1,31 @@ import expect from 'expect'; -import { bindActionCreators, createRedux } from '../../src'; -import * as helpers from '../_helpers'; - -const { todoActions, todoStore } = helpers; +import { bindActionCreators, createStore } from '../../src'; +import { todos } from '../helpers/reducers'; +import * as actionCreators from '../helpers/actionCreators'; describe('Utils', () => { describe('bindActionCreators', () => { - - let redux; + let store; beforeEach(() => { - redux = createRedux({ todoStore }); + store = createStore(todos); }); - it('should bind given actions to the dispatcher', done => { - let expectedCallCount = 2; - // just for monitoring the dispatched actions - redux.subscribe(() => { - expectedCallCount--; - if (expectedCallCount === 0) { - const state = redux.getState(); - expect(state.todoStore).toEqual([ - { id: 2, text: 'World' }, - { id: 1, text: 'Hello' } - ]); - done(); - } - }); - const actions = bindActionCreators(todoActions, redux.dispatch); - expect(Object.keys(actions)).toEqual(Object.keys(todoActions)); + it('should wrap the action creators with the dispatch function', () => { + const boundActionCreators = bindActionCreators(actionCreators, store.dispatch); + expect( + Object.keys(boundActionCreators) + ).toEqual( + Object.keys(actionCreators) + ); - actions.addTodo('Hello'); - actions.addTodoAsync('World'); + const action = boundActionCreators.addTodo('Hello'); + expect(action).toEqual( + actionCreators.addTodo('Hello') + ); + expect(store.getState()).toEqual([ + { id: 1, text: 'Hello' } + ]); }); }); }); diff --git a/test/utils/identity.spec.js b/test/utils/identity.spec.js index 87783b8117e..ef48e5593c1 100644 --- a/test/utils/identity.spec.js +++ b/test/utils/identity.spec.js @@ -3,8 +3,8 @@ import identity from '../../src/utils/identity'; describe('Utils', () => { describe('identity', () => { - it('should return first argument passed to it', () => { - const test = { 'a': 1 }; + it('should return the first argument passed to it', () => { + const test = { a: 1 }; expect(identity(test, 'test')).toBe(test); }); }); diff --git a/test/utils/mapValues.spec.js b/test/utils/mapValues.spec.js index 36c3c6b2c9c..6f7945aa118 100644 --- a/test/utils/mapValues.spec.js +++ b/test/utils/mapValues.spec.js @@ -4,8 +4,14 @@ import mapValues from '../../src/utils/mapValues'; describe('Utils', () => { describe('mapValues', () => { it('should return object with mapped values', () => { - const test = { 'a': 'c', 'b': 'd' }; - expect(mapValues(test, (val, key) => val + key)).toEqual({ 'a': 'ca', 'b': 'db' }); + const test = { + a: 'c', + b: 'd' + }; + expect(mapValues(test, (val, key) => val + key)).toEqual({ + a: 'ca', + b: 'db' + }); }); }); }); diff --git a/test/utils/pick.spec.js b/test/utils/pick.spec.js index 6cd95f7395f..c7b5a71e847 100644 --- a/test/utils/pick.spec.js +++ b/test/utils/pick.spec.js @@ -4,8 +4,15 @@ import pick from '../../src/utils/pick'; describe('Utils', () => { describe('pick', () => { it('should return object with picked values', () => { - const test = { 'name': 'lily', 'age': 20 }; - expect(pick(test, x => typeof x === 'string')).toEqual({ 'name': 'lily' }); + const test = { + name: 'lily', + age: 20 + }; + expect( + pick(test, x => typeof x === 'string') + ).toEqual({ + name: 'lily' + }); }); }); }); diff --git a/test/utils/shallowEquality.spec.js b/test/utils/shallowEquality.spec.js index 8ba48e6bdd0..7146185bfb2 100644 --- a/test/utils/shallowEquality.spec.js +++ b/test/utils/shallowEquality.spec.js @@ -5,17 +5,17 @@ import shallowEqual from '../../src/utils/shallowEqual'; describe('Utils', () => { // More info: https://github.com/gaearon/redux/pull/75#issuecomment-111635748 describe('shallowEqualScalar', () => { - it('returns true if both arguments are the same object', () => { + it('should return true if both arguments are the same object', () => { const o = { a: 1, b: 2 }; expect(shallowEqualScalar(o, o)).toBe(true); }); - it('returns false if either argument is null', () => { + it('should return false if either argument is null', () => { expect(shallowEqualScalar(null, {})).toBe(false); expect(shallowEqualScalar({}, null)).toBe(false); }); - it('returns true if arguments fields are equal', () => { + it('should return true if arguments fields are equal', () => { expect( shallowEqualScalar( { a: 1, b: 2, c: undefined }, @@ -31,7 +31,7 @@ describe('Utils', () => { ).toBe(true); }); - it('returns false if first argument has too many keys', () => { + it('should return false if first argument has too many keys', () => { expect( shallowEqualScalar( { a: 1, b: 2, c: 3 }, @@ -40,7 +40,7 @@ describe('Utils', () => { ).toBe(false); }); - it('returns false if second argument has too many keys', () => { + it('should return false if second argument has too many keys', () => { expect( shallowEqualScalar( { a: 1, b: 2 }, @@ -49,7 +49,7 @@ describe('Utils', () => { ).toBe(false); }); - it('returns false if arguments have keys dont have same value', () => { + it('should return false if arguments have keys dont have same value', () => { expect( shallowEqualScalar( { a: 1, b: 2 }, @@ -58,7 +58,7 @@ describe('Utils', () => { ).toBe(false); }); - it('returns false if arguments have field that are objects', () => { + it('should return false if arguments have field that are objects', () => { const o = {}; expect( shallowEqualScalar( @@ -68,7 +68,7 @@ describe('Utils', () => { ).toBe(false); }); - it('returns false if arguments have different keys', () => { + it('should return false if arguments have different keys', () => { expect( shallowEqualScalar( { a: 1, b: 2, c: undefined }, @@ -79,7 +79,7 @@ describe('Utils', () => { }); describe('shallowEqual', () => { - it('returns true if arguments fields are equal', () => { + it('should return true if arguments fields are equal', () => { expect( shallowEqual( { a: 1, b: 2, c: undefined }, @@ -103,7 +103,7 @@ describe('Utils', () => { ).toBe(true); }); - it('returns false if first argument has too many keys', () => { + it('should return false if first argument has too many keys', () => { expect( shallowEqual( { a: 1, b: 2, c: 3 }, @@ -112,7 +112,7 @@ describe('Utils', () => { ).toBe(false); }); - it('returns false if second argument has too many keys', () => { + it('should return false if second argument has too many keys', () => { expect( shallowEqual( { a: 1, b: 2 }, @@ -121,7 +121,7 @@ describe('Utils', () => { ).toBe(false); }); - it('returns false if arguments have different keys', () => { + it('should return false if arguments have different keys', () => { expect( shallowEqual( { a: 1, b: 2, c: undefined }, @@ -130,5 +130,4 @@ describe('Utils', () => { ).toBe(false); }); }); - });