From a70f5b3e428fe99d2b3b18c42127463bd5a4f6ba Mon Sep 17 00:00:00 2001 From: Erik Michaelson Date: Fri, 17 Jun 2016 13:18:31 -0400 Subject: [PATCH 1/2] Add create/get/keys options. Warnings/errors for various new situations. Leave old warnings/errors in tact. --- src/combineReducers.js | 55 ++++++++++++++++++++++++-------- test/combineReducers.spec.js | 61 ++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 13 deletions(-) diff --git a/src/combineReducers.js b/src/combineReducers.js index ad2d48439e..b5a4e2b292 100644 --- a/src/combineReducers.js +++ b/src/combineReducers.js @@ -1,5 +1,4 @@ import { ActionTypes } from './createStore' -import isPlainObject from 'lodash/isPlainObject' import warning from './utils/warning' function getUndefinedStateErrorMessage(key, action) { @@ -12,8 +11,7 @@ function getUndefinedStateErrorMessage(key, action) { ) } -function getUnexpectedStateShapeWarningMessage(inputState, reducers, action) { - var reducerKeys = Object.keys(reducers) +function getUnexpectedStateShapeWarningMessage(inputState, keysMethod, reducers, reducerKeys, action) { var argumentName = action && action.type === ActionTypes.INIT ? 'preloadedState argument passed to createStore' : 'previous state received by the reducer' @@ -25,7 +23,7 @@ function getUnexpectedStateShapeWarningMessage(inputState, reducers, action) { ) } - if (!isPlainObject(inputState)) { + if (typeof inputState !== 'object') { return ( `The ${argumentName} has unexpected type of "` + ({}).toString.call(inputState).match(/\s([a-z|A-Z]+)/)[1] + @@ -34,7 +32,14 @@ function getUnexpectedStateShapeWarningMessage(inputState, reducers, action) { ) } - var unexpectedKeys = Object.keys(inputState).filter(key => !reducers.hasOwnProperty(key)) + try { + var unexpectedKeys = keysMethod(inputState).filter(key => !reducers.hasOwnProperty(key)) + } catch (e) { + return ( + `The provided options.keys failed on ${argumentName}. ` + + 'Could not check for unexpectedKeys.' + ) + } if (unexpectedKeys.length > 0) { return ( @@ -87,10 +92,23 @@ function assertReducerSanity(reducers) { * if the state passed to them was undefined, and the current state for any * unrecognized action. * + * @param {Object} [options={ + * create: (obj) => obj, + * get: (obj, key) => obj[key], + * keys: (obj) => Object.keys(obj) + * }] An optional object that defines how to create a new state, how to + * get a property from the state object, and how to iterate over the keys + * of a state object. + * * @returns {Function} A reducer function that invokes every reducer inside the * passed object, and builds a state object with the same shape. */ -export default function combineReducers(reducers) { +export default function combineReducers(reducers, options) { + options = Object.assign({ + create: (obj) => obj, + get: (obj, key) => obj[key], + keys: (obj) => Object.keys(obj) + }, options) var reducerKeys = Object.keys(reducers) var finalReducers = {} for (var i = 0; i < reducerKeys.length; i++) { @@ -108,32 +126,43 @@ export default function combineReducers(reducers) { sanityError = e } - return function combination(state = {}, action) { + return function combination(state = options.create({}), action) { if (sanityError) { throw sanityError } if (process.env.NODE_ENV !== 'production') { - var warningMessage = getUnexpectedStateShapeWarningMessage(state, finalReducers, action) + var warningMessage = getUnexpectedStateShapeWarningMessage(state, options.keys, finalReducers, finalReducerKeys, action) if (warningMessage) { warning(warningMessage) } } var hasChanged = false - var nextState = {} + var nextStateDescriptor = {} for (var i = 0; i < finalReducerKeys.length; i++) { var key = finalReducerKeys[i] var reducer = finalReducers[key] - var previousStateForKey = state[key] - var nextStateForKey = reducer(previousStateForKey, action) + + try { + var previousStateForKey = options.get(state, key) + } catch (e) { + throw new Error(`Could not get key "${key}" using ${options.get}`) + } + + try { + var nextStateForKey = reducer(previousStateForKey, action) + } catch (e) { + throw new Error(`Reducer for key "${key}" failed`) + } + if (typeof nextStateForKey === 'undefined') { var errorMessage = getUndefinedStateErrorMessage(key, action) throw new Error(errorMessage) } - nextState[key] = nextStateForKey + nextStateDescriptor[key] = nextStateForKey hasChanged = hasChanged || nextStateForKey !== previousStateForKey } - return hasChanged ? nextState : state + return hasChanged ? options.create(nextStateDescriptor) : state } } diff --git a/test/combineReducers.spec.js b/test/combineReducers.spec.js index d861ba371f..4a8bb979b1 100644 --- a/test/combineReducers.spec.js +++ b/test/combineReducers.spec.js @@ -1,5 +1,6 @@ import expect from 'expect' import { combineReducers } from '../src' +import Immutable from 'immutable' import createStore, { ActionTypes } from '../src/createStore' describe('Utils', () => { @@ -31,6 +32,66 @@ describe('Utils', () => { ).toEqual([ 'stack' ]) }) + it('can use custom create/get/keys methods', () => { + const create = (obj) => + obj instanceof Immutable.Map ? obj : new Immutable.Map(obj) + const get = (obj, key) => + obj.get(key) + const keys = (obj) => + obj.keySeq().toArray() + const stack = (state = [], action) => + action.type === 'PUSH' ? state.concat(action.value) : state + const PUSH_ONE = { type: 'PUSH', value: 1 } + + const reducer = combineReducers({ stack }, { create, get, keys }) + + const s1 = reducer(undefined, PUSH_ONE) + expect(s1.get('stack')).toEqual( [ 1 ] ) + + const spy = expect.spyOn(console, 'error') + + // throws for non-objects + expect( + () => reducer(2, PUSH_ONE) + ).toThrow( + /Could not get key "stack"/ + ) + expect(spy.calls[0].arguments[0]).toMatch( + /The previous state received.*type of "Number"/ + ) + + // throws if it can't get a prop + expect(() => + reducer({ stack: [] }, PUSH_ONE) + ).toThrow( + /Could not get key "stack".*/ + ) + expect(spy.calls[1].arguments[0]).toMatch( + /The provided options.keys failed.*/ + ) + + // warns when it gets an unexpected key + reducer(create({ boof: 1 }), PUSH_ONE) + expect(spy.calls[2].arguments[0]).toMatch( + /Unexpected key "boof".*/ + ) + + // warns when it can't check for unexpectedKeys + reducer({ get: () => [] }, PUSH_ONE) + expect(spy.calls[3].arguments[0]).toMatch( + /The provided options.keys failed on previous state.*/ + ) + + // warns unexpected keys if it tries default key iterator + const reducer2 = combineReducers({ stack }, { create, get }) // no keys + reducer2(create(), {}) + expect(spy.calls[4].arguments[0]).toMatch( + /Unexpected keys "size".*/ + ) + + spy.restore() + }) + it('throws an error if a reducer returns undefined handling an action', () => { const reducer = combineReducers({ counter(state = 0, action) { From 72029b8a86f6b933b720233483d6267835eb6836 Mon Sep 17 00:00:00 2001 From: Erik Michaelson Date: Fri, 17 Jun 2016 16:13:27 -0400 Subject: [PATCH 2/2] Add immutable to devDependencies --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 2c0803da72..b4981f86f0 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "expect": "^1.8.0", "gitbook-cli": "^0.3.4", "glob": "^6.0.4", + "immutable": "3.8.1", "isparta": "^4.0.0", "mocha": "^2.2.5", "rimraf": "^2.3.4",