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); + }); +});