├── .gitignore ├── src ├── actions │ ├── api │ │ ├── index.js │ │ └── api.js │ └── throw-error │ │ ├── index.js │ │ └── throw-error.js ├── reducers │ ├── root │ │ ├── index.js │ │ └── root.js │ └── throw-error │ │ ├── index.js │ │ └── throw-error.js ├── sagas │ ├── root │ │ ├── index.js │ │ └── root.js │ ├── api │ │ ├── index.js │ │ └── api.js │ ├── throw-error │ │ ├── index.js │ │ └── throw-error.js │ └── error-handler │ │ ├── index.js │ │ └── error-handler.js ├── utils │ ├── fetch │ │ ├── index.js │ │ └── fetch.js │ ├── httpbin │ │ ├── index.js │ │ └── httpbin.js │ ├── logger │ │ ├── index.js │ │ └── logger.js │ ├── onerror │ │ ├── index.js │ │ └── onerror.js │ ├── throwing-module │ │ ├── index.js │ │ └── throwing-module.js │ ├── map-state-to-props │ │ ├── index.js │ │ └── map-state-to-props.js │ └── bind-action-creators │ │ ├── index.js │ │ └── bind-action-creators.js ├── containers │ └── app │ │ ├── index.js │ │ └── app.js ├── constants │ └── action-types │ │ ├── index.js │ │ └── action-types.js ├── components │ ├── react-poop │ │ ├── index.js │ │ └── react-poop.js │ └── react-component-errors │ │ ├── index.js │ │ └── react-component-errors.js ├── store │ ├── index.js │ └── configure-store.js ├── templates │ └── index.ejs └── index.js ├── .babelrc ├── webpack ├── production.config.js └── development.config.js ├── .eslintrc ├── test ├── components │ ├── react-poop │ │ └── react-poop.spec.js │ └── react-component-errors │ │ └── react-component-errors.spec.js ├── sagas │ ├── error-handler │ │ └── error-handler.spec.js │ └── api │ │ └── api.spec.js ├── utils │ ├── map-state-to-props │ │ └── map-state-to-props.spec.js │ └── bind-action-creators │ │ └── bind-action-creators.spec.js ├── actions │ ├── api │ │ └── api.spec.js │ └── throw-error │ │ └── throw-error.spec.js ├── reducers │ └── throw-error │ │ └── throw-error.spec.js └── containers │ └── app │ └── app.spec.js ├── README.md ├── LICENSE └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | -------------------------------------------------------------------------------- /src/actions/api/index.js: -------------------------------------------------------------------------------- 1 | export * from './api' 2 | -------------------------------------------------------------------------------- /src/reducers/root/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './root' 2 | -------------------------------------------------------------------------------- /src/sagas/root/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './root' 2 | -------------------------------------------------------------------------------- /src/utils/fetch/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './fetch' 2 | -------------------------------------------------------------------------------- /src/actions/throw-error/index.js: -------------------------------------------------------------------------------- 1 | export * from './throw-error' 2 | -------------------------------------------------------------------------------- /src/containers/app/index.js: -------------------------------------------------------------------------------- 1 | export { default, App } from './app' 2 | -------------------------------------------------------------------------------- /src/utils/httpbin/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './httpbin' 2 | -------------------------------------------------------------------------------- /src/utils/logger/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './logger' 2 | -------------------------------------------------------------------------------- /src/utils/onerror/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './onerror' 2 | -------------------------------------------------------------------------------- /src/constants/action-types/index.js: -------------------------------------------------------------------------------- 1 | export * from './action-types' 2 | -------------------------------------------------------------------------------- /src/sagas/api/index.js: -------------------------------------------------------------------------------- 1 | export { default, httpbinGet } from './api' 2 | -------------------------------------------------------------------------------- /src/components/react-poop/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './react-poop' 2 | -------------------------------------------------------------------------------- /src/sagas/throw-error/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './throw-error' 2 | -------------------------------------------------------------------------------- /src/utils/throwing-module/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './throwing-module' 2 | -------------------------------------------------------------------------------- /src/utils/throwing-module/throwing-module.js: -------------------------------------------------------------------------------- 1 | throw new Error('Module error') 2 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | export { default as configureStore } from './configure-store' 2 | -------------------------------------------------------------------------------- /src/utils/map-state-to-props/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './map-state-to-props' 2 | -------------------------------------------------------------------------------- /src/sagas/error-handler/index.js: -------------------------------------------------------------------------------- 1 | export { default, errorHandler } from './error-handler' 2 | -------------------------------------------------------------------------------- /src/utils/bind-action-creators/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './bind-action-creators' 2 | -------------------------------------------------------------------------------- /src/components/react-component-errors/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './react-component-errors' 2 | 3 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"], 3 | 4 | "plugins": ["transform-object-rest-spread"] 5 | } 6 | -------------------------------------------------------------------------------- /src/reducers/throw-error/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './throw-error' 2 | export * from './throw-error' 3 | -------------------------------------------------------------------------------- /src/reducers/root/root.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import throwError from 'reducers/throw-error' 3 | 4 | export default combineReducers({ throwError }) 5 | -------------------------------------------------------------------------------- /src/utils/onerror/onerror.js: -------------------------------------------------------------------------------- 1 | import Logger from 'utils/logger' 2 | 3 | window.onerror = (message, source, lineno, colno, error) => { 4 | Logger.error(error, { message, source, lineno, colno }) 5 | 6 | return true 7 | } 8 | -------------------------------------------------------------------------------- /src/templates/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Proper Error Handling 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /src/utils/map-state-to-props/map-state-to-props.js: -------------------------------------------------------------------------------- 1 | export default (func, errorHandler) => (state, ownProps) => { 2 | try { 3 | return func(state, ownProps) 4 | } catch (error) { 5 | errorHandler(error, { state, ownProps }) 6 | } 7 | 8 | return {} 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/fetch/fetch.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-fetch' 2 | 3 | export default url => fetch(url) 4 | .then(response => { 5 | if (!response.ok) { 6 | return Promise.reject(response.statusText) 7 | } 8 | 9 | return { response } 10 | }) 11 | .catch(error => ({ error })) 12 | -------------------------------------------------------------------------------- /src/utils/httpbin/httpbin.js: -------------------------------------------------------------------------------- 1 | import fetch from 'utils/fetch' 2 | 3 | export default { 4 | 5 | get(options) { 6 | let url = 'https://httpbin.org/' 7 | 8 | if (options && options.status) { 9 | url += `status/${options.status}` 10 | } 11 | 12 | return fetch(url) 13 | }, 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/sagas/root/root.js: -------------------------------------------------------------------------------- 1 | import { fork } from 'redux-saga/effects' 2 | import api from 'sagas/api' 3 | import throwError from 'sagas/throw-error' 4 | import errorHandler from 'sagas/error-handler' 5 | 6 | export default function* root() { 7 | yield [ 8 | fork(api), 9 | fork(throwError), 10 | fork(errorHandler), 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/actions/api/api.js: -------------------------------------------------------------------------------- 1 | import { API_GET, API_GET_SUCCESS, API_GET_FAILURE } from 'constants/action-types' 2 | 3 | const get = options => ({ type: API_GET, options }) 4 | export const getSuccess = response => ({ type: API_GET_SUCCESS, response }) 5 | export const getFailure = error => ({ type: API_GET_FAILURE, error }) 6 | 7 | export const getStatus = status => get({ status }) 8 | -------------------------------------------------------------------------------- /src/components/react-poop/react-poop.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import poop from 'react-poop' 3 | 4 | const ReactPoop = ({ componentsShouldThrow }) => ( 5 |
ReactPoop{componentsShouldThrow && this.does.not.exist}
6 | ) 7 | 8 | ReactPoop.propTypes = { 9 | componentsShouldThrow: PropTypes.bool, 10 | } 11 | 12 | export default poop(ReactPoop) 13 | -------------------------------------------------------------------------------- /src/sagas/error-handler/error-handler.js: -------------------------------------------------------------------------------- 1 | import { takeEvery } from 'redux-saga' 2 | import { call } from 'redux-saga/effects' 3 | import Logger from 'utils/logger' 4 | 5 | export function* errorHandler({ error, type }) { 6 | yield call(Logger.info, error, type) 7 | } 8 | 9 | function* takeEveryError() { 10 | yield* takeEvery(action => action.error, errorHandler) 11 | } 12 | 13 | export default takeEveryError 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { Provider } from 'react-redux' 4 | import App from 'containers/app' 5 | import { configureStore } from 'store' 6 | // import ThrowingModule from 'utils/throwing-module' 7 | 8 | const store = configureStore() 9 | 10 | ReactDOM.render( 11 | 12 | 13 | , 14 | document.getElementById('root') 15 | ) 16 | -------------------------------------------------------------------------------- /src/constants/action-types/action-types.js: -------------------------------------------------------------------------------- 1 | export const THROW_ERROR_IN_COMPONENTS = 'THROW_ERROR_IN_COMPONENTS' 2 | export const THROW_ERROR_IN_REDUCERS = 'THROW_ERROR_IN_REDUCERS' 3 | export const THROW_ERROR_IN_SAGAS = 'THROW_ERROR_IN_SAGAS' 4 | export const THROW_ERROR_IN_SELECTORS = 'THROW_ERROR_IN_SELECTORS' 5 | 6 | export const API_GET = 'API_GET' 7 | export const API_GET_SUCCESS = 'API_GET_SUCCESS' 8 | export const API_GET_FAILURE = 'API_GET_FAILURE' 9 | -------------------------------------------------------------------------------- /webpack/production.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const development = require('./development.config.js') 3 | 4 | development.plugins.push( 5 | new webpack.optimize.UglifyJsPlugin(), 6 | new webpack.optimize.OccurrenceOrderPlugin(), 7 | new webpack.optimize.DedupePlugin(), 8 | new webpack.DefinePlugin({ 9 | 'process.env': { 10 | NODE_ENV: JSON.stringify('production'), 11 | }, 12 | }) 13 | ) 14 | 15 | module.exports = development 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | 4 | "env": { 5 | "mocha": true 6 | }, 7 | 8 | "rules": { 9 | "semi": [2, "never"], 10 | "no-console": 0 11 | }, 12 | 13 | "parserOptions": { 14 | "ecmaFeatures": { 15 | "experimentalObjectRestSpread": true 16 | } 17 | }, 18 | 19 | "settings": { 20 | "import/resolver": { 21 | "webpack": { 22 | "config": "webpack/development.config.js" 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/components/react-poop/react-poop.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { shallow } from 'enzyme' 3 | import React from 'react' 4 | import ReactPoop from 'components/react-poop' 5 | 6 | describe('', () => { 7 | it('renders a div', () => { 8 | const wrapper = shallow() 9 | 10 | assert(wrapper.equals(
)) 11 | }) 12 | 13 | it('renders a 💩', () => { 14 | const wrapper = shallow() 15 | 16 | assert(wrapper.contains('💩')) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /src/actions/throw-error/throw-error.js: -------------------------------------------------------------------------------- 1 | import { 2 | THROW_ERROR_IN_COMPONENTS, 3 | THROW_ERROR_IN_REDUCERS, 4 | THROW_ERROR_IN_SAGAS, 5 | THROW_ERROR_IN_SELECTORS, 6 | } from 'constants/action-types' 7 | 8 | export const inComponents = () => ({ type: THROW_ERROR_IN_COMPONENTS }) 9 | export const inActions = () => { throw new Error('Action error') } 10 | export const inReducers = () => ({ type: THROW_ERROR_IN_REDUCERS }) 11 | export const inSagas = () => ({ type: THROW_ERROR_IN_SAGAS }) 12 | export const inSelectors = () => ({ type: THROW_ERROR_IN_SELECTORS }) 13 | -------------------------------------------------------------------------------- /test/sagas/error-handler/error-handler.spec.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill' 2 | import assert from 'assert' 3 | import { call } from 'redux-saga/effects' 4 | import * as ApiActions from 'actions/api' 5 | import Logger from 'utils/logger' 6 | import { errorHandler } from 'sagas/error-handler' 7 | 8 | describe('errorHandler', () => { 9 | it('fires the logging', () => { 10 | const action = ApiActions.getFailure('error') 11 | const generator = errorHandler(action) 12 | 13 | assert.deepEqual(generator.next().value, call(Logger.info, action.error, action.type)) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/sagas/throw-error/throw-error.js: -------------------------------------------------------------------------------- 1 | import { takeEvery } from 'redux-saga' 2 | import { call } from 'redux-saga/effects' 3 | import { THROW_ERROR_IN_SAGAS } from 'constants/action-types' 4 | import Logger from 'utils/logger' 5 | 6 | function* throwError() { 7 | try { 8 | yield call(() => { throw new Error('Saga error') }) 9 | } catch (error) { 10 | yield call(Logger.error, error, { type: THROW_ERROR_IN_SAGAS }) 11 | } 12 | } 13 | 14 | function* takeEveryThrowErrorInSagas() { 15 | yield* takeEvery(THROW_ERROR_IN_SAGAS, throwError) 16 | } 17 | 18 | export default takeEveryThrowErrorInSagas 19 | -------------------------------------------------------------------------------- /src/sagas/api/api.js: -------------------------------------------------------------------------------- 1 | import { takeEvery } from 'redux-saga' 2 | import { call, put } from 'redux-saga/effects' 3 | import * as ApiActions from 'actions/api' 4 | import httpbin from 'utils/httpbin' 5 | import { API_GET } from 'constants/action-types' 6 | 7 | export function* httpbinGet({ options }) { 8 | const { response, error } = yield call(httpbin.get, options) 9 | if (response) { 10 | yield put(ApiActions.getSuccess()) 11 | } else { 12 | yield put(ApiActions.getFailure(error)) 13 | } 14 | } 15 | 16 | function* takeEveryApiGet() { 17 | yield* takeEvery(API_GET, httpbinGet) 18 | } 19 | 20 | export default takeEveryApiGet 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Proper Error Handling 2 | 3 | No matter how good you are as a developer and how many tests you write: your application will throw errors. 4 | At YPlan we tried to catch all the exceptions and handle them in the right way to give our users the best possible experience. 5 | In this talk, I will go through the problems we tried to solve and the solutions we implemented #forreal 6 | 7 | [https://speakerdeck.com/michelebertoli/proper-error-handling](https://speakerdeck.com/michelebertoli/proper-error-handling) 8 | 9 | ## Usage 10 | 11 | ```bash 12 | npm install 13 | npm start 14 | ``` 15 | 16 | ## Test 17 | 18 | ```bash 19 | npm test 20 | ``` 21 | -------------------------------------------------------------------------------- /src/utils/logger/logger.js: -------------------------------------------------------------------------------- 1 | import StackTrace from 'stacktrace-js' 2 | 3 | const log = stack => { 4 | console.log(stack.map(frame => frame.toString()).join('\n')) 5 | console.groupEnd() 6 | } 7 | 8 | export default { 9 | 10 | error(error, extra) { 11 | if (console.group) { 12 | console.group('💀') 13 | console.log(error) 14 | console.log(extra) 15 | 16 | StackTrace.fromError(error).then(log) 17 | } 18 | }, 19 | 20 | info(message, extra) { 21 | if (console.group) { 22 | console.group('ℹ️') 23 | console.log(message) 24 | console.log(extra) 25 | console.groupEnd() 26 | } 27 | }, 28 | 29 | } 30 | -------------------------------------------------------------------------------- /test/components/react-component-errors/react-component-errors.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { shallow } from 'enzyme' 3 | import React from 'react' 4 | import ReactComponentErrors from 'components/react-component-errors' 5 | 6 | describe('', () => { 7 | it('renders a div', () => { 8 | const wrapper = shallow() 9 | 10 | assert(wrapper.equals(
)) 11 | }) 12 | 13 | it('throws an error', () => { 14 | const wrapper = shallow() 15 | 16 | assert.throws( 17 | () => wrapper.instance().throwError({ componentsShouldThrow: true }), 18 | /Component error/ 19 | ) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /webpack/development.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | 4 | module.exports = { 5 | 6 | devtool: 'source-map', // 'eval', 7 | 8 | entry: [ 9 | './src/utils/onerror', 10 | './src', 11 | ], 12 | 13 | output: { 14 | path: 'build', 15 | filename: 'bundle.js', 16 | }, 17 | 18 | resolve: { 19 | root: [ 20 | path.resolve('./src'), 21 | ], 22 | }, 23 | 24 | module: { 25 | loaders: [ 26 | { 27 | test: /\.js$/, 28 | exclude: /(node_modules|bower_components)/, 29 | loader: 'babel', 30 | }, 31 | ], 32 | }, 33 | 34 | plugins: [ 35 | new HtmlWebpackPlugin({ 36 | template: './src/templates/index.ejs', 37 | }), 38 | ], 39 | 40 | } 41 | -------------------------------------------------------------------------------- /test/utils/map-state-to-props/map-state-to-props.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import sinon from 'sinon' 3 | import safeMapStateToProps from 'utils/map-state-to-props' 4 | 5 | describe('safeMapStateToProps', () => { 6 | let errorHandler 7 | 8 | beforeEach(() => { 9 | errorHandler = sinon.spy() 10 | }) 11 | 12 | it('catches the error', () => { 13 | const error = new Error('test') 14 | const func = () => { throw error } 15 | const state = { state: 'state' } 16 | const ownProps = { ownProps: 'ownProps' } 17 | const mapStateToProps = safeMapStateToProps(func, errorHandler) 18 | mapStateToProps(state, ownProps) 19 | 20 | assert.deepEqual(errorHandler.args[0][0], error) 21 | assert.deepEqual(errorHandler.args[0][1], { state, ownProps }) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /src/store/configure-store.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill' 2 | import { createStore, applyMiddleware } from 'redux' 3 | import reduxCatch from 'redux-catch' 4 | import createSagaMiddleware from 'redux-saga' 5 | import createLogger from 'redux-logger' 6 | import rootReducer from 'reducers/root' 7 | import rootSaga from 'sagas/root' 8 | import Logger from 'utils/logger' 9 | 10 | const errorHandler = (error, getState) => Logger.error(error, getState()) 11 | 12 | const sagaMiddleware = createSagaMiddleware() 13 | 14 | export default () => { 15 | const store = createStore( 16 | rootReducer, 17 | applyMiddleware( 18 | reduxCatch(errorHandler), 19 | sagaMiddleware, 20 | createLogger({ collapsed: true }) 21 | ) 22 | ) 23 | 24 | sagaMiddleware.run(rootSaga) 25 | 26 | return store 27 | } 28 | -------------------------------------------------------------------------------- /src/reducers/throw-error/throw-error.js: -------------------------------------------------------------------------------- 1 | import { 2 | THROW_ERROR_IN_COMPONENTS, 3 | THROW_ERROR_IN_REDUCERS, 4 | THROW_ERROR_IN_SELECTORS, 5 | } from 'constants/action-types' 6 | 7 | export default (state = {}, action) => { 8 | switch (action.type) { 9 | case THROW_ERROR_IN_COMPONENTS: 10 | return { componentsShouldThrow: true } 11 | 12 | case THROW_ERROR_IN_REDUCERS: 13 | throw new Error('Reducer error') 14 | 15 | case THROW_ERROR_IN_SELECTORS: 16 | return { selectorsShouldThrow: true } 17 | 18 | default: 19 | return state 20 | } 21 | } 22 | 23 | export const getComponentsShouldThrow = state => state.throwError.componentsShouldThrow 24 | 25 | export const getSelectorsShouldThrow = state => { 26 | if (state.throwError.selectorsShouldThrow) { 27 | throw new Error('Selector error') 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/bind-action-creators/bind-action-creators.js: -------------------------------------------------------------------------------- 1 | import { bindActionCreators as originalBindActionCreators } from 'redux' 2 | 3 | const wrap = (func, errorHandler, action) => ( 4 | (...args) => { 5 | try { 6 | return func.apply(null, args) 7 | } catch (error) { 8 | return errorHandler(error, { action }) 9 | } 10 | } 11 | ) 12 | 13 | export default (actionCreators, dispatch, errorHandler) => { 14 | let boundActionCreators = originalBindActionCreators(actionCreators, dispatch) 15 | 16 | if (typeof boundActionCreators === 'function') { 17 | boundActionCreators = wrap(boundActionCreators, errorHandler, actionCreators.name) 18 | } else { 19 | Object.keys(boundActionCreators).forEach(key => { 20 | boundActionCreators[key] = wrap(boundActionCreators[key], errorHandler, key) 21 | }) 22 | } 23 | 24 | return boundActionCreators 25 | } 26 | -------------------------------------------------------------------------------- /src/components/react-component-errors/react-component-errors.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react' 2 | import wrapReactLifecycleMethodsWithTryCatch, { config } from 'react-component-errors' 3 | import Logger from 'utils/logger' 4 | 5 | config.errorHandler = errorReport => Logger.error(errorReport.error, errorReport) 6 | 7 | class ReactComponentErrors extends Component { 8 | 9 | componentWillReceiveProps(nextProps) { 10 | this.throwError(nextProps) 11 | } 12 | 13 | throwError(props) { 14 | if (props.componentsShouldThrow) { 15 | throw new Error('Component error') 16 | } 17 | } 18 | 19 | render() { 20 | return
ReactComponentErrors
21 | } 22 | 23 | } 24 | 25 | ReactComponentErrors.propTypes = { 26 | componentsShouldThrow: PropTypes.bool, 27 | } 28 | 29 | wrapReactLifecycleMethodsWithTryCatch(ReactComponentErrors) 30 | 31 | export default ReactComponentErrors 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Michele Bertoli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/sagas/api/api.spec.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill' 2 | import assert from 'assert' 3 | import { call, put } from 'redux-saga/effects' 4 | import * as ApiActions from 'actions/api' 5 | import httpbin from 'utils/httpbin' 6 | import { httpbinGet } from 'sagas/api' 7 | 8 | describe('httpbinGet', () => { 9 | const options = { options: 'options' } 10 | 11 | it('fires the http call', () => { 12 | const generator = httpbinGet({ options }) 13 | 14 | assert.deepEqual(generator.next().value, call(httpbin.get, options)) 15 | }) 16 | 17 | it('puts the success action', () => { 18 | const generator = httpbinGet({ options }) 19 | 20 | assert.deepEqual(generator.next().value, call(httpbin.get, options)) 21 | assert.deepEqual(generator.next({ response: 'response' }).value, put(ApiActions.getSuccess())) 22 | }) 23 | 24 | it('puts the fail action', () => { 25 | const generator = httpbinGet({ options }) 26 | const error = { error: 'error' } 27 | 28 | assert.deepEqual(generator.next().value, call(httpbin.get, options)) 29 | assert.deepEqual(generator.next({ error }).value, put(ApiActions.getFailure(error))) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /test/actions/api/api.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { API_GET, API_GET_SUCCESS, API_GET_FAILURE } from 'constants/action-types' 3 | import * as ApiActions from 'actions/api' 4 | 5 | describe('api', () => { 6 | describe('getSuccess', () => { 7 | it('creates a get success action', () => { 8 | const response = 'response' 9 | const expected = { 10 | type: API_GET_SUCCESS, 11 | response, 12 | } 13 | 14 | assert.deepEqual(ApiActions.getSuccess(response), expected) 15 | }) 16 | }) 17 | 18 | describe('getFailure', () => { 19 | it('creates a get fail action', () => { 20 | const error = 'error' 21 | const expected = { 22 | type: API_GET_FAILURE, 23 | error, 24 | } 25 | 26 | assert.deepEqual(ApiActions.getFailure(error), expected) 27 | }) 28 | }) 29 | 30 | describe('getStatus', () => { 31 | it('creates a get status action', () => { 32 | const status = 'status' 33 | const expected = { 34 | type: API_GET, 35 | options: { status }, 36 | } 37 | 38 | assert.deepEqual(ApiActions.getStatus(status), expected) 39 | }) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /test/utils/bind-action-creators/bind-action-creators.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import sinon from 'sinon' 3 | import safeBindActionCreators from 'utils/bind-action-creators' 4 | 5 | describe('safeBindActionCreators', () => { 6 | const error = new Error('test') 7 | const dispatch = func => func 8 | let errorHandler 9 | 10 | beforeEach(() => { 11 | errorHandler = sinon.spy() 12 | }) 13 | 14 | it('wraps a single function', () => { 15 | const actionCreators = () => { throw error } 16 | const boundActionCreators = safeBindActionCreators(actionCreators, dispatch, errorHandler) 17 | boundActionCreators() 18 | 19 | assert.deepEqual(errorHandler.args[0][0], error) 20 | assert.deepEqual(errorHandler.args[0][1], { action: 'actionCreators' }) 21 | }) 22 | 23 | it('wraps multiples functions', () => { 24 | const actionCreators = { 25 | actionCreator: () => { throw error }, 26 | } 27 | const boundActionCreators = safeBindActionCreators(actionCreators, dispatch, errorHandler) 28 | boundActionCreators.actionCreator() 29 | 30 | assert.deepEqual(errorHandler.args[0][0], error) 31 | assert.deepEqual(errorHandler.args[0][1], { action: 'actionCreator' }) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /test/actions/throw-error/throw-error.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { 3 | THROW_ERROR_IN_COMPONENTS, 4 | THROW_ERROR_IN_REDUCERS, 5 | THROW_ERROR_IN_SAGAS, 6 | THROW_ERROR_IN_SELECTORS, 7 | } from 'constants/action-types' 8 | import * as ThrowErrorActions from 'actions/throw-error' 9 | 10 | describe('throw error', () => { 11 | describe('inComponents', () => { 12 | it('creates a throw error in components action', () => { 13 | const expected = { 14 | type: THROW_ERROR_IN_COMPONENTS, 15 | } 16 | 17 | assert.deepEqual(ThrowErrorActions.inComponents(), expected) 18 | }) 19 | }) 20 | 21 | describe('inActions', () => { 22 | it('throws an error', () => { 23 | assert.throws(() => ThrowErrorActions.inActions(), /Action error/) 24 | }) 25 | }) 26 | 27 | describe('inReducers', () => { 28 | it('creates a throw error in reducers action', () => { 29 | const expected = { 30 | type: THROW_ERROR_IN_REDUCERS, 31 | } 32 | 33 | assert.deepEqual(ThrowErrorActions.inReducers(), expected) 34 | }) 35 | }) 36 | 37 | describe('inSagas', () => { 38 | it('creates a throw error in sagas action', () => { 39 | const expected = { 40 | type: THROW_ERROR_IN_SAGAS, 41 | } 42 | 43 | assert.deepEqual(ThrowErrorActions.inSagas(), expected) 44 | }) 45 | }) 46 | 47 | describe('inSelectors', () => { 48 | it('creates a throw error in selectors action', () => { 49 | const expected = { 50 | type: THROW_ERROR_IN_SELECTORS, 51 | } 52 | 53 | assert.deepEqual(ThrowErrorActions.inSelectors(), expected) 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /test/reducers/throw-error/throw-error.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { 3 | THROW_ERROR_IN_COMPONENTS, 4 | THROW_ERROR_IN_REDUCERS, 5 | THROW_ERROR_IN_SELECTORS, 6 | } from 'constants/action-types' 7 | import throwError, { getComponentsShouldThrow, getSelectorsShouldThrow } from 'reducers/throw-error' 8 | 9 | describe('throw error', () => { 10 | it('returns the initial state', () => { 11 | assert.deepEqual(throwError(undefined, {}), {}) 12 | }) 13 | 14 | it('handles THROW_ERROR_IN_COMPONENTS', () => { 15 | const action = { 16 | type: THROW_ERROR_IN_COMPONENTS, 17 | } 18 | const expected = { componentsShouldThrow: true } 19 | 20 | assert.deepEqual(throwError(undefined, action), expected) 21 | }) 22 | 23 | it('throws an error', () => { 24 | const action = { 25 | type: THROW_ERROR_IN_REDUCERS, 26 | } 27 | 28 | assert.throws(() => throwError(undefined, action), /Reducer error/) 29 | }) 30 | 31 | it('handles THROW_ERROR_IN_SELECTORS', () => { 32 | const action = { 33 | type: THROW_ERROR_IN_SELECTORS, 34 | } 35 | const expected = { selectorsShouldThrow: true } 36 | 37 | assert.deepEqual(throwError(undefined, action), expected) 38 | }) 39 | }) 40 | 41 | describe('getComponentsShouldThrow', () => { 42 | it('returns componentsShouldThrow', () => { 43 | const state = { 44 | throwError: { componentsShouldThrow: true }, 45 | } 46 | 47 | assert.equal(getComponentsShouldThrow(state), true) 48 | }) 49 | }) 50 | 51 | describe('getSelectorsShouldThrow', () => { 52 | it('throws an error', () => { 53 | const state = { 54 | throwError: { selectorsShouldThrow: true }, 55 | } 56 | 57 | assert.throws(() => getSelectorsShouldThrow(state), /Selector error/) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /src/containers/app/app.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import { connect } from 'react-redux' 3 | import * as ThrowErrorActions from 'actions/throw-error' 4 | import { getComponentsShouldThrow, getSelectorsShouldThrow } from 'reducers/throw-error' 5 | import * as ApiActions from 'actions/api' 6 | import ReactComponentErrors from 'components/react-component-errors' 7 | import ReactPoop from 'components/react-poop' 8 | import Logger from 'utils/logger' 9 | import safeBindActionCreators from 'utils/bind-action-creators' 10 | import safeMapStateToProps from 'utils/map-state-to-props' 11 | 12 | export const App = props => ( 13 |
14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 |
23 |
24 | 25 | 26 |
27 |
28 | ) 29 | 30 | App.propTypes = { 31 | apiActions: PropTypes.object, 32 | componentsShouldThrow: PropTypes.bool, 33 | throwErrorActions: PropTypes.object, 34 | } 35 | 36 | const mapStateToProps = safeMapStateToProps(state => ({ 37 | componentsShouldThrow: getComponentsShouldThrow(state), 38 | selectorsShouldThrow: getSelectorsShouldThrow(state), 39 | }), Logger.error) 40 | 41 | const mapDispatchToProps = dispatch => ({ 42 | apiActions: safeBindActionCreators(ApiActions, dispatch, Logger.error), 43 | throwErrorActions: safeBindActionCreators(ThrowErrorActions, dispatch, Logger.error), 44 | }) 45 | 46 | export default connect(mapStateToProps, mapDispatchToProps)(App) 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-redux-error-handling", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack -p --config webpack/production.config.js", 8 | "lint": "eslint ./src", 9 | "start": "webpack-dev-server --config webpack/development.config.js --content-base build", 10 | "test": "NODE_PATH=./src mocha --compilers js:babel-register --reporter emoji-reporter ./test/**/*.spec.js", 11 | "test:watch": "npm test -- --watch" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+ssh://git@gitlab.com/MicheleBertoli/react-redux-error-handling.git" 16 | }, 17 | "author": "", 18 | "license": "ISC", 19 | "bugs": { 20 | "url": "https://gitlab.com/MicheleBertoli/react-redux-error-handling/issues" 21 | }, 22 | "homepage": "https://gitlab.com/MicheleBertoli/react-redux-error-handling#README", 23 | "dependencies": { 24 | "babel-polyfill": "^6.13.0", 25 | "isomorphic-fetch": "^2.2.1", 26 | "raven-js": "^3.6.1", 27 | "react": "^15.3.1", 28 | "react-component-errors": "0.0.6", 29 | "react-dom": "^15.3.1", 30 | "react-poop": "^0.2.0", 31 | "react-redux": "^4.4.5", 32 | "redux": "^3.6.0", 33 | "redux-catch": "^1.2.0", 34 | "redux-logger": "^2.6.1", 35 | "redux-saga": "^0.11.1", 36 | "stacktrace-js": "^1.3.1" 37 | }, 38 | "devDependencies": { 39 | "babel-core": "^6.14.0", 40 | "babel-loader": "^6.2.5", 41 | "babel-plugin-transform-object-rest-spread": "^6.8.0", 42 | "babel-preset-es2015": "^6.14.0", 43 | "babel-preset-react": "^6.11.1", 44 | "babel-register": "^6.14.0", 45 | "emoji-reporter": "^0.3.0", 46 | "enzyme": "^2.4.1", 47 | "eslint": "^2.13.1", 48 | "eslint-config-airbnb": "^9.0.1", 49 | "eslint-import-resolver-webpack": "^0.3.1", 50 | "eslint-plugin-import": "^1.9.2", 51 | "eslint-plugin-jsx-a11y": "^1.5.3", 52 | "eslint-plugin-react": "^5.2.2", 53 | "html-webpack-plugin": "^2.22.0", 54 | "mocha": "^3.0.2", 55 | "react-addons-test-utils": "^15.3.1", 56 | "sinon": "^1.17.5", 57 | "webpack": "^1.13.2", 58 | "webpack-dev-server": "^1.15.1" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/containers/app/app.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import sinon from 'sinon' 3 | import { shallow } from 'enzyme' 4 | import React from 'react' 5 | import { App } from 'containers/app' 6 | import ReactPoop from 'components/react-poop' 7 | 8 | describe('', () => { 9 | let props 10 | 11 | beforeEach(() => { 12 | props = { 13 | componentsShouldThrow: true, 14 | throwErrorActions: { 15 | inComponents: sinon.spy(), 16 | inReducers: sinon.spy(), 17 | inSagas: sinon.spy(), 18 | inActions: sinon.spy(), 19 | inSelectors: sinon.spy(), 20 | }, 21 | apiActions: { 22 | getStatus: sinon.spy(), 23 | }, 24 | } 25 | }) 26 | 27 | it('passes the componentsShouldThrow prop to ReactComponentErrors', () => { 28 | const wrapper = shallow() 29 | const element = wrapper.find('ReactComponentErrors') 30 | 31 | assert.equal(element.prop('componentsShouldThrow'), props.componentsShouldThrow) 32 | }) 33 | 34 | it('passes the componentsShouldThrow prop to ReactPoop', () => { 35 | const wrapper = shallow() 36 | const element = wrapper.find(ReactPoop) 37 | 38 | assert.equal(element.prop('componentsShouldThrow'), props.componentsShouldThrow) 39 | }) 40 | 41 | it('fires the inComponents action', () => { 42 | const wrapper = shallow() 43 | const element = wrapper.find('button').at(0) 44 | element.simulate('click') 45 | 46 | assert(props.throwErrorActions.inComponents.called) 47 | }) 48 | 49 | it('fires the inActions action', () => { 50 | const wrapper = shallow() 51 | const element = wrapper.find('button').at(1) 52 | element.simulate('click') 53 | 54 | assert(props.throwErrorActions.inActions.called) 55 | }) 56 | 57 | it('fires the inReducers action', () => { 58 | const wrapper = shallow() 59 | const element = wrapper.find('button').at(2) 60 | element.simulate('click') 61 | 62 | assert(props.throwErrorActions.inReducers.called) 63 | }) 64 | 65 | it('fires the inSagas action', () => { 66 | const wrapper = shallow() 67 | const element = wrapper.find('button').at(3) 68 | element.simulate('click') 69 | 70 | assert(props.throwErrorActions.inSagas.called) 71 | }) 72 | 73 | it('fires the inSelectors action', () => { 74 | const wrapper = shallow() 75 | const element = wrapper.find('button').at(4) 76 | element.simulate('click') 77 | 78 | assert(props.throwErrorActions.inSelectors.called) 79 | }) 80 | 81 | it('fires the getStatus 404 action', () => { 82 | const wrapper = shallow() 83 | const element = wrapper.find('button').at(5) 84 | element.simulate('click') 85 | 86 | assert(props.apiActions.getStatus.called) 87 | assert.equal(props.apiActions.getStatus.args[0][0], 404) 88 | }) 89 | 90 | it('fires the getStatus 500 action', () => { 91 | const wrapper = shallow() 92 | const element = wrapper.find('button').at(6) 93 | element.simulate('click') 94 | 95 | assert(props.apiActions.getStatus.called) 96 | assert.equal(props.apiActions.getStatus.args[0][0], 500) 97 | }) 98 | }) 99 | --------------------------------------------------------------------------------