├── .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 | [](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 |
--------------------------------------------------------------------------------