├── .npmignore ├── .eslintignore ├── .gitignore ├── test ├── render.js ├── render-to-string.js ├── combine-epics.js ├── contain.js └── create-epic.js ├── src ├── index.js ├── of-type.js ├── combine-epics.js ├── render.js ├── render-to-string.js ├── contain.js └── create-epic.js ├── .travis.yml ├── .babelrc ├── CHANGELOG.md ├── package.json ├── docs └── api │ └── README.md ├── .eslintrc └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | .tern-project 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib/*.js 2 | coverage 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib/* 2 | node_modules 3 | .nyc_output 4 | coverage 5 | .tern-project 6 | -------------------------------------------------------------------------------- /test/render.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { render } from '../src'; 3 | 4 | test('render', t => { 5 | t.is( 6 | typeof render, 7 | 'function', 8 | 'render is a function' 9 | ); 10 | t.pass('no tests yet'); 11 | }); 12 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export contain from './contain'; 2 | export createEpic from './create-epic'; 3 | export renderToString from './render-to-string'; 4 | export render from './render'; 5 | export combineEpics from './combine-epics'; 6 | export ofType from './of-type.js'; 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | 4 | node_js: 5 | - '6' 6 | 7 | cache: 8 | directories: 9 | - node_modules 10 | 11 | before_install: if [[ `npm -v` != 3* ]]; then npm i -g npm@3; fi 12 | 13 | after_success: 14 | npm run cover:alls 15 | -------------------------------------------------------------------------------- /test/render-to-string.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { renderToString } from '../src'; 3 | 4 | test('renderToString', t => { 5 | t.is( 6 | typeof renderToString, 7 | 'function', 8 | 'renderToString is a function' 9 | ); 10 | t.pass('no tests yet'); 11 | }); 12 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react", 5 | "stage-0" 6 | ], 7 | "env": { 8 | "test": { 9 | "sourceMaps": "inline", 10 | "plugins": [ "istanbul" ] 11 | }, 12 | "production": { 13 | "plugins": [ 14 | "babel-plugin-dev-expression" 15 | ] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/of-type.js: -------------------------------------------------------------------------------- 1 | export default function ofType(...keys) { 2 | return this.filter(({ type }) => { 3 | const len = keys.length; 4 | if (len === 1) { 5 | return type === keys[0]; 6 | } else { 7 | for (let i = 0; i < len; i++) { 8 | if (keys[i] === type) { 9 | return true; 10 | } 11 | } 12 | } 13 | return false; 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # [0.3.0](https://github.com/BerkeleyTrue/redux-epic/compare/v0.2.0...v0.3.0) (2017-05-05) 3 | 4 | 5 | ### Features 6 | 7 | * **combineEpics:** Adds new combineEpics ([946febf](https://github.com/BerkeleyTrue/redux-epic/commit/946febf)) 8 | 9 | 10 | ### BREAKING CHANGES 11 | 12 | * **combineEpics:** Removes ofType method from actions stream 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/combine-epics.js: -------------------------------------------------------------------------------- 1 | // source 2 | // github.com/redux-observable/redux-observable/blob/master/src/combineEpics.js 3 | import { Observable } from 'rx'; 4 | 5 | export default function combineEpics(...epics) { 6 | return (...args) => Observable.merge(...epics.map(epic => { 7 | const output = epic(...args); 8 | if (!output) { 9 | throw new TypeError(` 10 | combineEpics: one of the provided Epics 11 | "${epic.name || ''}" does not return a stream. 12 | Double check you're not missing a return statement! 13 | `); 14 | } 15 | return output; 16 | })); 17 | } 18 | -------------------------------------------------------------------------------- /src/render.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import { Disposable, Observable } from 'rx'; 3 | 4 | // render( 5 | // Component: ReactComponent, 6 | // DomContainer: DOMNode 7 | // ) => Observable[RootInstance] 8 | 9 | export default function render(Component, DOMContainer) { 10 | return Observable.create(observer => { 11 | try { 12 | ReactDOM.render(Component, DOMContainer, function() { 13 | observer.onNext(this); 14 | }); 15 | } catch (e) { 16 | return observer.onError(e); 17 | } 18 | 19 | return Disposable.create(() => { 20 | return ReactDOM.unmountComponentAtNode(DOMContainer); 21 | }); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/render-to-string.js: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rx'; 2 | import ReactDOM from 'react-dom/server'; 3 | import debug from 'debug'; 4 | 5 | const log = debug('redux-epic:renderToString'); 6 | 7 | // renderToString( 8 | // Component: ReactComponent, 9 | // epicMiddleware: EpicMiddleware 10 | // ) => Observable[String] 11 | 12 | export default function renderToString(Component, epicMiddleware) { 13 | try { 14 | log('initial render pass started'); 15 | ReactDOM.renderToStaticMarkup(Component); 16 | log('initial render pass completed'); 17 | } catch (e) { 18 | return Observable.throw(e); 19 | } 20 | log('calling action$ onCompleted'); 21 | epicMiddleware.end(); 22 | return Observable.merge(epicMiddleware) 23 | .last({ defaultValue: null }) 24 | .map(() => { 25 | epicMiddleware.restart(); 26 | const markup = ReactDOM.renderToString(Component); 27 | return { markup }; 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /test/combine-epics.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { Observable, Subject } from 'rx'; 4 | 5 | import { combineEpics, ofType } from '../src'; 6 | 7 | test('should combine epics', t => { 8 | const epic1 = (actions, store) => actions::ofType('ACTION1') 9 | .map(action => ({ type: 'DELEGATED1', action, store })); 10 | const epic2 = (actions, store) => actions::ofType('ACTION2') 11 | .map(action => ({ type: 'DELEGATED2', action, store })); 12 | 13 | const epic = combineEpics(epic1, epic2); 14 | const store = { I: 'am', a: 'store' }; 15 | const actions = new Subject(); 16 | const result = epic(actions, store); 17 | const emittedActions = []; 18 | 19 | result.subscribe(emittedAction => emittedActions.push(emittedAction)); 20 | 21 | actions.onNext({ type: 'ACTION1' }); 22 | actions.onNext({ type: 'ACTION2' }); 23 | 24 | t.deepEqual( 25 | emittedActions, 26 | [ 27 | { type: 'DELEGATED1', action: { type: 'ACTION1' }, store }, 28 | { type: 'DELEGATED2', action: { type: 'ACTION2' }, store } 29 | ] 30 | ); 31 | }); 32 | 33 | test('should pass along every argument arbitrarily', t => { 34 | const epic1 = sinon.stub().returns(Observable.of('first')); 35 | const epic2 = sinon.stub().returns(Observable.of('second')); 36 | 37 | const rootEpic = combineEpics(epic1, epic2); 38 | // ava does not support rxjsv4 39 | return rootEpic(1, 2, 3, 4) 40 | .toArray() 41 | .subscribe(values => { 42 | console.log('foo: ', values); 43 | t.deepEqual(values, ['first', 'second']); 44 | t.is(epic1.callCount, 1); 45 | t.is(epic2.callCount, 1); 46 | 47 | t.deepEqual(epic1.firstCall.args, [1, 2, 3, 4]); 48 | t.deepEqual(epic2.firstCall.args, [1, 2, 3, 4]); 49 | }); 50 | }); 51 | 52 | test( 53 | 'should errors if epic doesn\'t return anything', t => { 54 | const epic1 = () => []; 55 | const epic2 = () => {}; 56 | const rootEpic = combineEpics(epic1, epic2); 57 | 58 | t.throws( 59 | () => rootEpic(), 60 | /does not return a stream/i 61 | ); 62 | }); 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-epic", 3 | "version": "0.3.0", 4 | "description": "Better Redux Async and Prefetching", 5 | "main": "lib/index.js", 6 | "directories": { 7 | "tests": "tests", 8 | "lib": "lib", 9 | "src": "src" 10 | }, 11 | "scripts": { 12 | "lint": "eslint .", 13 | "pretest": "npm run lint", 14 | "test": "BABEL_ENV=test nyc ava", 15 | "test:watch": "BABEL_ENV=test nyc ava --watch --fail-fast", 16 | "cover": "nyc report --reporter=html", 17 | "cover:alls": "nyc report --reporter=text-lcov | coveralls", 18 | "prebuild": "npm run test", 19 | "build": "BABEL_ENV=production babel src --out-dir lib", 20 | "prepublish": "npm run build" 21 | }, 22 | "nyc": { 23 | "sourceMap": false, 24 | "instrument": false, 25 | "include": [ 26 | "src/*.js" 27 | ] 28 | }, 29 | "ava": { 30 | "files": [ 31 | "test/*.js" 32 | ], 33 | "require": [ 34 | "babel-register" 35 | ], 36 | "babel": "inherit" 37 | }, 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/BerkeleyTrue/redux-epic.git" 41 | }, 42 | "keywords": [ 43 | "redux", 44 | "react", 45 | "asynx", 46 | "rx", 47 | "rxjs", 48 | "saga", 49 | "middleware" 50 | ], 51 | "author": "Berkeley Martinez (http://RoboTie.com)", 52 | "license": "MIT", 53 | "bugs": { 54 | "url": "https://github.com/BerkeleyTrue/redux-epic/issues" 55 | }, 56 | "homepage": "https://github.com/BerkeleyTrue/redux-epic#readme", 57 | "devDependencies": { 58 | "ava": "^0.22.0", 59 | "babel-cli": "^6.7.7", 60 | "babel-core": "^6.7.7", 61 | "babel-eslint": "^7.2.2", 62 | "babel-plugin-dev-expression": "^0.2.1", 63 | "babel-plugin-istanbul": "^4.1.1", 64 | "babel-preset-es2015": "^6.6.0", 65 | "babel-preset-react": "^6.5.0", 66 | "babel-preset-stage-0": "^6.5.0", 67 | "babel-register": "^6.7.2", 68 | "babel-runtime": "^6.6.1", 69 | "coveralls": "^2.11.9", 70 | "enzyme": "^2.3.0", 71 | "eslint": "^3.0.0", 72 | "eslint-plugin-react": "^7.0.0", 73 | "nyc": "^11.0.2", 74 | "react-addons-test-utils": "^15.2.0", 75 | "redux": "^3.5.2", 76 | "sinon": "^3.0.0" 77 | }, 78 | "dependencies": { 79 | "debug": "^3.0.0", 80 | "invariant": "^2.2.1", 81 | "react": "^15.0.0 || ^0.14.3", 82 | "react-addons-shallow-compare": "^15.0.1 || ^0.14.3", 83 | "react-dom": "^15.0.0 || ^0.14.3", 84 | "rx": "^4.1.0", 85 | "warning": "^3.0.0" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /docs/api/README.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | This document uses [rtype](https://github.com/ericelliott/rtype) for type signatures. 4 | 5 | ## createEpic 6 | 7 | Creates an epic middleware to be passed into Redux createStore 8 | 9 | ```js 10 | Epic( 11 | actions: Observable[ ...Action ], 12 | { 13 | getState: () => Object 14 | }, 15 | dependencies: Object 16 | ) => Observable[ ...Any ] 17 | 18 | interface EpicMiddleware { 19 | ({ 20 | dispatch: Function, 21 | getState: Function 22 | }) => ( (next: Function) => (action: Action) => Action ), 23 | // used to dispose epics 24 | dispose() => Void 25 | } 26 | 27 | interface createEpic { 28 | (dependencies: Object, ...epics: [ Epic... ]) => EpicMiddleware 29 | (...epics: [ Epic... ]) => EpicMiddleware 30 | } 31 | ``` 32 | 33 | ## Contain 34 | 35 | Creates a [Hgher Order Component (HOC)](https://medium.com/@dan_abramov/mixins-are-dead-long-live-higher-order-components-94a0d2f9e750#.qoukwp2kc) 36 | around your React Component. This can be combined with Redux's `connect` HOC. 37 | 38 | ```js 39 | interface Options { 40 | fetchAction?: ActionCreator, 41 | getActionArgs?(props: Object, context: Object) => [ ...Any ], 42 | isPrimed?(props: Object, context: Object) => Boolean, 43 | shouldRefetch?( 44 | props: Object, 45 | nextProps: Object, 46 | context: Object, 47 | nextContext: Object 48 | ) => Boolean, 49 | } 50 | 51 | interface contain { 52 | (options?: Options, Component: ReactComponent) => ReactComponent 53 | (options?: Object) => (Component: ReactComponent) => ReactComponent 54 | } 55 | ``` 56 | A simple example: 57 | 58 | ```js 59 | import React from 'react'; 60 | import { connect } from 'react-redux'; 61 | import { contain } from 'redux-epic'; 62 | 63 | class ShowUser extends React.Component { 64 | render() { 65 | const { user = {} } = this.props; 66 | return ( 67 |

UserName: { user.name }

68 | ); 69 | } 70 | } 71 | 72 | const containComponent = contain({ 73 | // this is the action we want the 74 | // container to call when this component 75 | // is going to be mounted 76 | fetchAction: 'fetchUser', 77 | // these are the arguments to call the action creator 78 | getActionArgs(props) { 79 | return [ props.params.userId ]; 80 | } 81 | }); 82 | 83 | const connectComponent = connect( 84 | state => ({ user: state.user }), // 85 | { fetchUser: userId => ({ type: 'FETCH_USER', payload: userId }) } 86 | ); 87 | 88 | // connect will provide the data from state and the binded actionCreator 89 | // contain will handle data fetching 90 | export default connectComponent(containComponent(ShowUser)); 91 | ``` 92 | 93 | 94 | ## render-to-string 95 | 96 | Used when you want your server-side rendered app to be fully populated. 97 | 98 | Ensures all the stores are populated before running React's renderToString internally. 99 | This will end the actions$ observable and wait for 100 | all of the epics to complete. 101 | 102 | ```js 103 | renderToString(Component: ReactComponent, epicMiddleware: EpicMiddleware) => Observable[String] 104 | ``` 105 | 106 | ## render 107 | 108 | 109 | Optional: Wraps `react-doms` render method in an observable. 110 | 111 | ```js 112 | render(Component: ReactComponent, DomContainer: DOMNode) => Observable[ RootInstance ] 113 | ``` 114 | -------------------------------------------------------------------------------- /src/contain.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { helpers } from 'rx'; 3 | import { createElement } from 'react'; 4 | import shallowCompare from 'react-addons-shallow-compare'; 5 | import debug from 'debug'; 6 | import invariant from 'invariant'; 7 | 8 | // // Using rtype signatures 9 | // interface Action { 10 | // type: String, 11 | // payload?: Any, 12 | // ...meta?: Object 13 | // } 14 | // 15 | // ActionCreator(...args?) => Action 16 | // 17 | // interface Options { 18 | // fetchAction?: ActionCreator, 19 | // getActionArgs?(props: Object, context: Object) => [], 20 | // isPrimed?(props: Object, context: Object) => Boolean, 21 | // shouldRefetch?( 22 | // props: Object, 23 | // nextProps: Object, 24 | // context: Object, 25 | // nextContext: Object 26 | // ) => Boolean, 27 | // } 28 | // 29 | // interface contain { 30 | // (options?: Options, Component: ReactComponent) => ReactComponent 31 | // (options?: Object) => (Component: ReactComponent) => ReactComponent 32 | // } 33 | 34 | 35 | const log = debug('redux-epic:contain'); 36 | const { isFunction } = helpers; 37 | 38 | export default function contain(options = {}) { 39 | return Component => { 40 | const name = Component.displayName || 'Anon Component'; 41 | let action; 42 | let isActionable = false; 43 | let hasRefetcher = isFunction(options.shouldRefetch); 44 | const getActionArgs = isFunction(options.getActionArgs) ? 45 | options.getActionArgs : 46 | (() => []); 47 | 48 | const isPrimed = isFunction(options.isPrimed) ? 49 | options.isPrimed : 50 | (() => false); 51 | 52 | function runAction(props, context, action) { 53 | const actionArgs = getActionArgs(props, context); 54 | invariant( 55 | Array.isArray(actionArgs), 56 | ` 57 | ${name} getActionArgs should always return an array 58 | but got ${actionArgs}. check the render method of ${name} 59 | ` 60 | ); 61 | return action.apply(null, actionArgs); 62 | } 63 | 64 | 65 | return class Container extends React.Component { 66 | static displayName = `Container(${name})`; 67 | 68 | componentWillMount() { 69 | const { props, context } = this; 70 | const fetchAction = options.fetchAction; 71 | if (!options.fetchAction) { 72 | log(`Contain(${name}) has no fetch action defined`); 73 | return; 74 | } 75 | if (isPrimed(this.props, this.context)) { 76 | log(`contain(${name}) is primed`); 77 | return; 78 | } 79 | 80 | action = props[options.fetchAction]; 81 | isActionable = typeof action === 'function'; 82 | 83 | invariant( 84 | isActionable, 85 | ` 86 | ${fetchAction} should be a function on Contain(${name})'s props 87 | but found ${action}. Check the fetch options for ${name}. 88 | ` 89 | ); 90 | 91 | runAction( 92 | props, 93 | context, 94 | action 95 | ); 96 | } 97 | 98 | componentWillReceiveProps(nextProps, nextContext) { 99 | if ( 100 | !isActionable || 101 | !hasRefetcher || 102 | !options.shouldRefetch( 103 | this.props, 104 | nextProps, 105 | this.context, 106 | nextContext 107 | ) 108 | ) { 109 | return; 110 | } 111 | 112 | runAction( 113 | nextProps, 114 | nextContext, 115 | action 116 | ); 117 | } 118 | 119 | shouldComponentUpdate(nextProps, nextState) { 120 | return shallowCompare(this, nextProps, nextState); 121 | } 122 | 123 | render() { 124 | return createElement( 125 | Component, 126 | this.props 127 | ); 128 | } 129 | }; 130 | }; 131 | } 132 | -------------------------------------------------------------------------------- /test/contain.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-multi-comp */ 2 | import test from 'ava'; 3 | import sinon from 'sinon'; 4 | import React from 'react'; 5 | import { shallow } from 'enzyme'; 6 | import { contain } from '../src'; 7 | 8 | test('contain', t => { 9 | t.is( 10 | typeof contain, 11 | 'function', 12 | 'contain is a function' 13 | ); 14 | }); 15 | 16 | test('contain() should return a function', t => { 17 | t.is( 18 | typeof contain(), 19 | 'function' 20 | ); 21 | t.is( 22 | typeof contain({}), 23 | 'function' 24 | ); 25 | }); 26 | 27 | test('contain should wrap a component', t => { 28 | class Test extends React.Component { 29 | render() { 30 | return

test

; 31 | } 32 | } 33 | Test.displayName = 'Test'; 34 | 35 | const Contained = contain({})(Test); 36 | t.is(typeof Contained, 'function'); 37 | t.is(typeof Contained.prototype.render, 'function'); 38 | t.not(Contained.prototype.render, Test.prototype.render); 39 | const wrapper = shallow(); 40 | t.truthy(wrapper.find('Test')); 41 | }); 42 | 43 | test('container should call fetch action when defined', t => { 44 | const testActionCreator = sinon.spy(); 45 | const options = { fetchAction: 'testActionCreator' }; 46 | class Test extends React.Component { 47 | render() { 48 | return

test

; 49 | } 50 | } 51 | Test.displayName = 'Test'; 52 | 53 | const Contained = contain(options)(Test); 54 | shallow( 55 | 56 | ); 57 | t.truthy(testActionCreator.calledOnce); 58 | }); 59 | 60 | test('container should throw if fetchAction is undefined', t => { 61 | const options = { fetchAction: 'testActionCreator' }; 62 | class Test extends React.Component { 63 | render() { 64 | return

test

; 65 | } 66 | } 67 | Test.displayName = 'Test'; 68 | 69 | const Contained = contain(options)(Test); 70 | t.throws( 71 | () => shallow(), 72 | /should be a function on Contain/ 73 | ); 74 | }); 75 | 76 | test('container should not fetch if primed', t => { 77 | const fetch = sinon.spy(); 78 | const isPrimed = sinon.spy(() => true); 79 | const options = { 80 | fetchAction: 'fetch', 81 | isPrimed 82 | }; 83 | class Test extends React.Component { 84 | render() { 85 | return

test

; 86 | } 87 | } 88 | Test.displayName = 'Test'; 89 | 90 | const Contained = contain(options)(Test); 91 | shallow(); 92 | t.false(fetch.called); 93 | t.true(isPrimed.calledOnce); 94 | }); 95 | 96 | test('container should throw if getActionArgs does not return an array', t => { 97 | const fetch = sinon.spy(); 98 | const getActionArgs = sinon.spy(() => {}); 99 | const options = { 100 | fetchAction: 'fetch', 101 | getActionArgs 102 | }; 103 | class Test extends React.Component { 104 | render() { 105 | return

test

; 106 | } 107 | } 108 | Test.displayName = 'Test'; 109 | 110 | const Contained = contain(options)(Test); 111 | t.throws( 112 | () => shallow(), 113 | /getActionArgs should always return an array/ 114 | ); 115 | }); 116 | 117 | test('container should call action creator with args', t => { 118 | const fetch = sinon.spy(); 119 | const foo = 'foo'; 120 | const getActionArgs = sinon.spy(() => [foo]); 121 | const options = { 122 | fetchAction: 'fetch', 123 | getActionArgs 124 | }; 125 | class Test extends React.Component { 126 | render() { 127 | return

test

; 128 | } 129 | } 130 | Test.displayName = 'Test'; 131 | 132 | const Contained = contain(options)(Test); 133 | shallow(); 134 | t.true(fetch.calledWith(foo)); 135 | }); 136 | 137 | test('container should call shouldRefetch', t => { 138 | const fetch = sinon.spy(); 139 | const shouldRefetch = sinon.spy(() => true); 140 | const options = { 141 | fetchAction: 'fetch', 142 | shouldRefetch 143 | }; 144 | class Test extends React.Component { 145 | render() { 146 | return

test

; 147 | } 148 | } 149 | Test.displayName = 'Test'; 150 | 151 | const Contained = contain(options)(Test); 152 | const rootWrapper = shallow(); 153 | t.true(fetch.calledOnce); 154 | rootWrapper.setProps({}); 155 | t.true(fetch.calledTwice); 156 | }); 157 | -------------------------------------------------------------------------------- /src/create-epic.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import warning from 'warning'; 3 | import { CompositeDisposable, Observable, Subject } from 'rx'; 4 | 5 | function addOutputWarning(source, name) { 6 | let actionsOutputWarned = false; 7 | return source.do(action => { 8 | warning( 9 | actionsOutputWarned || action && typeof action.type === 'string', 10 | ` 11 | Future versions of redux-epic will pass all items to the dispatch 12 | function. 13 | Make sure you intented to pass ${action} to the dispatch or you 14 | filter out non-action elements at the individual epic level. 15 | Check the ${name} epic. 16 | ` 17 | ); 18 | actionsOutputWarned = !(action && typeof action.type === 'string'); 19 | }); 20 | } 21 | 22 | function createMockStore(store, name) { 23 | let mockStoreWarned = false; 24 | function mockStore() { 25 | warning( 26 | mockStoreWarned, 27 | ` 28 | The second argument to an epic is now a mock store, 29 | but it was called as a function. Pull the getState method off 30 | of the second argument of the epic instead. 31 | Check the ${name} epic. 32 | 33 | Epic type signature: 34 | epic( 35 | actions: Observable[...Action], 36 | { dispatch: Function, getState: Function } 37 | ) => Observable[...Action] 38 | ` 39 | ); 40 | mockStoreWarned = true; 41 | return store.getState(); 42 | } 43 | mockStore.getState = store.getState; 44 | mockStore.dispatch = store.dispatch; 45 | return mockStore; 46 | } 47 | // Epic( 48 | // actions: Observable[...Action], 49 | // getState: () => Object, 50 | // dependencies: Object 51 | // ) => Observable[...Action] 52 | // 53 | // interface EpicMiddleware { 54 | // ({ 55 | // dispatch: Function, 56 | // getState: Function 57 | // }) => next: Function => action: Action => Action, 58 | // // used to dispose sagas 59 | // dispose() => Void, 60 | // 61 | // // the following are internal methods 62 | // // they may change without warning 63 | // restart() => Void, 64 | // end() => Void, 65 | // subscribe() => Disposable, 66 | // subscribeOnCompleted() => Disposable, 67 | // 68 | // } 69 | // 70 | // createEpic( 71 | // dependencies: Object|Epic, 72 | // ...epics: [...Epics] 73 | // ) => EpicMiddleware 74 | 75 | export default function createEpic(dependencies, ...epics) { 76 | if (typeof dependencies === 'function') { 77 | epics.push(dependencies); 78 | dependencies = {}; 79 | } 80 | let actions; 81 | let lifecycle; 82 | let compositeDisposable; 83 | let start; 84 | function epicMiddleware(store) { 85 | const { dispatch } = store; 86 | 87 | start = () => { 88 | compositeDisposable = new CompositeDisposable(); 89 | actions = new Subject(); 90 | lifecycle = new Subject(); 91 | const epicSubscription = Observable 92 | .from(epics) 93 | // need to test for pass-through sagas 94 | .map(epic => { 95 | const name = epic.name || 'Anon Epic'; 96 | const result = epic( 97 | actions, 98 | createMockStore(store, name), 99 | dependencies 100 | ); 101 | invariant( 102 | Observable.isObservable(result), 103 | ` 104 | Epics should return an observable but got %s 105 | Check the ${name} epic 106 | `, 107 | result 108 | ); 109 | invariant( 110 | result !== actions, 111 | ` 112 | Epics should not be identity functions. 113 | Check the ${name} epic 114 | ` 115 | ); 116 | return addOutputWarning(result, name); 117 | }) 118 | .mergeAll() 119 | .filter(action => action && typeof action.type === 'string') 120 | .subscribe( 121 | action => dispatch(action), 122 | err => { throw err; }, 123 | () => lifecycle.onCompleted() 124 | ); 125 | compositeDisposable.add(epicSubscription); 126 | }; 127 | start(); 128 | return next => action => { 129 | const result = next(action); 130 | actions.onNext(action); 131 | return result; 132 | }; 133 | } 134 | 135 | epicMiddleware.subscribe = 136 | (...args) => lifecycle.subscribe.apply(lifecycle, args); 137 | epicMiddleware.subscribeOnCompleted = 138 | (...args) => lifecycle.subscribeOnCompleted.apply(lifecycle, args); 139 | epicMiddleware.end = () => actions.onCompleted(); 140 | epicMiddleware.dispose = () => compositeDisposable.dispose(); 141 | epicMiddleware.restart = () => { 142 | epicMiddleware.dispose(); 143 | actions.dispose(); 144 | start(); 145 | }; 146 | return epicMiddleware; 147 | } 148 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOption": { 3 | "ecmaVersion": 6, 4 | "ecmaFeatures": { 5 | "jsx": true 6 | } 7 | }, 8 | "env": { 9 | "node": true 10 | }, 11 | "parser": "babel-eslint", 12 | "plugins": [ 13 | "react" 14 | ], 15 | "globals": { 16 | }, 17 | "rules": { 18 | "comma-dangle": 2, 19 | "no-cond-assign": 2, 20 | "no-console": 0, 21 | "no-constant-condition": 2, 22 | "no-control-regex": 2, 23 | "no-debugger": 2, 24 | "no-dupe-keys": 2, 25 | "no-empty": 2, 26 | "no-empty-character-class": 2, 27 | "no-ex-assign": 2, 28 | "no-extra-boolean-cast": 2, 29 | "no-extra-parens": 0, 30 | "no-extra-semi": 2, 31 | "no-func-assign": 2, 32 | "no-inner-declarations": 2, 33 | "no-invalid-regexp": 2, 34 | "no-irregular-whitespace": 2, 35 | "no-negated-in-lhs": 2, 36 | "no-obj-calls": 2, 37 | "no-regex-spaces": 2, 38 | "no-reserved-keys": 0, 39 | "no-sparse-arrays": 2, 40 | "no-unreachable": 2, 41 | "use-isnan": 2, 42 | "valid-jsdoc": 2, 43 | "valid-typeof": 2, 44 | 45 | "block-scoped-var": 0, 46 | "complexity": 0, 47 | "consistent-return": 2, 48 | "curly": 2, 49 | "default-case": 1, 50 | "dot-notation": 0, 51 | "eqeqeq": 1, 52 | "guard-for-in": 1, 53 | "no-alert": 1, 54 | "no-caller": 2, 55 | "no-div-regex": 2, 56 | "no-else-return": 0, 57 | "no-eq-null": 1, 58 | "no-eval": 2, 59 | "no-extend-native": 2, 60 | "no-extra-bind": 2, 61 | "no-fallthrough": 2, 62 | "no-floating-decimal": 2, 63 | "no-implied-eval": 2, 64 | "no-iterator": 2, 65 | "no-labels": 2, 66 | "no-lone-blocks": 2, 67 | "no-loop-func": 1, 68 | "no-multi-spaces": 1, 69 | "no-multi-str": 2, 70 | "no-native-reassign": 2, 71 | "no-new": 2, 72 | "no-new-func": 2, 73 | "no-new-wrappers": 2, 74 | "no-octal": 2, 75 | "no-octal-escape": 2, 76 | "no-process-env": 0, 77 | "no-proto": 2, 78 | "no-redeclare": 1, 79 | "no-return-assign": 2, 80 | "no-script-url": 2, 81 | "no-self-compare": 2, 82 | "no-sequences": 2, 83 | "no-unused-expressions": 2, 84 | "no-void": 1, 85 | "no-warning-comments": [ 86 | 1, 87 | { 88 | "terms": [ 89 | "fixme" 90 | ], 91 | "location": "start" 92 | } 93 | ], 94 | "no-with": 2, 95 | "radix": 2, 96 | "vars-on-top": 0, 97 | "wrap-iife": [2, "any"], 98 | "yoda": 0, 99 | 100 | "strict": 0, 101 | 102 | "no-catch-shadow": 2, 103 | "no-delete-var": 2, 104 | "no-label-var": 2, 105 | "no-shadow": 0, 106 | "no-shadow-restricted-names": 2, 107 | "no-undef": 2, 108 | "no-undef-init": 2, 109 | "no-undefined": 1, 110 | "no-unused-vars": 2, 111 | "no-use-before-define": 0, 112 | 113 | "handle-callback-err": 2, 114 | "no-mixed-requires": 0, 115 | "no-new-require": 2, 116 | "no-path-concat": 2, 117 | "no-process-exit": 2, 118 | "no-restricted-modules": 0, 119 | "no-sync": 0, 120 | 121 | "brace-style": [ 122 | 2, 123 | "1tbs", 124 | { "allowSingleLine": true } 125 | ], 126 | "camelcase": 1, 127 | "comma-spacing": [ 128 | 2, 129 | { 130 | "before": false, 131 | "after": true 132 | } 133 | ], 134 | "comma-style": [ 135 | 2, "last" 136 | ], 137 | "consistent-this": 0, 138 | "eol-last": 2, 139 | "func-names": 0, 140 | "func-style": 0, 141 | "key-spacing": [ 142 | 2, 143 | { 144 | "beforeColon": false, 145 | "afterColon": true 146 | } 147 | ], 148 | "max-nested-callbacks": 0, 149 | "new-cap": 0, 150 | "new-parens": 2, 151 | "no-array-constructor": 2, 152 | "no-inline-comments": 1, 153 | "no-lonely-if": 1, 154 | "no-mixed-spaces-and-tabs": 2, 155 | "no-multiple-empty-lines": [ 156 | 1, 157 | { "max": 2 } 158 | ], 159 | "no-nested-ternary": 2, 160 | "no-new-object": 2, 161 | "semi-spacing": [2, { "before": false, "after": true }], 162 | "no-spaced-func": 2, 163 | "no-ternary": 0, 164 | "no-trailing-spaces": 1, 165 | "no-underscore-dangle": 0, 166 | "one-var": 0, 167 | "operator-assignment": 0, 168 | "padded-blocks": 0, 169 | "quote-props": [2, "as-needed"], 170 | "quotes": [ 171 | 2, 172 | "single", 173 | "avoid-escape" 174 | ], 175 | "semi": [ 176 | 2, 177 | "always" 178 | ], 179 | "sort-vars": 0, 180 | "keyword-spacing": [ 2 ], 181 | "space-before-function-paren": [ 182 | 2, 183 | "never" 184 | ], 185 | "space-before-blocks": [ 186 | 2, 187 | "always" 188 | ], 189 | "space-in-brackets": 0, 190 | "space-in-parens": 0, 191 | "space-infix-ops": 2, 192 | "space-unary-ops": [ 193 | 1, 194 | { 195 | "words": true, 196 | "nonwords": false 197 | } 198 | ], 199 | "spaced-comment": [ 200 | 2, 201 | "always", 202 | { "exceptions": ["-"] } 203 | ], 204 | "wrap-regex": 1, 205 | 206 | "max-depth": 0, 207 | "max-len": [ 208 | 2, 209 | 80, 210 | 2 211 | ], 212 | "max-params": 0, 213 | "max-statements": 0, 214 | "no-bitwise": 1, 215 | "no-plusplus": 0, 216 | 217 | "react/display-name": 1, 218 | "react/jsx-boolean-value": [1, "always"], 219 | "jsx-quotes": [1, "prefer-single"], 220 | "react/jsx-no-undef": 1, 221 | "react/jsx-sort-props": [1, { "ignoreCase": true }], 222 | "react/jsx-uses-react": 1, 223 | "react/jsx-uses-vars": 1, 224 | "react/no-did-mount-set-state": 2, 225 | "react/no-did-update-set-state": 2, 226 | "react/no-multi-comp": [2, { "ignoreStateless": true } ], 227 | "react/prop-types": 2, 228 | "react/react-in-jsx-scope": 1, 229 | "react/self-closing-comp": 1, 230 | "react/jsx-wrap-multilines": 1 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redux-Epic 2 | 3 | [![Greenkeeper badge](https://badges.greenkeeper.io/BerkeleyTrue/redux-epic.svg)](https://greenkeeper.io/) 4 | 5 | > Better async in Redux with SSR data pre-fetching 6 | 7 | Redux-Epic is a library built to do complex/async side-effects and 8 | server side rendering(SSR) data pre-fetching using RxJS. 9 | 10 | #### NOTE: 11 | With the release of RxJS@5, I'm recommending everyone move to [redux-observable](https://github.com/redux-observable/redux-observable). This library will soon be deprecated as soon as the API is close enough to make the transition easier. With this latest release, we change the API of epics (in a non-breaking way) to make the signatures the same as redux-observable epics. 12 | 13 | The last piece of the puzzle is SSR. I've created [react-redux-epic](https://github.com/BerkeleyTrue/react-redux-epic) to add SSR to Redux Observable in the same fashion as Redux-Epic. 14 | 15 | ## Current Async story in Redux 16 | 17 | There are currently two different modes of handling side-effects in Redux. The 18 | first is to dispatch actions that are functions or actions that contain some sort 19 | of data structure. Here are some common examples: 20 | 21 | * [redux-thunk](https://github.com/gaearon/redux-thunk): dispatch action creators (functions) instead of plain action objects 22 | * [redux-promise](https://github.com/acdlite/redux-promise): dispatch promises(or actions containing promises) that are converted to actions 23 | 24 | The downside of these two libraries: you are no longer dispatching plain 25 | serializable action objects. 26 | 27 | The second and cleaner mode is taken by [redux-saga](https://github.com/yelouafi/redux-saga). 28 | You create sagas (generator functions) and the library converts those sagas to redux middleware. 29 | You then dispatch actions normally and the sagas react to those actions. 30 | 31 | ## Redux-Epic makes async better 32 | 33 | While Redux-Saga is awesome and a source of inspiration for this library, 34 | I've never been sold on generators themselves. They are a great way to create 35 | long lived iterables (you pull data out of them), it just doesn't make sense 36 | when you want something to push data to you instead. Iterables (generators create iterables) 37 | are by definition not reactive but interactive. They require something to be 38 | constantly polling (pulling data out) them until they complete. 39 | 40 | On the other hand, Observables are reactive! Instead of polling the iterable, we 41 | just wait for new actions from our observables and dispatch as normal. 42 | 43 | ## Why create Redux-Epic? 44 | 45 | * Observables are powerful and proven 46 | * Allows Redux-Epic to offer a smaller API surface 47 | * Allows us to easily do server side rendering with data pre-fetching 48 | 49 | * The Epic approach is a cleaner API 50 | * Action creators are plain map functions. In other words, they take in data 51 | and output actions 52 | * Components are can be plain mapping functions. Take in data and output html. 53 | * Complex/Async application logic lives in epics. 54 | 55 | * Server Side Rendering depends on async side-effects, which is why it's built into 56 | Redux-Epic 57 | 58 | Observables offer a powerful and functional API. With Redux-Epic we take 59 | advantage of this built in power and leave our specific API as small as 60 | possible. 61 | 62 | ## Install 63 | 64 | ```bash 65 | npm install --save redux-epic 66 | ``` 67 | 68 | ## Basic Usage 69 | 70 | Let's create a Epic, a function that returns an observable stream of actions, 71 | that handles fetching user data 72 | 73 | ```js 74 | // fetch-user-epic.js 75 | import { Observable } from 'rx'; 76 | import fetchUser from 'my-cool-ajax-library'; 77 | 78 | export default function tickEpic(actions$) { 79 | return action$ 80 | // only listen for the action we care about 81 | // this will be our trigger 82 | .filter(action.type === 'FETCH_USER') 83 | .flatMap(action => { 84 | const userId = action.payload; 85 | // fetchUser makes an ajax request and returns an observable 86 | return fetchUser(`/api/user/${userId}`) 87 | // turn the result of the ajax request 88 | // into an action 89 | .map(user => { return { type: 'UPDATE_USER', payload: { user: user } }; }) 90 | // handle request errors with another action creator 91 | .catch(error => Observable.just({ type: 'FETCH_USER_ERROR', error: error })); 92 | }); 93 | } 94 | ``` 95 | 96 | Now to start using your newly created epic: 97 | 98 | ```js 99 | import { createEpic } from 'redux-epic'; 100 | import { createStore, applyMiddleware } from 'redux' 101 | import myReducer from './my-reducer' 102 | import fetchUserEpic from './fetch-user-epic' 103 | 104 | // createEpic can take any number of epics 105 | const epicMiddleware = createEpic(fetchUserEpic); 106 | 107 | const store = createStore( 108 | myReducer, 109 | applyMiddleware(epicMiddleware); 110 | ); 111 | 112 | ``` 113 | 114 | And that's it! Your epic is now connected to redux store. 115 | Now to trigger your epic, you just need to dispatch the 116 | 'FETCH_USER' action! 117 | 118 | ## [Docs](docs) 119 | 120 | * [API](docs/api) 121 | * [recipes](docs/recipes) 122 | 123 | ## Previous Art 124 | 125 | This library is inspired by the following 126 | 127 | * [Redux-Saga](https://github.com/yelouafi/redux-saga): Sagas built using generators 128 | * [Redux-Saga-RxJS](https://github.com/salsita/redux-saga-rxjs): Sagas using RxJS (No longer maintained) 129 | 130 | ## Redux-Observable 131 | 132 | It's come to my attention the recent changes to the library [redux-observable](https://github.com/redux-observable/redux-observable). 133 | 134 | Initially redux-observable was very different from redux-epic. They took the same path as redux-thunk, redux-promise, and others where the observable (or promise, or function, or whatever) goes through the dispatch. This was distasteful to me, which is why I went the same route as redux-saga and redux-saga-rxjs, using middleware to intercept and dispatch actions in a fashion that fits the idioms of both Redux and Rx. 135 | 136 | It looks like they independently came to the same conclusion in https://github.com/redux-observable/redux-observable/pull/55 and https://github.com/redux-observable/redux-observable/pull/67 and switched to an identical model as mine, even the same name! It's great that we both came to the same conclusions and validates my initial thoughts. 137 | 138 | After a conversation with the authors of Redux-Observable, we've decided to join 139 | forces. More details to come as to exactly how that would work. 140 | 141 | I recommend using Redux-Observable over this library unless you need server side 142 | data fetcing today. 143 | -------------------------------------------------------------------------------- /test/create-epic.js: -------------------------------------------------------------------------------- 1 | import { Observable, Subject } from 'rx'; 2 | import test from 'ava'; 3 | import { spy } from 'sinon'; 4 | import { applyMiddleware, createStore } from 'redux'; 5 | import { createEpic } from '../src'; 6 | 7 | const setup = (saga, spy) => { 8 | const reducer = (state = 0) => state; 9 | const epicMiddleware = createEpic( 10 | action$ => action$ 11 | .filter(({ type }) => type === 'foo') 12 | .map(() => ({ type: 'bar' })), 13 | action$ => action$ 14 | .filter(({ type }) => type === 'bar') 15 | .map(({ type: 'baz' })), 16 | saga ? saga : () => Observable.empty() 17 | ); 18 | const store = applyMiddleware(epicMiddleware)(createStore)(spy || reducer); 19 | return { 20 | reducer, 21 | epicMiddleware, 22 | store 23 | }; 24 | }; 25 | 26 | test('createEpic', t => { 27 | const epicMiddleware = createEpic( 28 | action$ => action$.map({ type: 'foo' }) 29 | ); 30 | t.is( 31 | typeof epicMiddleware, 32 | 'function', 33 | 'epicMiddleware is a function' 34 | ); 35 | t.is( 36 | typeof epicMiddleware.subscribe, 37 | 'function', 38 | 'epicMiddleware has a subscription method' 39 | ); 40 | t.is( 41 | typeof epicMiddleware.subscribeOnCompleted, 42 | 'function', 43 | 'epicMiddleware has a subscribeOnCompleted method' 44 | ); 45 | t.is( 46 | typeof epicMiddleware.end, 47 | 'function', 48 | 'epicMiddleware does have an end method' 49 | ); 50 | t.is( 51 | typeof epicMiddleware.restart, 52 | 'function', 53 | 'epicMiddleware does have a restart method' 54 | ); 55 | t.is( 56 | typeof epicMiddleware.dispose, 57 | 'function', 58 | 'epicMiddleware does have a dispose method' 59 | ); 60 | }); 61 | 62 | test('dispatching actions', t => { 63 | const reducer = spy((state = 0) => state); 64 | const { store } = setup(null, reducer); 65 | store.dispatch({ type: 'foo' }); 66 | t.is(reducer.callCount, 4, 'reducer is called four times'); 67 | t.true( 68 | reducer.getCall(1).calledWith(0, { type: 'foo' }), 69 | 'reducer called with initial action' 70 | ); 71 | t.true( 72 | reducer.getCall(2).calledWith(0, { type: 'bar' }), 73 | 'reducer was called with saga action' 74 | ); 75 | t.true( 76 | reducer.getCall(3).calledWith(0, { type: 'baz' }), 77 | 'second saga responded to action from first saga' 78 | ); 79 | }); 80 | 81 | test('lifecycle subscribe', t => { 82 | const { epicMiddleware } = setup(); 83 | const subscription = epicMiddleware.subscribe(() => {}); 84 | const subscription2 = epicMiddleware.subscribeOnCompleted(() => {}); 85 | t.is( 86 | typeof subscription, 87 | 'object', 88 | 'subscribe did return a disposable' 89 | ); 90 | t.is( 91 | typeof subscription.dispose, 92 | 'function', 93 | 'disposable does have a dispose method' 94 | ); 95 | t.notThrows( 96 | () => subscription.dispose(), 97 | 'disposable is disposable' 98 | ); 99 | t.is( 100 | typeof subscription2, 101 | 'object', 102 | 'subscribe did return a disposable' 103 | ); 104 | t.is( 105 | typeof subscription2.dispose, 106 | 'function', 107 | 'disposable does have a dispose method' 108 | ); 109 | t.notThrows( 110 | () => subscription2.dispose(), 111 | 'disposable is disposable' 112 | ); 113 | }); 114 | 115 | test.cb('lifecycle end', t => { 116 | const result$ = new Subject(); 117 | const { epicMiddleware } = setup(() => result$); 118 | epicMiddleware.subscribeOnCompleted(() => { 119 | t.pass('all sagas completed'); 120 | t.end(); 121 | }); 122 | epicMiddleware.end(); 123 | t.pass('saga still active'); 124 | result$.onCompleted(); 125 | }); 126 | 127 | test('lifecycle disposable', t => { 128 | const result$ = new Subject(); 129 | const { epicMiddleware } = setup(() => result$); 130 | t.plan(2); 131 | epicMiddleware.subscribeOnCompleted(() => { 132 | t.fail('all sagas completed'); 133 | }); 134 | t.true( 135 | result$.hasObservers(), 136 | 'saga is observed by epicMiddleware' 137 | ); 138 | epicMiddleware.dispose(); 139 | t.false( 140 | result$.hasObservers(), 141 | 'watcher has no observers after epicMiddleware is disposed' 142 | ); 143 | }); 144 | 145 | test('restart', t => { 146 | const reducer = spy((state = 0) => state); 147 | const { epicMiddleware, store } = setup(null, reducer); 148 | store.dispatch({ type: 'foo' }); 149 | t.true( 150 | reducer.getCall(1).calledWith(0, { type: 'foo' }), 151 | 'reducer called with initial dispatch' 152 | ); 153 | t.true( 154 | reducer.getCall(2).calledWith(0, { type: 'bar' }), 155 | 'reducer called with saga action' 156 | ); 157 | t.true( 158 | reducer.getCall(3).calledWith(0, { type: 'baz' }), 159 | 'second saga responded to action from first saga' 160 | ); 161 | epicMiddleware.end(); 162 | t.is(reducer.callCount, 4, 'saga produced correct amount of actions'); 163 | epicMiddleware.restart(); 164 | store.dispatch({ type: 'foo' }); 165 | t.is( 166 | reducer.callCount, 167 | 7, 168 | 'saga restart and produced correct amount of actions' 169 | ); 170 | t.true( 171 | reducer.getCall(4).calledWith(0, { type: 'foo' }), 172 | 'reducer called with second dispatch' 173 | ); 174 | t.true( 175 | reducer.getCall(5).calledWith(0, { type: 'bar' }), 176 | 'reducer called with saga reaction' 177 | ); 178 | t.true( 179 | reducer.getCall(6).calledWith(0, { type: 'baz' }), 180 | 'second saga responded to action from first saga' 181 | ); 182 | }); 183 | 184 | test.cb('long lived saga', t => { 185 | let count = 0; 186 | const tickSaga = action$ => action$ 187 | .filter(({ type }) => type === 'start-tick') 188 | .flatMap(() => Observable.interval(500)) 189 | // make sure long lived saga's do not persist after 190 | // action$ has completed 191 | .takeUntil(action$.last()) 192 | .map(({ type: 'tick' })); 193 | 194 | const reducerSpy = spy((state = 0) => state); 195 | const { store, epicMiddleware } = setup(tickSaga, reducerSpy); 196 | const unlisten = store.subscribe(() => { 197 | count += 1; 198 | if (count >= 5) { 199 | epicMiddleware.end(); 200 | } 201 | }); 202 | epicMiddleware.subscribeOnCompleted(() => { 203 | t.is( 204 | count, 205 | 5, 206 | 'saga dispatched correct amount of ticks' 207 | ); 208 | unlisten(); 209 | t.pass('long lived saga completed'); 210 | t.end(); 211 | }); 212 | store.dispatch({ type: 'start-tick' }); 213 | }); 214 | 215 | test('throws', t => { 216 | const tr8tr = () => 'traitor!'; 217 | const identity = action$ => action$; 218 | t.plan(2); 219 | t.throws( 220 | () => setup(tr8tr), 221 | null, 222 | 'epicMiddleware should throw sagas that do not return observables' 223 | ); 224 | t.throws( 225 | () => setup(identity), 226 | null, 227 | 'epicMiddleware should throw sagas return the original action observable' 228 | ); 229 | }); 230 | 231 | test('dependencies', t => { 232 | t.plan(1); 233 | const myDep = {}; 234 | const reducer = (state = 0) => state; 235 | const saga = (actions$, getState, deps) => { 236 | t.is(deps.myDep, myDep); 237 | return Observable.empty(); 238 | }; 239 | const epicMiddleware = createEpic({ myDep }, saga); 240 | createStore(reducer, 0, applyMiddleware(epicMiddleware)); 241 | }); 242 | 243 | test('warn about second argument of epic', t => { 244 | t.plan(2); 245 | const warningSpy = spy(console, 'error'); 246 | const epic = (actions, getState) => { 247 | getState(); 248 | getState(); 249 | return Observable.of({ type: 'foo' }); 250 | }; 251 | const epicMiddleware = createEpic(epic); 252 | const dispatch = x => x; 253 | const getState = () => 4; 254 | epicMiddleware({ dispatch, getState })(x => x)({ type: 'foo' }); 255 | epicMiddleware.end(); 256 | t.true( 257 | warningSpy.calledOnce, 258 | 'warning was called' 259 | ); 260 | t.true( 261 | (/mock store/g).test(warningSpy.getCall(0).args[0]), 262 | 'warning was called with message' 263 | ); 264 | console.error.restore(); 265 | }); 266 | 267 | test('warn when non-action elements are sent', t => { 268 | t.plan(2); 269 | const warningSpy = spy(console, 'error'); 270 | const nullEpic = () => Observable.of(null, null); 271 | const epicMiddleware = createEpic(nullEpic); 272 | const dispatch = x => x; 273 | const getState = () => 4; 274 | epicMiddleware({ dispatch, getState })(x => x)({ type: 'foo' }); 275 | epicMiddleware.end(); 276 | t.true( 277 | warningSpy.calledOnce, 278 | 'warning was called' 279 | ); 280 | t.true( 281 | (/pass null to the dispatch/g).test(warningSpy.getCall(0).args[0]), 282 | 'warning was called with message' 283 | ); 284 | console.error.restore(); 285 | }); 286 | --------------------------------------------------------------------------------