From 984a0e7a80359b34ed55d73dc44d0b0800ad80a9 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Mon, 9 May 2016 04:47:17 +0100 Subject: [PATCH] Proof of concept: enhancer overhaul --- src/createStore.js | 229 ++++++++++------------------------- test/applyMiddleware.spec.js | 18 --- test/createStore.spec.js | 22 +++- 3 files changed, 80 insertions(+), 189 deletions(-) diff --git a/src/createStore.js b/src/createStore.js index 0c59bb0689..716710be80 100644 --- a/src/createStore.js +++ b/src/createStore.js @@ -1,64 +1,77 @@ import isPlainObject from 'lodash/isPlainObject' import $$observable from 'symbol-observable' -/** - * These are private action types reserved by Redux. - * For any unknown actions, you must return the current state. - * If the current state is undefined, you must return the initial state. - * Do not reference these action types directly in your code. - */ export var ActionTypes = { INIT: '@@redux/INIT' } -/** - * Creates a Redux store that holds the state tree. - * The only way to change the data in the store is to call `dispatch()` on it. - * - * There should only be a single store in your app. To specify how different - * parts of the state tree respond to actions, you may combine several reducers - * into a single reducer function by using `combineReducers`. - * - * @param {Function} reducer A function that returns the next state tree, given - * the current state tree and the action to handle. - * - * @param {any} [initialState] The initial state. You may optionally specify it - * to hydrate the state from the server in universal apps, or to restore a - * previously serialized user session. - * If you use `combineReducers` to produce the root reducer function, this must be - * an object with the same shape as `combineReducers` keys. - * - * @param {Function} enhancer The store enhancer. You may optionally specify it - * to enhance the store with third-party capabilities such as middleware, - * time travel, persistence, etc. The only store enhancer that ships with Redux - * is `applyMiddleware()`. - * - * @returns {Store} A Redux store that lets you read the state, dispatch actions - * and subscribe to changes. - */ -export default function createStore(reducer, initialState, enhancer) { - if (typeof initialState === 'function' && typeof enhancer === 'undefined') { - enhancer = initialState - initialState = undefined +function createStoreBase(reducer, initialState, onChange) { + var currentState = initialState + var isDispatching = false + + function getState() { + return currentState } - if (typeof enhancer !== 'undefined') { - if (typeof enhancer !== 'function') { - throw new Error('Expected the enhancer to be a function.') + function dispatch(action) { + if (!isPlainObject(action)) { + throw new Error( + 'Actions must be plain objects. ' + + 'Use custom middleware for async actions.' + ) + } + if (typeof action.type === 'undefined') { + throw new Error( + 'Actions may not have an undefined "type" property. ' + + 'Have you misspelled a constant?' + ) + } + if (isDispatching) { + throw new Error('Reducers may not dispatch actions.') + } + + try { + isDispatching = true + currentState = reducer(currentState, action) + } finally { + isDispatching = false } - return enhancer(createStore)(reducer, initialState) + onChange() + return action } + return { + dispatch, + getState + } +} + +export default function createStore(reducer, initialState, enhancer) { if (typeof reducer !== 'function') { throw new Error('Expected the reducer to be a function.') } + if (typeof initialState === 'function' && typeof enhancer === 'undefined') { + enhancer = initialState + initialState = undefined + } + if (typeof enhancer !== 'undefined' && typeof enhancer !== 'function') { + throw new Error('Expected the enhancer to be a function.') + } - var currentReducer = reducer - var currentState = initialState + enhancer = enhancer || (x => x) + var createFinalStoreBase = enhancer(createStoreBase) + + var storeBase var currentListeners = [] var nextListeners = currentListeners - var isDispatching = false + + function onChange() { + var listeners = currentListeners = nextListeners + for (var i = 0; i < listeners.length; i++) { + listeners[i]() + } + } function ensureCanMutateNextListeners() { if (nextListeners === currentListeners) { @@ -66,45 +79,12 @@ export default function createStore(reducer, initialState, enhancer) { } } - /** - * Reads the state tree managed by the store. - * - * @returns {any} The current state tree of your application. - */ - function getState() { - return currentState - } - - /** - * Adds a change listener. It will be called any time an action is dispatched, - * and some part of the state tree may potentially have changed. You may then - * call `getState()` to read the current state tree inside the callback. - * - * You may call `dispatch()` from a change listener, with the following - * caveats: - * - * 1. The subscriptions are snapshotted just before every `dispatch()` call. - * If you subscribe or unsubscribe while the listeners are being invoked, this - * will not have any effect on the `dispatch()` that is currently in progress. - * However, the next `dispatch()` call, whether nested or not, will use a more - * recent snapshot of the subscription list. - * - * 2. The listener should not expect to see all state changes, as the state - * might have been updated multiple times during a nested `dispatch()` before - * the listener is called. It is, however, guaranteed that all subscribers - * registered before the `dispatch()` started will be called with the latest - * state by the time it exits. - * - * @param {Function} listener A callback to be invoked on every dispatch. - * @returns {Function} A function to remove this change listener. - */ function subscribe(listener) { if (typeof listener !== 'function') { throw new Error('Expected listener to be a function.') } var isSubscribed = true - ensureCanMutateNextListeners() nextListeners.push(listener) @@ -114,121 +94,43 @@ export default function createStore(reducer, initialState, enhancer) { } isSubscribed = false - ensureCanMutateNextListeners() var index = nextListeners.indexOf(listener) nextListeners.splice(index, 1) } } - /** - * Dispatches an action. It is the only way to trigger a state change. - * - * The `reducer` function, used to create the store, will be called with the - * current state tree and the given `action`. Its return value will - * be considered the **next** state of the tree, and the change listeners - * will be notified. - * - * The base implementation only supports plain object actions. If you want to - * dispatch a Promise, an Observable, a thunk, or something else, you need to - * wrap your store creating function into the corresponding middleware. For - * example, see the documentation for the `redux-thunk` package. Even the - * middleware will eventually dispatch plain object actions using this method. - * - * @param {Object} action A plain object representing “what changed”. It is - * a good idea to keep actions serializable so you can record and replay user - * sessions, or use the time travelling `redux-devtools`. An action must have - * a `type` property which may not be `undefined`. It is a good idea to use - * string constants for action types. - * - * @returns {Object} For convenience, the same action object you dispatched. - * - * Note that, if you use a custom middleware, it may wrap `dispatch()` to - * return something else (for example, a Promise you can await). - */ function dispatch(action) { - if (!isPlainObject(action)) { - throw new Error( - 'Actions must be plain objects. ' + - 'Use custom middleware for async actions.' - ) - } - - if (typeof action.type === 'undefined') { - throw new Error( - 'Actions may not have an undefined "type" property. ' + - 'Have you misspelled a constant?' - ) - } - - if (isDispatching) { - throw new Error('Reducers may not dispatch actions.') - } - - try { - isDispatching = true - currentState = currentReducer(currentState, action) - } finally { - isDispatching = false - } - - var listeners = currentListeners = nextListeners - for (var i = 0; i < listeners.length; i++) { - listeners[i]() - } + return storeBase.dispatch(action) + } - return action + function getState() { + return storeBase.getState() } - /** - * Replaces the reducer currently used by the store to calculate the state. - * - * You might need this if your app implements code splitting and you want to - * load some of the reducers dynamically. You might also need this if you - * implement a hot reloading mechanism for Redux. - * - * @param {Function} nextReducer The reducer for the store to use instead. - * @returns {void} - */ function replaceReducer(nextReducer) { if (typeof nextReducer !== 'function') { throw new Error('Expected the nextReducer to be a function.') } - currentReducer = nextReducer + var nextInitialState = storeBase ? getState() : initialState + storeBase = createFinalStoreBase(nextReducer, nextInitialState, onChange) dispatch({ type: ActionTypes.INIT }) } - /** - * Interoperability point for observable/reactive libraries. - * @returns {observable} A minimal observable of state changes. - * For more information, see the observable proposal: - * https://github.com/zenparsing/es-observable - */ function observable() { - var outerSubscribe = subscribe return { - /** - * The minimal observable subscription method. - * @param {Object} observer Any object that can be used as an observer. - * The observer object should have a `next` method. - * @returns {subscription} An object with an `unsubscribe` method that can - * be used to unsubscribe the observable from the store, and prevent further - * emission of values from the observable. - */ subscribe(observer) { if (typeof observer !== 'object') { throw new TypeError('Expected the observer to be an object.') } - function observeState() { if (observer.next) { observer.next(getState()) } } - observeState() - var unsubscribe = outerSubscribe(observeState) + var unsubscribe = subscribe(observeState) return { unsubscribe } }, @@ -238,15 +140,12 @@ export default function createStore(reducer, initialState, enhancer) { } } - // When a store is created, an "INIT" action is dispatched so that every - // reducer returns their initial state. This effectively populates - // the initial state tree. - dispatch({ type: ActionTypes.INIT }) + replaceReducer(reducer) return { dispatch, - subscribe, getState, + subscribe, replaceReducer, [$$observable]: observable } diff --git a/test/applyMiddleware.spec.js b/test/applyMiddleware.spec.js index cb12ab8514..988981a1ce 100644 --- a/test/applyMiddleware.spec.js +++ b/test/applyMiddleware.spec.js @@ -94,22 +94,4 @@ describe('applyMiddleware', () => { done() }) }) - - it('keeps unwrapped dispatch available while middleware is initializing', () => { - // This is documenting the existing behavior in Redux 3.x. - // We plan to forbid this in Redux 4.x. - - function earlyDispatch({ dispatch }) { - dispatch(addTodo('Hello')) - return () => action => action - } - - const store = createStore(reducers.todos, applyMiddleware(earlyDispatch)) - expect(store.getState()).toEqual([ - { - id: 1, - text: 'Hello' - } - ]) - }) }) diff --git a/test/createStore.spec.js b/test/createStore.spec.js index 369bb98af3..ec834b2afd 100644 --- a/test/createStore.spec.js +++ b/test/createStore.spec.js @@ -495,22 +495,27 @@ describe('createStore', () => { }) it('accepts enhancer as the third argument', () => { + const spyActions = [] const emptyArray = [] const spyEnhancer = vanillaCreateStore => (...args) => { expect(args[0]).toBe(reducers.todos) expect(args[1]).toBe(emptyArray) - expect(args.length).toBe(2) + expect(args[2]).toBeA('function') + expect(args.length).toBe(3) const vanillaStore = vanillaCreateStore(...args) return { ...vanillaStore, - dispatch: expect.createSpy(vanillaStore.dispatch).andCallThrough() + dispatch(action) { + spyActions.push(action) + return vanillaStore.dispatch(action) + } } } const store = createStore(reducers.todos, emptyArray, spyEnhancer) const action = addTodo('Hello') store.dispatch(action) - expect(store.dispatch).toHaveBeenCalledWith(action) + expect(spyActions).toContain(action) expect(store.getState()).toEqual([ { id: 1, @@ -520,21 +525,26 @@ describe('createStore', () => { }) it('accepts enhancer as the second argument if initial state is missing', () => { + const spyActions = [] const spyEnhancer = vanillaCreateStore => (...args) => { expect(args[0]).toBe(reducers.todos) expect(args[1]).toBe(undefined) - expect(args.length).toBe(2) + expect(args[2]).toBeA('function') + expect(args.length).toBe(3) const vanillaStore = vanillaCreateStore(...args) return { ...vanillaStore, - dispatch: expect.createSpy(vanillaStore.dispatch).andCallThrough() + dispatch(action) { + spyActions.push(action) + return vanillaStore.dispatch(action) + } } } const store = createStore(reducers.todos, spyEnhancer) const action = addTodo('Hello') store.dispatch(action) - expect(store.dispatch).toHaveBeenCalledWith(action) + expect(spyActions).toContain(action) expect(store.getState()).toEqual([ { id: 1,