├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── package.json ├── src ├── combineReducers.js ├── index.js ├── routerReducer.js ├── stateTransformer.js └── utils │ └── combineReducersValidation.js └── test ├── combineReducers.spec.js └── reducer.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "browser": true, 5 | "node": true, 6 | "es6": true 7 | }, 8 | 9 | "parser": "babel-eslint" 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | *.log -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .eslintrc 2 | test -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Edward Stone 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-seamless-immutable 2 | 3 | Helpers for using [`seamless-immutable`](https://github.com/rtfeldman/seamless-immutable) in [Redux](http://redux.js.org). Provides a compatible `combineReducers` and `routerReducer` (for use with `react-router-redux`). 4 | 5 | ## Installation 6 | 7 | $ npm install redux-seamless-immutable 8 | 9 | ## Usage 10 | 11 | ```javascript 12 | import { combineReducers, routerReducer, stateTransformer } from 'redux-seamless-immutable' 13 | import { createStore, applyMiddleware } from 'redux' 14 | import createLogger from 'redux-logger' 15 | 16 | import reducer from './reducers' 17 | 18 | const rootReducer = combineReducers({ 19 | reducer, 20 | routing: routerReducer 21 | }) 22 | 23 | const loggerMiddleware = createLogger({ 24 | stateTransformer: stateTransformer 25 | }) 26 | 27 | const store = createStore( 28 | rootReducer, 29 | applyMiddleware( 30 | loggerMiddleware 31 | ) 32 | ) 33 | ``` 34 | 35 | ## API 36 | 37 | #### `combineReducers(reducers)` 38 | 39 | A `seamless-immutable` compatible [`combineReducers`](http://redux.js.org/docs/api/combineReducers.html). 40 | 41 | #### `routerReducer(state, action)` 42 | 43 | A `seamless-immutable` compatible replacement for the [`routerReducer`](https://github.com/reactjs/react-router-redux#routerreducer) from [react-router-redux](https://github.com/reactjs/react-router-redux). 44 | 45 | #### `stateTransformer(state)` 46 | 47 | A [`stateTransformer`](https://github.com/fcomb/redux-logger#statetransformer--state-object--state) for the [`redux-logger`](https://github.com/fcomb/redux-logger) middleware to convert an `Immutable` store to a plain JS object. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-seamless-immutable", 3 | "version": "0.4.0", 4 | "description": "Helpers for using seamless-immutable with Redux", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "lint": "eslint src", 8 | "build": "rimraf lib && babel src --out-dir lib", 9 | "build:watch": "watch 'npm run build' ./src/", 10 | "prepublish": "npm run build", 11 | "test": "mocha --compilers js:babel-core/register --recursive ./test" 12 | }, 13 | "babel": { 14 | "presets": [ 15 | "es2015" 16 | ] 17 | }, 18 | "keywords": [ 19 | "javascript", 20 | "redux", 21 | "immutable", 22 | "seamless", 23 | "router" 24 | ], 25 | "author": "Edward Stone ", 26 | "repository": "https://github.com/eadmundo/redux-seamless-immutable", 27 | "bugs": { 28 | "url": "https://github.com/eadmundo/redux-seamless-immutable/issues" 29 | }, 30 | "homepage": "https://github.com/eadmundo/redux-seamless-immutable", 31 | "license": "MIT", 32 | "dependencies": { 33 | "react-router-redux": "^4.0.0", 34 | "seamless-immutable": "^7.1.2" 35 | }, 36 | "devDependencies": { 37 | "babel-cli": "^6.6.5", 38 | "babel-eslint": "^5.0.0", 39 | "babel-loader": "^6.2.4", 40 | "babel-preset-es2015": "^6.6.0", 41 | "babel-preset-react": "^6.5.0", 42 | "babel-preset-stage-0": "^6.5.0", 43 | "chai": "^3.5.0", 44 | "clean-webpack-plugin": "^0.1.8", 45 | "eslint": "~2.2.0", 46 | "html-webpack-plugin": "^2.14.0", 47 | "mocha": "^2.4.5", 48 | "moment": "^2.12.0", 49 | "moment-timezone": "^0.5.2", 50 | "react": "^0.14.7", 51 | "react-dom": "^0.14.7", 52 | "react-redux": "^4.4.1", 53 | "redux": "^3.3.1", 54 | "rimraf": "^2.5.2", 55 | "watch": "^0.17.1", 56 | "webpack": "^1.12.14", 57 | "webpack-hot-middleware": "^2.10.0" 58 | }, 59 | "npmName": "redux-seamless-immutable", 60 | "npmFileMap": [ 61 | { 62 | "basePath": "/lib/", 63 | "files": [ 64 | "*.js" 65 | ] 66 | } 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /src/combineReducers.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | import { 3 | assertReducerShape, 4 | getUndefinedStateError, 5 | getNoValidReducersError, 6 | getNotSupportedTypeAsReducerError, 7 | getPossibleUnexpectedStateShapeWarning 8 | } from './utils/combineReducersValidation' 9 | 10 | export default function combineReducers(reducers) { 11 | // Validate reducers. 12 | const validReducers = Object.keys(reducers).reduce((accum, key) => { 13 | // A reducer must be a function. 14 | if (typeof reducers[key] !== 'function') { 15 | if (process.env.NODE_ENV !== 'production') { 16 | const errorMessage = getNotSupportedTypeAsReducerError(reducers, key) 17 | throw new Error(errorMessage) 18 | } 19 | return accum 20 | } 21 | 22 | return accum.set(key, reducers[key]) 23 | }, Immutable({})) 24 | 25 | const validReducerKeys = Object.keys(validReducers) 26 | 27 | if (process.env.NODE_ENV !== 'production') { 28 | if (validReducerKeys.length === 0) { 29 | const errorMessage = getNoValidReducersError() 30 | throw new Error(errorMessage) 31 | } 32 | } 33 | 34 | let shapeAssertionError 35 | try { 36 | assertReducerShape(validReducers) 37 | } catch (e) { 38 | shapeAssertionError = e 39 | } 40 | 41 | return function combination(state = Immutable({}), action) { 42 | if (shapeAssertionError) { 43 | throw new Error(shapeAssertionError) 44 | } 45 | 46 | if (process.env.NODE_ENV !== 'production') { 47 | const warningMessage = getPossibleUnexpectedStateShapeWarning(state, validReducers, action) 48 | if (warningMessage) { 49 | // eslint-disable-next-line no-console 50 | console.warn(warningMessage) 51 | } 52 | } 53 | 54 | return validReducerKeys.reduce((nextState, key) => { 55 | const nextDomainState = validReducers[key](state[key], action) 56 | 57 | // Validate the next state; it cannot be undefined. 58 | if (typeof nextDomainState === 'undefined') { 59 | const errorMessage = getUndefinedStateError(key, action) 60 | throw new Error(errorMessage) 61 | } 62 | 63 | return nextState.set(key, nextDomainState) 64 | }, Immutable(state)) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import combineReducers from './combineReducers'; 2 | import routerReducer from './routerReducer'; 3 | import stateTransformer from './stateTransformer'; 4 | 5 | export { 6 | combineReducers, 7 | routerReducer, 8 | stateTransformer 9 | } -------------------------------------------------------------------------------- /src/routerReducer.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | import { LOCATION_CHANGE } from 'react-router-redux'; 3 | 4 | const initialState = Immutable({ 5 | locationBeforeTransitions: null 6 | }); 7 | 8 | export default function routerReducer(state=initialState, { type, payload }) { 9 | if (type === LOCATION_CHANGE) { 10 | return state.set('locationBeforeTransitions', payload); 11 | } 12 | return state 13 | } -------------------------------------------------------------------------------- /src/stateTransformer.js: -------------------------------------------------------------------------------- 1 | export default function stateTransformer(state) { 2 | return state.asMutable({deep: true}); 3 | } -------------------------------------------------------------------------------- /src/utils/combineReducersValidation.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable' 2 | 3 | const REDUX_INIT_ACTION_TYPE = '@@redux/INIT' 4 | 5 | export function getUndefinedStateError (reducerKey, action) { 6 | const actionType = action && action.type ? `"${action.type}"` : 'an' 7 | 8 | return ( 9 | `Reducer "${reducerKey}" returned undefined when handling ${actionType} ` + 10 | `action. To ignore an action, you must explicitly return the previous state.` 11 | ) 12 | } 13 | 14 | export function getNoValidReducersError () { 15 | return ( 16 | 'Store does not have a valid reducer. Make sure the argument passed ' + 17 | 'to combineReducers is an object whose values are reducers.' 18 | ) 19 | } 20 | 21 | export function getNotSupportedTypeAsReducerError (reducers, reducerKey) { 22 | return ( 23 | `"${typeof reducers[reducerKey]}" is not a supported type for reducer "${reducerKey}". ` + 24 | 'A reducer must be a function.' 25 | ) 26 | } 27 | 28 | export function getPossibleUnexpectedStateShapeWarning (state, reducers, action) { 29 | const reducerKeys = Object.keys(reducers) 30 | const stateName = action && action.type === REDUX_INIT_ACTION_TYPE 31 | ? 'preloadedState argument passed to createStore' 32 | : 'previous state received by the reducer' 33 | 34 | if (!Immutable.isImmutable(state)) { 35 | return ( 36 | `The ${stateName} is of an unexpected type. Expected state to be an instance of a ` + 37 | `Seamless Immutable object with the following properties: "${reducerKeys.join('", "')}".` 38 | ) 39 | } 40 | 41 | const unexpectedKeys = Object.keys(state).filter(key => !reducers.hasOwnProperty(key)) 42 | 43 | if (unexpectedKeys.length > 0) { 44 | return ( 45 | `Unexpected ${unexpectedKeys.length > 1 ? 'keys' : 'key'} "${unexpectedKeys.join('", "')}" ` + 46 | `found in ${stateName}. Expected to find one of the known reducer keys instead: ` + 47 | `"${reducerKeys.join('", "')}". Unexpected keys will be ignored.` 48 | ) 49 | } 50 | } 51 | 52 | export function assertReducerShape (reducers) { 53 | Object.keys(reducers).forEach(key => { 54 | const reducer = reducers[key] 55 | const initialState = reducer(undefined, { type: REDUX_INIT_ACTION_TYPE }) 56 | 57 | if (typeof initialState === 'undefined') { 58 | throw new Error( 59 | `Reducer "${key}" returned undefined during initialization. ` + 60 | `If the state passed to the reducer is undefined, you must ` + 61 | `explicitly return the initial state. The initial state may ` + 62 | `not be undefined. If you don't want to set a value for this reducer, ` + 63 | `you can use null instead of undefined.` 64 | ) 65 | } 66 | 67 | const type = '@@redux/PROBE_UNKNOWN_ACTION_' + Math.random().toString(36).substring(7).split('').join('.') 68 | if (typeof reducer(undefined, { type }) === 'undefined') { 69 | throw new Error( 70 | `Reducer "${key}" returned undefined when probed with a random type. ` + 71 | `Don't try to handle ${REDUX_INIT_ACTION_TYPE} or other actions in "redux/*" ` + 72 | `namespace. They are considered private. Instead, you must return the ` + 73 | `current state for any unknown actions, unless it is undefined, ` + 74 | `in which case you must return the initial state, regardless of the ` + 75 | `action type. The initial state may not be undefined, but can be null.` 76 | ) 77 | } 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /test/combineReducers.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import Immutable from 'seamless-immutable'; 3 | import combineReducers from '../src/combineReducers'; 4 | 5 | describe('combineReducers', () => { 6 | it('returns a composite reducer that maps the state keys to given reducers', () => { 7 | const reducer = combineReducers({ 8 | counter: (state = 0, action) => 9 | action.type === 'increment' ? state + 1 : state, 10 | stack: (state = [], action) => 11 | action.type === 'push' ? [ ...state, action.value ] : state 12 | }) 13 | 14 | const s1 = reducer(Immutable({}), { type: 'increment' }) 15 | expect(s1).to.eql({ counter: 1, stack: [] }) 16 | const s2 = reducer(s1, { type: 'push', value: 'a' }) 17 | expect(s2).to.eql({ counter: 1, stack: [ 'a' ] }) 18 | }) 19 | 20 | it('returns the same object instance if no state has changed', () => { 21 | const reducer = combineReducers({ 22 | doNothing1: state => state || Immutable({ hello: "world" }), 23 | doNothing2: state => state || Immutable({ one: 2 }) 24 | }) 25 | 26 | const s1 = reducer(Immutable({}), { type: "test1" }); 27 | const s2 = reducer(s1, { type: "test2" }); 28 | 29 | expect(s1).to.equal(s2); 30 | }) 31 | 32 | it('maintains referential equality if the reducers it is combining do', () => { 33 | const reducer = combineReducers({ 34 | child1 (state = {}) { 35 | return state 36 | }, 37 | child2 (state = {}) { 38 | return state 39 | }, 40 | child3 (state = {}) { 41 | return state 42 | } 43 | }) 44 | 45 | const initialState = reducer(Immutable({}), '@@INIT') 46 | expect(reducer(initialState, { type: 'FOO' })).to.equal(initialState) 47 | }) 48 | 49 | it('does not have referential equality if one of the reducers changes something', () => { 50 | const reducer = combineReducers({ 51 | child1 (state = {}) { 52 | return state 53 | }, 54 | child2 (state = { count: 0 }, action) { 55 | switch (action.type) { 56 | case 'increment': 57 | return { count: state.count + 1 } 58 | default: 59 | return state 60 | } 61 | }, 62 | child3 (state = {}) { 63 | return state 64 | } 65 | }) 66 | 67 | const initialState = reducer(undefined, '@@INIT') 68 | expect(reducer(initialState, { type: 'increment' })).to.not.equal(initialState) 69 | }) 70 | 71 | it('catches error thrown in reducer when initializing and re-throw', () => { 72 | const reducer = combineReducers({ 73 | throwingReducer () { 74 | throw new Error('Error thrown in reducer') 75 | } 76 | }) 77 | expect( 78 | () => reducer({}) 79 | ).to.throw( 80 | /Error thrown in reducer/ 81 | ) 82 | }) 83 | 84 | it('ignores all props which are not a function (in production)', () => { 85 | global.process.env.NODE_ENV = 'production'; 86 | 87 | const reducer = combineReducers({ 88 | fake: true, 89 | broken: 'string', 90 | another: { nested: 'object' }, 91 | stack: (state = []) => state 92 | }); 93 | 94 | const validReducerKeys = Object.keys(reducer(Immutable({}), { type: 'push' })) 95 | 96 | expect(validReducerKeys).to.deep.equal(['stack']) 97 | 98 | global.process.env.NODE_ENV = undefined 99 | }) 100 | 101 | it('throws an error if a reducer returns undefined handling an action', () => { 102 | const reducer = combineReducers({ 103 | counter (state = 0, action) { 104 | switch (action && action.type) { 105 | case 'increment': 106 | return state + 1 107 | case 'decrement': 108 | return state - 1 109 | case 'whatever': 110 | case null: 111 | case undefined: 112 | return undefined 113 | default: 114 | return state 115 | } 116 | } 117 | }) 118 | 119 | const state = Immutable({ counter: 0 }) 120 | 121 | expect( 122 | () => reducer(state, { type: 'whatever' }) 123 | ).to.throw( 124 | /"counter".*"whatever"/ 125 | ) 126 | expect( 127 | () => reducer(state, null) 128 | ).to.throw( 129 | /"counter".*an action/ 130 | ) 131 | expect( 132 | () => reducer(state, {}) 133 | ).to.throw( 134 | /"counter".*an action/ 135 | ) 136 | }) 137 | 138 | it('throws an error on first call if a reducer returns undefined initializing', () => { 139 | const reducer = combineReducers({ 140 | counter (state, action) { 141 | switch (action && action.type) { 142 | case 'increment': 143 | return state + 1 144 | case 'decrement': 145 | return state - 1 146 | default: 147 | return state 148 | } 149 | } 150 | }) 151 | 152 | expect( 153 | () => reducer(Immutable({}), 'asdf') 154 | ).to.throw( 155 | /"counter".*initialization/ 156 | ) 157 | }) 158 | 159 | it('allows a symbol to be used as an action type', () => { 160 | const increment = Symbol('INCREMENT') 161 | 162 | const reducer = combineReducers({ 163 | counter (state = 0, action) { 164 | switch (action.type) { 165 | case increment: 166 | return state + 1 167 | default: 168 | return state 169 | } 170 | } 171 | }) 172 | 173 | expect( 174 | reducer(Immutable({ counter: 0 }), { type: increment }).counter 175 | ).to.equal(1) 176 | }) 177 | 178 | it('throws an error on first call if a reducer attempts to handle a private action', () => { 179 | const reducer = combineReducers({ 180 | counter (state, action) { 181 | switch (action.type) { 182 | case 'increment': 183 | return state + 1 184 | case 'decrement': 185 | return state - 1 186 | // Never do this in your code: 187 | case '@@redux/INIT': 188 | return 0 189 | default: 190 | return undefined 191 | } 192 | } 193 | }) 194 | 195 | expect(() => reducer()).to.throw( 196 | /"counter".*private/ 197 | ) 198 | }) 199 | }) 200 | -------------------------------------------------------------------------------- /test/reducer.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import Immutable from 'seamless-immutable'; 3 | import routerReducer from '../src/routerReducer'; 4 | import { LOCATION_CHANGE } from 'react-router-redux'; 5 | 6 | describe('routerReducer', () => { 7 | const state = Immutable({ 8 | locationBeforeTransitions: { 9 | pathname: '/foo', 10 | action: 'POP' 11 | } 12 | }) 13 | 14 | it('updates the path', () => { 15 | expect(routerReducer(state, { 16 | type: LOCATION_CHANGE, 17 | payload: { 18 | path: '/bar', 19 | action: 'PUSH' 20 | } 21 | })).to.eql({ 22 | locationBeforeTransitions: { 23 | path: '/bar', 24 | action: 'PUSH' 25 | } 26 | }) 27 | }) 28 | 29 | it('works with initialState', () => { 30 | expect(routerReducer(undefined, { 31 | type: LOCATION_CHANGE, 32 | payload: { 33 | path: '/bar', 34 | action: 'PUSH' 35 | } 36 | })).to.eql({ 37 | locationBeforeTransitions: { 38 | path: '/bar', 39 | action: 'PUSH' 40 | } 41 | }) 42 | }) 43 | 44 | 45 | it('respects replace', () => { 46 | expect(routerReducer(state, { 47 | type: LOCATION_CHANGE, 48 | payload: { 49 | path: '/bar', 50 | action: 'REPLACE' 51 | } 52 | })).to.eql({ 53 | locationBeforeTransitions: { 54 | path: '/bar', 55 | action: 'REPLACE' 56 | } 57 | }) 58 | }) 59 | }) 60 | --------------------------------------------------------------------------------