diff --git a/.gitignore b/.gitignore
index d8698404..fc52dc1c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,5 @@ tmp
node_modules
lib
dist
+coverage
+
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 00000000..601542a7
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,9 @@
+language: node_js
+
+node_js:
+ - "4"
+ - "5"
+ - "6"
+
+after_success:
+ - npm run coveralls
diff --git a/examples/user-dashboard/src/entries/index.js b/examples/user-dashboard/src/entries/index.js
index c9bc29b0..5aaccf36 100644
--- a/examples/user-dashboard/src/entries/index.js
+++ b/examples/user-dashboard/src/entries/index.js
@@ -12,26 +12,31 @@ app.model(require('../models/users'));
app.router(require('../routes'));
// 4. Start
-const { render } = app.start(document.getElementById('root'));
+app.start(document.getElementById('root'), {
-// Support Routes HMR.
-// This will be implemented in babel plugin later.
-if (module.hot) {
- const renderNormally = render;
- const renderException = (error) => {
- const RedBox = require('redbox-react');
- ReactDOM.render(, document.getElementById('root'));
- };
- const newRender = (routes) => {
- try {
- renderNormally(routes);
- } catch (error) {
- console.error('error', error);
- renderException(error);
+ // Support Routes HMR.
+ // This will be implemented in babel plugin later.
+ hmr: (render) => {
+ if (module.hot) {
+ const renderNormally = render;
+ const renderException = (error) => {
+ const RedBox = require('redbox-react');
+ ReactDOM.render(, document.getElementById('root'));
+ };
+ const newRender = (routes) => {
+ try {
+ renderNormally(routes);
+ } catch (error) {
+ console.error('error', error);
+ renderException(error);
+ }
+ };
+ module.hot.accept('../routes', () => {
+ const routes = require('../routes');
+ newRender(routes);
+ });
}
- };
- module.hot.accept('../routes', () => {
- const routes = require('../routes');
- newRender(routes);
- });
-}
+ },
+});
+
+
diff --git a/package.json b/package.json
index 30304f4d..6ff9d949 100644
--- a/package.json
+++ b/package.json
@@ -21,10 +21,15 @@
"author": "chencheng ",
"license": "MIT",
"scripts": {
+ "test": "babel-node node_modules/.bin/babel-istanbul cover node_modules/.bin/_mocha --no-timeouts",
+ "debug": "./node_modules/.bin/mocha --require babel-core/register --no-timeouts",
"build": "rm -rf lib && babel src --out-dir lib --ignore __tests__",
- "lint": "eslint --ext .js src"
+ "lint": "eslint --ext .js src",
+ "coveralls": "cat ./coverage/lcov.info | coveralls"
},
"dependencies": {
+ "global": "^4.3.0",
+ "is-plain-object": "^2.0.1",
"isomorphic-fetch": "^2.2.1",
"react-redux": "4.4.x",
"react-router": "^2.5.1",
@@ -40,6 +45,7 @@
"devDependencies": {
"babel-cli": "^6.10.1",
"babel-eslint": "^6.0.4",
+ "babel-istanbul": "^0.11.0",
"babel-plugin-add-module-exports": "^0.2.1",
"babel-plugin-transform-runtime": "^6.9.0",
"babel-preset-es2015": "^6.9.0",
@@ -48,9 +54,12 @@
"babel-runtime": "^6.9.2",
"browserify": "^13.0.1",
"browserify-shim": "^3.8.12",
+ "coveralls": "^2.11.9",
"envify": "^3.4.1",
"eslint": "^2.7.0",
"eslint-config-airbnb": "^9.0.1",
+ "expect": "^1.20.2",
+ "mocha": "^2.5.3",
"uglifyjs": "^2.4.10"
},
"babel": {
@@ -80,4 +89,4 @@
"fetch.js",
"index.js"
]
-}
+}
\ No newline at end of file
diff --git a/src/index.js b/src/index.js
index 133ef584..8efdceb6 100644
--- a/src/index.js
+++ b/src/index.js
@@ -7,20 +7,34 @@ import { hashHistory, Router } from 'react-router';
import { syncHistoryWithStore, routerReducer as routing } from 'react-router-redux';
import { handleActions } from 'redux-actions';
import { fork } from 'redux-saga/effects';
+import document from 'global/document';
+import window from 'global/window';
import { is, check, warn } from './utils';
-function dva() {
+function dva(opts = {}) {
+ const onError = opts.onError || function(err) {
+ throw new Error(err);
+ };
+ const onErrorWrapper = (err) => {
+ if (err) {
+ if (is.string(err)) err = new Error(err);
+ onError(err);
+ }
+ };
+
let _routes = null;
const _models = [];
const app = {
model,
router,
start,
+ store: null,
};
return app;
function model(model) {
- check(model.namespace, is.notUndef, 'Namespace must be defined with model');
+ check(model.namespace, is.notUndef, 'Namespace must be defined with model.');
+ check(model.namespace, namespace => namespace !== 'routing', 'Namespace should not be routing.');
_models.push(model);
}
@@ -29,58 +43,125 @@ function dva() {
_routes = routes;
}
+ // Usage:
+ // app.start();
+ // app.start(container);
+ // app.start(container, opts);
+ // app.start(opts);
function start(container, opts = {}) {
- check(container, is.element, 'Container must be DOMElement');
- check(_routes, is.notUndef, 'Routes is not defined');
- let sagas = {};
- const rootReducer = {};
+ // If no container supplied, return jsx element.
+ if (arguments.length === 0
+ || (arguments.length === 1 && is.object(container))) {
+ opts = container || {};
+ container = null;
+ } else {
+ check(container, is.element, 'Container must be DOMElement.');
+ }
+ check(_routes, is.notUndef, 'Routes is not defined.');
+ // Get sagas and reducers from model.
+ let sagas = {};
+ let reducers = {
+ routing,
+ };
_models.forEach(model => {
- rootReducer[model.namespace] = handleActions(model.reducers || {}, model.state);
+ if (is.array(model.reducers)) {
+ const [_reducers, enhancer] = model.reducers;
+ reducers[model.namespace] = enhancer(handleActions(_reducers || {}, model.state));
+ } else {
+ reducers[model.namespace] = handleActions(model.reducers || {}, model.state);
+ }
sagas = { ...sagas, ...model.effects };
});
+ // Support external reducers.
+ if (is.notUndef(opts.reducers)) {
+ check(opts.reducers, is.object, 'Reducers must be object.');
+ check(opts.reducers, optReducers => {
+ for (var k in optReducers) {
+ if (k in reducers) return false;
+ }
+ return true;
+ }, 'Reducers should not be conflict with namespace in model.');
+ reducers = { ...reducers, ...opts.reducers };
+ }
+
+ // Create store.
+ if (is.notUndef(opts.middlewares)) {
+ check(opts.middlewares, is.array, 'Middlewares must be array.')
+ }
const sagaMiddleware = createSagaMiddleware();
const enhancer = compose(
- applyMiddleware(sagaMiddleware),
+ applyMiddleware.apply(null, [sagaMiddleware, ...(opts.middlewares || [])]),
window.devToolsExtension ? window.devToolsExtension() : f => f
);
- const store = createStore(
- combineReducers({ ...rootReducer, routing }), {}, enhancer
+ const initialState = opts.initialState || {};
+ const store = app.store = createStore(
+ combineReducers(reducers), initialState, enhancer
);
- const history = syncHistoryWithStore(opts.history || hashHistory, store);
+
+ // Sync history.
+ // Use try catch because it don't work in test.
+ let history;
+ try {
+ history = syncHistoryWithStore(opts.history || hashHistory, store);
+ } catch (e) {}
+
+ // Start saga.
sagaMiddleware.run(rootSaga);
- document.addEventListener('DOMContentLoaded', () => {
- _models.forEach(({ subscriptions }) => {
- if (subscriptions) {
- check(subscriptions, is.array, 'Subscriptions must be an array');
- subscriptions.forEach(sub => {
- check(sub, is.func, 'Subscription must be an function');
- sub(store.dispatch);
- });
- }
- });
+ // Handle subscriptions.
+ _models.forEach(({ subscriptions }) => {
+ if (subscriptions) {
+ check(subscriptions, is.array, 'Subscriptions must be an array');
+ subscriptions.forEach(sub => {
+ check(sub, is.func, 'Subscription must be an function');
+ sub(store.dispatch, onErrorWrapper);
+ });
+ }
});
+ // Render and hmr.
+ if (container) {
+ render();
+ if (opts.hmr) {
+ opts.hmr(render);
+ }
+ } else {
+ const Routes = _routes;
+ return
+
+ ;
+ }
+
function getWatcher(k, saga) {
let _saga = saga;
let _type = 'takeEvery';
if (Array.isArray(saga)) {
[_saga, opts] = saga;
opts = opts || {};
- check(opts.type, is.sagaType, 'Type must be takeEvery or takeLatest');
- warn(opts.type, v => v === 'takeLatest', 'takeEvery is the default type, no need to set it by opts');
+ check(opts.type, is.sagaType, 'Type must be takeEvery, takeLatest or watcher');
+ warn(opts.type, v => v !== 'takeEvery', 'takeEvery is the default type, no need to set it by opts');
_type = opts.type;
}
- if (_type === 'takeEvery') {
+ function* sagaWithErrorCatch() {
+ try {
+ yield _saga();
+ } catch (e) {
+ onError(e);
+ }
+ }
+
+ if (_type === 'watcher') {
+ return sagaWithErrorCatch;
+ } else if (_type === 'takeEvery') {
return function*() {
- yield takeEvery(k, _saga);
+ yield takeEvery(k, sagaWithErrorCatch);
};
} else {
return function*() {
- yield takeLatest(k, _saga);
+ yield takeLatest(k, sagaWithErrorCatch);
};
}
}
@@ -102,11 +183,6 @@ function dva() {
), container);
}
-
- render();
- return {
- render,
- };
}
}
diff --git a/src/utils.js b/src/utils.js
index 8dc2ce3b..75a60c30 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -1,3 +1,4 @@
+import isPlainObject from 'is-plain-object';
export function check(value, predicate, error) {
if(!predicate(value)) {
@@ -20,8 +21,9 @@ export const is = {
number : n => typeof n === 'number',
element : n => typeof n === 'object' && n.nodeType && n.nodeName,
array : Array.isArray,
+ object : isPlainObject,
jsx : v => v && v.$$typeof && v.$$typeof.toString() === 'Symbol(react.element)',
- sagaType : v => v === 'takeEvery' || v === 'takeLatest',
+ sagaType : v => v === 'takeEvery' || v === 'takeLatest' || v === 'watcher',
};
/**
diff --git a/test/app.model-test.js b/test/app.model-test.js
new file mode 100644
index 00000000..05beeac2
--- /dev/null
+++ b/test/app.model-test.js
@@ -0,0 +1,180 @@
+import expect from 'expect';
+import dva from '../src/index';
+import { take, call } from '../effects';
+
+describe('app.model', () => {
+ it('reducer enhancer', () => {
+ function enhancer(reducer) {
+ return (state, action) => {
+ if (action.type === 'square') {
+ return state * state;
+ }
+ return reducer(state, action);
+ };
+ }
+
+ const app = dva();
+ app.model({
+ namespace: 'count',
+ state: 3,
+ reducers: [{
+ ['add'](state) {
+ return state + 1;
+ },
+ }, enhancer],
+ });
+ app.router(({history}) => );
+ app.start();
+
+ app.store.dispatch({ type: 'square' });
+ app.store.dispatch({ type: 'add' });
+ expect(app.store.getState().count).toEqual(10);
+ });
+
+ it('effects: type takeEvery', () => {
+ let count = 0;
+ const app = dva();
+ app.model({
+ namespace: 'count',
+ state: 0,
+ effects: {
+ ['add']: function*() {
+ count = count + 1;
+ },
+ }
+ });
+ app.router(({history}) => );
+ app.start();
+
+ app.store.dispatch({type: 'add'});
+ app.store.dispatch({type: 'add'});
+ expect(count).toEqual(2);
+ });
+
+ it('effects: type takeLatest', (done) => {
+ let count = 0;
+ const app = dva();
+ const delay = (timeout) => {
+ return new Promise(resolve => {
+ setTimeout(resolve, timeout);
+ });
+ };
+ app.model({
+ namespace: 'count',
+ state: 0,
+ effects: {
+ ['add']: [function*() {
+ yield call(delay, 1);
+ count = count + 1;
+ }, {
+ type: 'takeLatest',
+ }],
+ }
+ });
+ app.router(({history}) => );
+ app.start();
+
+ // Only catch the last one.
+ app.store.dispatch({type: 'add'});
+ app.store.dispatch({type: 'add'});
+
+ setTimeout(() => {
+ expect(count).toEqual(1);
+ done();
+ }, 100);
+ });
+
+ it('effects: type watcher', (done) => {
+ let count = 0;
+ const app = dva();
+ const delay = (timeout) => {
+ return new Promise(resolve => {
+ setTimeout(resolve, timeout);
+ });
+ };
+ app.model({
+ namespace: 'count',
+ state: 0,
+ effects: {
+ ['addWatcher']: [function*() {
+ while(true) {
+ yield take('add');
+ yield delay(1);
+ count = count + 1;
+ }
+ }, {
+ type: 'watcher',
+ }],
+ }
+ });
+ app.router(({history}) => );
+ app.start();
+
+ // Only catch the first one.
+ app.store.dispatch({type: 'add'});
+ app.store.dispatch({type: 'add'});
+
+ setTimeout(() => {
+ expect(count).toEqual(1);
+ done();
+ }, 100);
+ });
+
+ it('effects: onError', () => {
+ const errors = [];
+ const app = dva({
+ onError: (error) => {
+ errors.push(error.message);
+ },
+ });
+
+ app.model({
+ namespace: 'count',
+ state: 0,
+ effects: {
+ ['add']: function*() {
+ throw new Error('effect error');
+ },
+ }
+ });
+ app.router(({history}) => );
+ app.start();
+ app.store.dispatch({type: 'add'});
+
+ expect(errors).toEqual(['effect error']);
+ });
+
+ it('subscriptions: onError', (done) => {
+ const errors = [];
+ const app = dva({
+ onError: (error) => {
+ errors.push(error.message);
+ },
+ });
+
+ app.model({
+ namespace: 'count',
+ state: 0,
+ effects: {
+ ['add']: function*() {
+ throw new Error('effect error');
+ },
+ },
+ subscriptions: [
+ function(dispatch, done) {
+ dispatch({ type: 'add' });
+ setTimeout(() => {
+ done('subscription error');
+ }, 100);
+ },
+ ]
+ });
+ app.router(({history}) => );
+ app.start();
+
+ setTimeout(() => {
+ expect(errors).toEqual(['effect error', 'subscription error']);
+ done();
+ }, 500);
+ });
+});
diff --git a/test/app.start-test.js b/test/app.start-test.js
new file mode 100644
index 00000000..905f9197
--- /dev/null
+++ b/test/app.start-test.js
@@ -0,0 +1,95 @@
+import expect from 'expect';
+import dva from '../src/index';
+
+describe('app.start', () => {
+
+ it('throw error if no routes defined', () => {
+ const app = dva();
+ expect(() => {
+ app.start();
+ }).toThrow(/Routes is not defined/);
+ });
+
+ it('opts.initialState', () => {
+ const app = dva();
+ app.model({
+ namespace: 'count',
+ state: 0,
+ });
+ app.router(({history}) => );
+ app.start({
+ initialState: {
+ count: 1,
+ },
+ });
+ expect(app.store.getState().count).toEqual(1);
+ });
+
+ it('opts.reducers', () => {
+ const reducers = {
+ count: (state, { type }) => {
+ if (type === 'add') {
+ return state + 1;
+ }
+ // default state
+ return 0;
+ },
+ };
+ const app = dva();
+ app.router(({history}) => );
+ app.start({
+ reducers,
+ });
+
+ expect(app.store.getState().count).toEqual(0);
+ app.store.dispatch({ type: 'add' });
+ expect(app.store.getState().count).toEqual(1);
+ });
+
+ it('opts.reducers: throw error if not plain object', () => {
+ const app = dva();
+ app.router(({history}) => );
+ expect(() => {
+ app.start({
+ reducers: 0,
+ });
+ }).toThrow(/Reducers must be object/);
+ });
+
+ it('opts.reducers: throw error if conflicts', () => {
+ const app = dva();
+ app.router(({history}) => );
+ expect(() => {
+ app.start({
+ reducers: { routing: function(){}, },
+ });
+ }).toThrow(/Reducers should not be conflict with namespace in model/);
+ });
+
+ it('opts.middlewares', () => {
+ let count = 0;
+ const countMiddleware = ({dispatch, getState}) => next => action => {
+ count = count + 1;
+ };
+
+ const app = dva();
+ app.router(({history}) => );
+ app.start({
+ middlewares: [countMiddleware]
+ });
+
+ app.store.dispatch({ type: 'test' });
+ expect(count).toEqual(1);
+ });
+
+ it('opts.middlewares: throw error if not array', () => {
+ const app = dva();
+ app.router(({history}) => );
+ expect(() => {
+ app.start({
+ middlewares: 0,
+ });
+ }).toThrow(/Middlewares must be array/);
+ });
+
+});
diff --git a/test/basic-test.js b/test/basic-test.js
new file mode 100644
index 00000000..971e6f51
--- /dev/null
+++ b/test/basic-test.js
@@ -0,0 +1,30 @@
+import expect from 'expect';
+import dva from '../src/index';
+
+describe('basic', () => {
+ it('basic', () => {
+ let sagaCount = 0;
+
+ const app = dva();
+ app.model({
+ namespace: 'count',
+ state: 0,
+ reducers: {
+ ['add'](state) {
+ return state + 1;
+ },
+ },
+ effects: {
+ ['add']: function*() {
+ sagaCount = sagaCount + 1;
+ },
+ }
+ });
+ app.router(({history}) => );
+ app.start();
+
+ app.store.dispatch({ type: 'add' });
+ expect(app.store.getState().count).toEqual(1);
+ expect(sagaCount).toEqual(1);
+ });
+});