diff --git a/docs/advanced/Middleware.md b/docs/advanced/Middleware.md index fe51ff6493..f77aa88273 100644 --- a/docs/advanced/Middleware.md +++ b/docs/advanced/Middleware.md @@ -268,12 +268,16 @@ The implementation of [`applyMiddleware()`](../api/applyMiddleware.md) that ship * It only exposes a subset of the [store API](../api/Store.md) to the middleware: [`dispatch(action)`](../api/Store.md#dispatch) and [`getState()`](../api/Store.md#getState). -* It does a bit of trickery to make sure that if you call `store.dispatch(action)` from your middleware instead of `next(action)`, the action will actually travel the whole middleware chain again, including the current middleware. This is useful for asynchronous middleware, as we have seen [previously](AsyncActions.md). +* It does a bit of trickery to make sure that if you call `store.dispatch(action)` from your middleware instead of `next(action)`, the action will actually travel the whole middleware chain again, including the current middleware. This is useful for asynchronous middleware, as we have seen [previously](AsyncActions.md). There is one caveat when calling `dispatch` during setup, described below. * To ensure that you may only apply middleware once, it operates on `createStore()` rather than on `store` itself. Instead of `(store, middlewares) => store`, its signature is `(...middlewares) => (createStore) => createStore`. Because it is cumbersome to apply functions to `createStore()` before using it, `createStore()` accepts an optional last argument to specify such functions. +#### Caveat: Dispatching During Setup + +While `applyMiddleware` executes and sets up your middleware, the `store.dispatch` function will point to the vanilla version provided by `createStore`. Dispatching would result in no other middleware being applied. If you are expecting an interaction with another middleware during setup, you will probably be disappointed. Because of this unexpected behavior, `applyMiddleware` will throw an error if you try to dispatch an action before the set up completes. Instead, you should either communicate directly with that other middleware via a common object (for an API-calling middleware, this may be your API client object) or waiting until after the middleware is constructed with a callback. + ### The Final Approach Given this middleware we just wrote: diff --git a/index.d.ts b/index.d.ts index 88a4c0fa30..7c62f0df79 100644 --- a/index.d.ts +++ b/index.d.ts @@ -43,13 +43,13 @@ export interface Action { * * @template S State object type. */ -export type Reducer = (state: S, action: A) => S; +export type Reducer = (state: S | undefined, action: A) => S; /** * Object whose values correspond to different reducer functions. */ -export interface ReducersMapObject { - [key: string]: Reducer; +export type ReducersMapObject = { + [K in keyof S]: Reducer; } /** @@ -70,7 +70,7 @@ export interface ReducersMapObject { * @returns A reducer function that invokes every reducer inside the passed * object, and builds a state object with the same shape. */ -export function combineReducers(reducers: ReducersMapObject): Reducer; +export function combineReducers(reducers: ReducersMapObject): Reducer; /* store */ diff --git a/package.json b/package.json index 54e2e707e2..69cc14d51a 100644 --- a/package.json +++ b/package.json @@ -111,8 +111,8 @@ "jest": "^18.0.0", "rimraf": "^2.3.4", "rxjs": "^5.0.0-beta.6", - "typescript": "^1.8.0", - "typescript-definition-tester": "0.0.4", + "typescript": "^2.1.0", + "typescript-definition-tester": "0.0.5", "webpack": "^1.9.6" }, "npmName": "redux", diff --git a/src/adaptEnhancer.js b/src/adaptEnhancer.js new file mode 100644 index 0000000000..53de7373b4 --- /dev/null +++ b/src/adaptEnhancer.js @@ -0,0 +1,44 @@ +import { validateModernEnhancers } from './createStore' + +// Transforms a modernEnhancer (store => partialStore) into a classicEnhancer +// (createStore => (reducer, preloadedState) => store) by wrapping it in an +// adapter function. This is to maintain backwards compatibility with the +// classic way of composing store enhancers with the `compose` function. +export default function adaptEnhancer(modernEnhancer) { + if (typeof modernEnhancer !== 'function') { + throw new Error(`Expected 'modernEnhancer' to be a function.`) + } + + function enhancer(createStore) { + const length = createStore.length + if (length !== 4) { + throw new Error( + `Expected 'createStore' to accept 4 arguments but it accepts ${length}.` + ) + } + + return (reducer, preloadedState, classicEnhancer, modernEnhancers) => { + validateModernEnhancers(modernEnhancers) + + return createStore( + reducer, + preloadedState, + classicEnhancer, + (modernEnhancers || []).concat(modernEnhancer) + ) + } + } + + enhancer.modern = modernEnhancer + return enhancer +} + +// Since most store enhancers have a factory function, this adapter function is +// provided as a convenience. See `applyMiddleware` as an example. +export function adaptEnhancerCreator(createModernEnhancer) { + if (typeof createModernEnhancer !== 'function') { + throw new Error(`Expected 'createModernEnhancer' to be a function.`) + } + + return (...args) => adaptEnhancer(createModernEnhancer(...args)) +} diff --git a/src/applyMiddleware.js b/src/applyMiddleware.js index 3cf08841b6..3b94ccd6a8 100644 --- a/src/applyMiddleware.js +++ b/src/applyMiddleware.js @@ -1,4 +1,6 @@ -import compose from './compose' +import { adaptEnhancerCreator } from './adaptEnhancer' + +export default adaptEnhancerCreator(applyMiddleware) /** * Creates a store enhancer that applies middleware to the dispatch method @@ -10,28 +12,50 @@ import compose from './compose' * Because middleware is potentially asynchronous, this should be the first * store enhancer in the composition chain. * - * Note that each middleware will be given the `dispatch` and `getState` functions - * as named arguments. + * Note that each middleware will be given the `dispatch` and `getState` + * functions as named arguments. * - * @param {...Function} middlewares The middleware chain to be applied. + * @param {...Function} middleware The middleware chain to be applied. * @returns {Function} A store enhancer applying the middleware. */ -export default function applyMiddleware(...middlewares) { - return (createStore) => (reducer, preloadedState, enhancer) => { - const store = createStore(reducer, preloadedState, enhancer) - let dispatch = store.dispatch - let chain = [] +function applyMiddleware(...middleware) { + return store => { + const dispatchProxy = createDispatchProxy() - const middlewareAPI = { + const api = { getState: store.getState, - dispatch: (action) => dispatch(action) + dispatch: dispatchProxy.dispatch, } - chain = middlewares.map(middleware => middleware(middlewareAPI)) - dispatch = compose(...chain)(store.dispatch) - return { - ...store, - dispatch - } + const dispatch = middleware + .map(mid => mid(api)) + .reduceRight((next, mid) => mid(next), store.dispatch) + + dispatchProxy.replace(dispatch) + return { dispatch } + } +} + +// Because the `finalDispatch` function isn't known until after the middleware +// have been composed, but it needs to be accessible to those middleware before +// then, it needs to be wrapped in a proxy function. To prevent that function +// from capturing `middleware` for the lifetime of the running application, it +// is defined outside of the `applyMiddleware` function. +function createDispatchProxy() { + let finalDispatch = throwPrematureDispatch + return { + dispatch(...args) { + return finalDispatch(...args) + }, + replace(newDispatch) { + finalDispatch = newDispatch + }, } } + +function throwPrematureDispatch() { + throw new Error( + 'Dispatching while constructing your middleware is not allowed. Other' + + ' middleware would not be applied to this dispatch.' + ) +} diff --git a/src/combineReducers.js b/src/combineReducers.js index f414de84d3..6345ea96aa 100644 --- a/src/combineReducers.js +++ b/src/combineReducers.js @@ -1,4 +1,4 @@ -import { ActionTypes } from './createStore' +import ActionTypes from './utils/actionTypes' import isPlainObject from 'lodash/isPlainObject' import warning from './utils/warning' diff --git a/src/createEvent.js b/src/createEvent.js new file mode 100644 index 0000000000..2abc5c7ba8 --- /dev/null +++ b/src/createEvent.js @@ -0,0 +1,41 @@ +export default function createEvent() { + let currentListeners = [] + let nextListeners = currentListeners + + function ensureCanMutateNextListeners() { + if (nextListeners === currentListeners) { + nextListeners = currentListeners.slice() + } + } + + function invoke() { + const listeners = currentListeners = nextListeners + for (let i = 0; i < listeners.length; i++) { + const listener = listeners[i] + listener() + } + } + + function subscribe(listener) { + if (typeof listener !== 'function') { + throw new Error('Expected listener to be a function.') + } + + let isSubscribed = true + ensureCanMutateNextListeners() + nextListeners.push(listener) + + return function unsubscribe() { + if (!isSubscribed) { + return + } + + isSubscribed = false + ensureCanMutateNextListeners() + const index = nextListeners.indexOf(listener) + nextListeners.splice(index, 1) + } + } + + return { invoke, subscribe } +} diff --git a/src/createStore.js b/src/createStore.js index 2712201921..0e7ac6c6a7 100644 --- a/src/createStore.js +++ b/src/createStore.js @@ -1,15 +1,8 @@ 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 const ActionTypes = { - INIT: '@@redux/INIT' -} +import ActionTypes from './utils/actionTypes' +import createEvent from './createEvent' /** * Creates a Redux store that holds the state tree. @@ -22,112 +15,144 @@ export const ActionTypes = { * @param {Function} reducer A function that returns the next state tree, given * the current state tree and the action to handle. * - * @param {any} [preloadedState] The initial state. You may optionally specify it - * to hydrate the state from the server in universal apps, or to restore a + * @param {any} [preloadedState] 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. + * 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, + * @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, preloadedState, enhancer) { - if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') { - enhancer = preloadedState - preloadedState = undefined +export default function createStore( + reducer, + preloadedState, + classicEnhancer, + modernEnhancers, +) { + if (typeof preloadedState === 'function' + && typeof classicEnhancer === 'undefined' + && typeof modernEnhancers === 'undefined' + ) { + return createStore(reducer, undefined, preloadedState) } - if (typeof enhancer !== 'undefined') { - if (typeof enhancer !== 'function') { - throw new Error('Expected the enhancer to be a function.') - } - - return enhancer(createStore)(reducer, preloadedState) + if (typeof classicEnhancer !== 'undefined' + && typeof modernEnhancers !== 'undefined' + ) { + throw new Error( + `Expected either 'classicEnhancer' or 'modernEnhancers' to be undefined.` + ) } - if (typeof reducer !== 'function') { - throw new Error('Expected the reducer to be a function.') + if (typeof classicEnhancer === 'function') { + return classicEnhancer(createStore)(reducer, preloadedState) + } else if (typeof classicEnhancer !== 'undefined') { + throw new Error(`Expected 'classicEnhancer' to be a function.`) } - let currentReducer = reducer - let currentState = preloadedState - let currentListeners = [] - let nextListeners = currentListeners - let isDispatching = false + validateModernEnhancers(modernEnhancers) - function ensureCanMutateNextListeners() { - if (nextListeners === currentListeners) { - nextListeners = currentListeners.slice() - } - } + // The setter function for the `final` prop is hidden from store enhancers, + // because setting the `finalStore` should only be done by `createStore`. + const { set: setFinal, ...finalPropDescriptor } = createFinalStoreProp() - /** - * Reads the state tree managed by the store. - * - * @returns {any} The current state tree of your application. - */ - function getState() { - return currentState - } + // Building a store is defined as running a series of store enhancers. They + // are run in last->first order to maintain consistency with `compose` and + // `applyMiddleware`. + const allEnhancers = [ + makeStoreInitializeStateViaDispatch, + blockReducerFromAccessingStore, + ...(modernEnhancers || []), + makeStoreObservable, + createBaseStore, + ] + const finalStore = allEnhancers.reduceRight((store, enhancer) => { + // Each enhancer receives the store as it exists after the previous enhancer + // has run. It cannot modify that store directly, since it has been frozen. + // It must return a new (partial) store that is then merged with the current + // store. This prevents an enhancer from omitting any properties that were + // previously defined; it can only add/override properties. + Object.defineProperty(store, 'final', finalPropDescriptor) + Object.freeze(store) + const enhancements = enhancer(store) + return { ...store, ...enhancements } + }, {}) - /** - * 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.') - } + setFinal(Object.freeze(finalStore)) + + // Once the final store has been constructed, `init` is called. Any enhancer + // can use `init` as a chance to do its initialization work; for example, + // subscribing to the store, or changing the values of reducer/preloadedState. + finalStore.init(Object.freeze({ reducer, preloadedState })) - let isSubscribed = true + // Certain properties made available to the store enhancers are omitted from + // the public store API, since they should not be called by application code. + const publicStore = { ...finalStore } + delete publicStore.final + delete publicStore.init + delete publicStore.onChange + delete publicStore.reducer + return Object.freeze(publicStore) +} - ensureCanMutateNextListeners() - nextListeners.push(listener) +export function validateModernEnhancers(modernEnhancers) { + if (typeof modernEnhancers === 'undefined') return - return function unsubscribe() { - if (!isSubscribed) { - return - } + if (!Array.isArray(modernEnhancers)) { + throw new Error(`Expected 'modernEnhancers' to be an array of functions.`) + } - isSubscribed = false + if (!modernEnhancers.every(func => typeof func === 'function')) { + throw new Error(`Expected 'modernEnhancers' to be an array of functions.`) + } +} - ensureCanMutateNextListeners() - const index = nextListeners.indexOf(listener) - nextListeners.splice(index, 1) - } +// Each store enhancer receives a store with a `final` prop. The value of this +// prop gets backfilled with the final store object once all the enhancers have +// run. This allows an enhancer to rely on enhancements provided by an enhancer +// later in the chain. +// +// For example, `dispatch` can rely on `store.final.onChange`, instead of +// `store.onChange`, allowing enhancers to override the `onChange` behavior that +// exists when `dispatch` gets defined. +// +// The getter/setter functions for this prop are defined outside of +// `createStore` so that the closures don't capture any of the args/variables +// from `createStore`. The property descriptor is defined with +// `enumerable: false` so that the getter doesn't get accidentally called when +// doing `{ ...store, ...enhancements }`. +function createFinalStoreProp() { + let finalStore = undefined + return { + configurable: false, + enumerable: false, + get() { + if (finalStore) return finalStore + throw new Error(`Cannot access 'store.final' during the build phase.`) + }, + set(value) { + finalStore = value + }, } +} + +function createBaseStore(store) { + let currentReducer = uninitializedReducer + let currentState = undefined /** * 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. + * 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 @@ -135,8 +160,8 @@ export default function createStore(reducer, preloadedState, enhancer) { * 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 + * @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. @@ -147,38 +172,31 @@ export default function createStore(reducer, preloadedState, enhancer) { * 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.') - } + validateAction(action) + currentState = store.final.reducer(currentState, action) + store.final.onChange() + return action + } - try { - isDispatching = true - currentState = currentReducer(currentState, action) - } finally { - isDispatching = false - } + /** + * Reads the state tree managed by the store. + * + * @returns {any} The current state tree of your application. + */ + function getState() { + return currentState + } - const listeners = currentListeners = nextListeners - for (let i = 0; i < listeners.length; i++) { - const listener = listeners[i] - listener() + function init({ preloadedState, reducer }) { + if (typeof reducer !== 'function') { + throw new Error('Expected the reducer to be a function.') } + currentReducer = reducer + currentState = preloadedState + } - return action + function reducer(...args) { + return currentReducer(...args) } /** @@ -195,9 +213,174 @@ export default function createStore(reducer, preloadedState, enhancer) { if (typeof nextReducer !== 'function') { throw new Error('Expected the nextReducer to be a function.') } - currentReducer = nextReducer - dispatch({ type: ActionTypes.INIT }) + } + + /** + * 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. + */ + const { subscribe, invoke: onChange } = createEvent() + + return { + dispatch, + getState, + init, + onChange, + reducer, + replaceReducer, + subscribe + } +} + +function uninitializedReducer() { + throw new Error('Reducer has not been initialized.') +} + +function validateAction(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?' + ) + } +} + +function blockReducerFromAccessingStore(store) { + const messages = { + forbiddenDispatch: + 'Reducers may not dispatch actions.', + + forbiddenGetState: + 'You may not call store.getState() while the reducer is executing. The' + + ' reducer has already received the state as an argument. Pass it down' + + ' from the top reducer instead of reading it from the store.', + + forbiddenSubscribe: + 'You may not call store.subscribe() while the reducer is executing. If' + + ' you would like to be notified after the store has been updated,' + + ' subscribe from a component and invoke store.getState() in the' + + ' callback to access the latest state. See' + + ' http://redux.js.org/docs/api/Store.html#subscribe for more details.', + + forbiddenUnsubscribe: + 'You may not unsubscribe from a store listener while the reducer is' + + ' executing. See http://redux.js.org/docs/api/Store.html#subscribe for' + + ' more details.', + } + + let runningReducer = 0 + + function blockReducer(message) { + if (runningReducer) { + throw new Error(message) + } + } + + function reducer(...args) { + try { + runningReducer += 1 + return store.reducer(...args) + } finally { + runningReducer -= 1 + } + } + + function dispatch(...args) { + blockReducer(messages.forbiddenDispatch) + return store.dispatch(...args) + } + + function getState(...args) { + blockReducer(messages.forbiddenGetState) + return store.getState(...args) + } + + function wrapUnsubscribe(baseUnsubscribe) { + return function unsubscribe(...args) { + blockReducer(messages.forbiddenUnsubscribe) + return baseUnsubscribe(...args) + } + } + + function subscribe(...args) { + blockReducer(messages.forbiddenSubscribe) + const baseSubscribe = store.subscribe(...args) + return wrapUnsubscribe(baseSubscribe) + } + + return { dispatch, getState, reducer, subscribe } +} + +function makeStoreInitializeStateViaDispatch(store) { + // 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. + + function init(...args) { + store.init(...args) + store.final.dispatch({ type: ActionTypes.INIT }) + } + + function replaceReducer(...args) { + store.replaceReducer(...args) + store.final.dispatch({ type: ActionTypes.INIT }) + } + + return { init, replaceReducer } +} + +function makeStoreObservable(store) { + /** + * 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. + */ + function subscribe(observer) { + if (typeof observer !== 'object') { + throw new TypeError('Expected the observer to be an object.') + } + + function observeState() { + if (observer.next) { + const state = store.final.getState() + observer.next(state) + } + } + + observeState() + const unsubscribe = store.final.subscribe(observeState) + return { unsubscribe } } /** @@ -207,48 +390,13 @@ export default function createStore(reducer, preloadedState, enhancer) { * https://github.com/zenparsing/es-observable */ function observable() { - const 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() - const unsubscribe = outerSubscribe(observeState) - return { unsubscribe } - }, - + subscribe, [$$observable]() { return this } } } - // 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 }) - - return { - dispatch, - subscribe, - getState, - replaceReducer, - [$$observable]: observable - } + return { [$$observable]: observable } } diff --git a/src/index.js b/src/index.js index 3aaaf703ae..ee3d0d0cc0 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,10 @@ -import createStore from './createStore' -import combineReducers from './combineReducers' -import bindActionCreators from './bindActionCreators' +import adaptEnhancer, { adaptEnhancerCreator } from './adaptEnhancer' import applyMiddleware from './applyMiddleware' +import bindActionCreators from './bindActionCreators' +import combineReducers from './combineReducers' import compose from './compose' +import createEvent from './createEvent' +import createStore from './createStore' import warning from './utils/warning' /* @@ -27,9 +29,12 @@ if ( } export { - createStore, - combineReducers, - bindActionCreators, + adaptEnhancer, + adaptEnhancerCreator, applyMiddleware, - compose + bindActionCreators, + combineReducers, + compose, + createEvent, + createStore, } diff --git a/src/utils/actionTypes.js b/src/utils/actionTypes.js new file mode 100644 index 0000000000..82c3ffedbe --- /dev/null +++ b/src/utils/actionTypes.js @@ -0,0 +1,11 @@ +/** + * 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. + */ +var ActionTypes = { + INIT: '@@redux/INIT' +} + +export default ActionTypes diff --git a/test/applyMiddleware.spec.js b/test/applyMiddleware.spec.js index 66c14121dd..9abc4aa18c 100644 --- a/test/applyMiddleware.spec.js +++ b/test/applyMiddleware.spec.js @@ -4,6 +4,17 @@ import { addTodo, addTodoAsync, addTodoIfEmpty } from './helpers/actionCreators' import { thunk } from './helpers/middleware' describe('applyMiddleware', () => { + it('warns when dispatching during middleware setup', () => { + function dispatchingMiddleware(store) { + store.dispatch(addTodo('Dont dispatch in middleware setup')) + return next => action => next(action) + } + + expect(() => + applyMiddleware(dispatchingMiddleware)(createStore)(reducers.todos) + ).toThrow() + }) + it('wraps dispatch method with middleware once', () => { function test(spyOnMethods) { return methods => { @@ -40,7 +51,7 @@ describe('applyMiddleware', () => { const store = applyMiddleware(test(spy), thunk)(createStore)(reducers.todos) return store.dispatch(addTodoAsync('Use Redux')).then(() => { - expect(spy.mock.calls.length).toEqual(2) + expect(spy.mock.calls.length).toEqual(3) }) }) @@ -94,21 +105,4 @@ describe('applyMiddleware', () => { }) }) - 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/combineReducers.spec.js b/test/combineReducers.spec.js index 2fd1ab1eab..b722a151a1 100644 --- a/test/combineReducers.spec.js +++ b/test/combineReducers.spec.js @@ -1,6 +1,7 @@ /* eslint-disable no-console */ import { combineReducers } from '../src' -import createStore, { ActionTypes } from '../src/createStore' +import createStore from '../src/createStore' +import ActionTypes from '../src/utils/actionTypes' describe('Utils', () => { describe('combineReducers', () => { diff --git a/test/createStore.spec.js b/test/createStore.spec.js index c2825dd592..6375ca9a36 100644 --- a/test/createStore.spec.js +++ b/test/createStore.spec.js @@ -1,5 +1,18 @@ -import { createStore, combineReducers } from '../src/index' -import { addTodo, dispatchInMiddle, throwError, unknownAction } from './helpers/actionCreators' +import { + adaptEnhancerCreator, + createStore, + combineReducers, + compose, +} from '../src/index' +import { + addTodo, + dispatchInMiddle, + getStateInMiddle, + subscribeInMiddle, + unsubscribeInMiddle, + throwError, + unknownAction +} from './helpers/actionCreators' import * as reducers from './helpers/reducers' import * as Rx from 'rxjs' import $$observable from 'symbol-observable' @@ -461,6 +474,31 @@ describe('createStore', () => { ).toThrow(/may not dispatch/) }) + it('does not allow getState() from within a reducer', () => { + const store = createStore(reducers.getStateInTheMiddleOfReducer) + + expect(() => + store.dispatch(getStateInMiddle(store.getState.bind(store))) + ).toThrow(/You may not call store.getState()/) + }) + + it('does not allow subscribe() from within a reducer', () => { + const store = createStore(reducers.subscribeInTheMiddleOfReducer) + + expect(() => + store.dispatch(subscribeInMiddle(store.subscribe.bind(store, () => {}))) + ).toThrow(/You may not call store.subscribe()/) + }) + + it('does not allow unsubscribe from subscribe() from within a reducer', () => { + const store = createStore(reducers.unsubscribeInTheMiddleOfReducer) + const unsubscribe = store.subscribe(() => {}) + + expect(() => + store.dispatch(unsubscribeInMiddle(unsubscribe.bind(store))) + ).toThrow(/You may not unsubscribe from a store/) + }) + it('recovers from an error within a reducer', () => { const store = createStore(reducers.errorThrowingReducer) expect(() => @@ -502,93 +540,6 @@ describe('createStore', () => { ).not.toThrow() }) - it('accepts enhancer as the third argument', () => { - const emptyArray = [] - const spyEnhancer = vanillaCreateStore => (...args) => { - expect(args[0]).toBe(reducers.todos) - expect(args[1]).toBe(emptyArray) - expect(args.length).toBe(2) - const vanillaStore = vanillaCreateStore(...args) - return { - ...vanillaStore, - dispatch: jest.fn(vanillaStore.dispatch) - } - } - - const store = createStore(reducers.todos, emptyArray, spyEnhancer) - const action = addTodo('Hello') - store.dispatch(action) - expect(store.dispatch).toBeCalledWith(action) - expect(store.getState()).toEqual([ - { - id: 1, - text: 'Hello' - } - ]) - }) - - it('accepts enhancer as the second argument if initial state is missing', () => { - const spyEnhancer = vanillaCreateStore => (...args) => { - expect(args[0]).toBe(reducers.todos) - expect(args[1]).toBe(undefined) - expect(args.length).toBe(2) - const vanillaStore = vanillaCreateStore(...args) - return { - ...vanillaStore, - dispatch: jest.fn(vanillaStore.dispatch) - } - } - - const store = createStore(reducers.todos, spyEnhancer) - const action = addTodo('Hello') - store.dispatch(action) - expect(store.dispatch).toBeCalledWith(action) - expect(store.getState()).toEqual([ - { - id: 1, - text: 'Hello' - } - ]) - }) - - it('throws if enhancer is neither undefined nor a function', () => { - expect(() => - createStore(reducers.todos, undefined, {}) - ).toThrow() - - expect(() => - createStore(reducers.todos, undefined, []) - ).toThrow() - - expect(() => - createStore(reducers.todos, undefined, null) - ).toThrow() - - expect(() => - createStore(reducers.todos, undefined, false) - ).toThrow() - - expect(() => - createStore(reducers.todos, undefined, undefined) - ).not.toThrow() - - expect(() => - createStore(reducers.todos, undefined, x => x) - ).not.toThrow() - - expect(() => - createStore(reducers.todos, x => x) - ).not.toThrow() - - expect(() => - createStore(reducers.todos, []) - ).not.toThrow() - - expect(() => - createStore(reducers.todos, {}) - ).not.toThrow() - }) - it('throws if nextReducer is not a function', () => { const store = createStore(reducers.todos) @@ -734,4 +685,159 @@ describe('createStore', () => { expect(results).toEqual([ { foo: 0, bar: 0, fromRx: true }, { foo: 1, bar: 0, fromRx: true } ]) }) }) + + describe('Enhancers API', () => { + it('accepts enhancer as the third argument', () => { + const emptyArray = [] + const spyEnhancer = vanillaCreateStore => (...args) => { + expect(args[0]).toBe(reducers.todos) + expect(args[1]).toBe(emptyArray) + expect(args.length).toBe(2) + const vanillaStore = vanillaCreateStore(...args) + return { + ...vanillaStore, + dispatch: jest.fn(vanillaStore.dispatch) + } + } + + const store = createStore(reducers.todos, emptyArray, spyEnhancer) + const action = addTodo('Hello') + store.dispatch(action) + expect(store.dispatch).toBeCalledWith(action) + expect(store.getState()).toEqual([ + { + id: 1, + text: 'Hello' + } + ]) + }) + + it('accepts enhancer as the second argument if initial state is missing', () => { + const spyEnhancer = vanillaCreateStore => (...args) => { + expect(args[0]).toBe(reducers.todos) + expect(args[1]).toBe(undefined) + expect(args.length).toBe(2) + const vanillaStore = vanillaCreateStore(...args) + return { + ...vanillaStore, + dispatch: jest.fn(vanillaStore.dispatch) + } + } + + const store = createStore(reducers.todos, spyEnhancer) + const action = addTodo('Hello') + store.dispatch(action) + expect(store.dispatch).toBeCalledWith(action) + expect(store.getState()).toEqual([ + { + id: 1, + text: 'Hello' + } + ]) + }) + + it('throws if enhancer is neither undefined nor a function', () => { + expect(() => + createStore(reducers.todos, undefined, {}) + ).toThrow() + + expect(() => + createStore(reducers.todos, undefined, []) + ).toThrow() + + expect(() => + createStore(reducers.todos, undefined, null) + ).toThrow() + + expect(() => + createStore(reducers.todos, undefined, false) + ).toThrow() + + expect(() => + createStore(reducers.todos, undefined, undefined) + ).not.toThrow() + + expect(() => + createStore(reducers.todos, undefined, x => x) + ).not.toThrow() + + expect(() => + createStore(reducers.todos, x => x) + ).not.toThrow() + + expect(() => + createStore(reducers.todos, []) + ).not.toThrow() + + expect(() => + createStore(reducers.todos, {}) + ).not.toThrow() + }) + + const reducer = (state = '', action) => action.state || state + + const append = (action, text) => ( + action.type === 'SET' + ? { ...action, state: (action.state || '') + text } + : action + ) + + const modernEnhancer = adaptEnhancerCreator( + text => store => ({ + dispatch(action) { + return store.dispatch(append(action, text)) + } + }) + ) + + const classicEnhancer = text => next => (reducer, state) => { + const store = next(reducer, state) + return { + ...store, + dispatch(action) { + return store.dispatch(append(action, text)) + } + } + } + + it('accepts two modern enhancers passed directly as the fourth argument', () => { + const a = modernEnhancer('A').modern + const b = modernEnhancer('B').modern + const store = createStore(reducer, undefined, undefined, [a, b]) + store.dispatch({ type: 'SET' }) + expect(store.getState()).toEqual('AB') + }) + + it('accepts two adapted modern enhancers that have been composed', () => { + const a = modernEnhancer('A') + const b = modernEnhancer('B') + const store = createStore(reducer, compose(a, b)) + store.dispatch({ type: 'SET' }) + expect(store.getState()).toEqual('AB') + }) + + it('accepts two classic enhancers that have been composed', () => { + const a = classicEnhancer('A') + const b = classicEnhancer('B') + const store = createStore(reducer, compose(a, b)) + store.dispatch({ type: 'SET' }) + expect(store.getState()).toEqual('AB') + }) + + it('accepts classic then adapted modern enhancers that have been composed', () => { + const a = classicEnhancer('A') + const b = modernEnhancer('B') + const store = createStore(reducer, compose(a, b)) + store.dispatch({ type: 'SET' }) + expect(store.getState()).toEqual('AB') + }) + + it('rejects adapted modern then classic enhancers that have been composed', () => { + const a = modernEnhancer('A') + const b = classicEnhancer('B') + expect(() => { + createStore(reducer, compose(a, b)) + }).toThrow() + }) + }) }) diff --git a/test/helpers/actionCreators.js b/test/helpers/actionCreators.js index 198f61be20..5c26cdcc41 100644 --- a/test/helpers/actionCreators.js +++ b/test/helpers/actionCreators.js @@ -1,4 +1,12 @@ -import { ADD_TODO, DISPATCH_IN_MIDDLE, THROW_ERROR, UNKNOWN_ACTION } from './actionTypes' +import { + ADD_TODO, + DISPATCH_IN_MIDDLE, + GET_STATE_IN_MIDDLE, + SUBSCRIBE_IN_MIDDLE, + UNSUBSCRIBE_IN_MIDDLE, + THROW_ERROR, + UNKNOWN_ACTION +} from './actionTypes' export function addTodo(text) { return { type: ADD_TODO, text } @@ -26,6 +34,27 @@ export function dispatchInMiddle(boundDispatchFn) { } } +export function getStateInMiddle(boundGetStateFn) { + return { + type: GET_STATE_IN_MIDDLE, + boundGetStateFn + } +} + +export function subscribeInMiddle(boundSubscribeFn) { + return { + type: SUBSCRIBE_IN_MIDDLE, + boundSubscribeFn + } +} + +export function unsubscribeInMiddle(boundUnsubscribeFn) { + return { + type: UNSUBSCRIBE_IN_MIDDLE, + boundUnsubscribeFn + } +} + export function throwError() { return { type: THROW_ERROR diff --git a/test/helpers/actionTypes.js b/test/helpers/actionTypes.js index 00092962f2..2e6104345c 100644 --- a/test/helpers/actionTypes.js +++ b/test/helpers/actionTypes.js @@ -1,4 +1,7 @@ export const ADD_TODO = 'ADD_TODO' export const DISPATCH_IN_MIDDLE = 'DISPATCH_IN_MIDDLE' +export const GET_STATE_IN_MIDDLE = 'GET_STATE_IN_MIDDLE' +export const SUBSCRIBE_IN_MIDDLE = 'SUBSCRIBE_IN_MIDDLE' +export const UNSUBSCRIBE_IN_MIDDLE = 'UNSUBSCRIBE_IN_MIDDLE' export const THROW_ERROR = 'THROW_ERROR' export const UNKNOWN_ACTION = 'UNKNOWN_ACTION' diff --git a/test/helpers/reducers.js b/test/helpers/reducers.js index 8e9c7321ec..31ce7b99e1 100644 --- a/test/helpers/reducers.js +++ b/test/helpers/reducers.js @@ -1,4 +1,11 @@ -import { ADD_TODO, DISPATCH_IN_MIDDLE, THROW_ERROR } from './actionTypes' +import { + ADD_TODO, + DISPATCH_IN_MIDDLE, + GET_STATE_IN_MIDDLE, + SUBSCRIBE_IN_MIDDLE, + UNSUBSCRIBE_IN_MIDDLE, + THROW_ERROR +} from './actionTypes' function id(state = []) { @@ -46,6 +53,36 @@ export function dispatchInTheMiddleOfReducer(state = [], action) { } } +export function getStateInTheMiddleOfReducer(state = [], action) { + switch (action.type) { + case GET_STATE_IN_MIDDLE: + action.boundGetStateFn() + return state + default: + return state + } +} + +export function subscribeInTheMiddleOfReducer(state = [], action) { + switch (action.type) { + case SUBSCRIBE_IN_MIDDLE: + action.boundSubscribeFn() + return state + default: + return state + } +} + +export function unsubscribeInTheMiddleOfReducer(state = [], action) { + switch (action.type) { + case UNSUBSCRIBE_IN_MIDDLE: + action.boundUnsubscribeFn() + return state + default: + return state + } +} + export function errorThrowingReducer(state = [], action) { switch (action.type) { case THROW_ERROR: diff --git a/test/typescript.spec.js b/test/typescript.spec.js index 8943a15658..de86902bb0 100644 --- a/test/typescript.spec.js +++ b/test/typescript.spec.js @@ -6,6 +6,9 @@ describe('TypeScript definitions', function () { tt.compileDirectory( __dirname + '/typescript', fileName => fileName.match(/\.ts$/), + { + strictNullChecks: true + }, () => done() ) }) diff --git a/test/typescript/actionCreators.ts b/test/typescript/actionCreators.ts index c3eac83b00..a2ec2c8f35 100644 --- a/test/typescript/actionCreators.ts +++ b/test/typescript/actionCreators.ts @@ -1,7 +1,7 @@ import { ActionCreator, Action, Dispatch, bindActionCreators, ActionCreatorsMapObject -} from "../../index.d.ts"; +} from "../../"; interface AddTodoAction extends Action { diff --git a/test/typescript/actions.ts b/test/typescript/actions.ts index 1a0bb29d03..1122c13357 100644 --- a/test/typescript/actions.ts +++ b/test/typescript/actions.ts @@ -1,4 +1,4 @@ -import {Action as ReduxAction} from "../../index.d.ts"; +import {Action as ReduxAction} from "../../"; namespace FSA { diff --git a/test/typescript/compose.ts b/test/typescript/compose.ts index 3fbb4d0dbc..3e3d665277 100644 --- a/test/typescript/compose.ts +++ b/test/typescript/compose.ts @@ -1,4 +1,4 @@ -import {compose} from "../../index.d.ts"; +import {compose} from "../../"; // copied from DefinitelyTyped/compose-function diff --git a/test/typescript/dispatch.ts b/test/typescript/dispatch.ts index bc54b17fb9..271135f802 100644 --- a/test/typescript/dispatch.ts +++ b/test/typescript/dispatch.ts @@ -1,4 +1,4 @@ -import {Dispatch, Action} from "../../index.d.ts"; +import {Dispatch, Action} from "../../"; declare const dispatch: Dispatch; @@ -6,7 +6,7 @@ declare const dispatch: Dispatch; const dispatchResult: Action = dispatch({type: 'TYPE'}); // thunk -declare module "../../index.d.ts" { +declare module "../../" { export interface Dispatch { (asyncAction: (dispatch: Dispatch, getState: () => S) => R): R; } diff --git a/test/typescript/middleware.ts b/test/typescript/middleware.ts index 5111873df9..1b3386841e 100644 --- a/test/typescript/middleware.ts +++ b/test/typescript/middleware.ts @@ -1,15 +1,15 @@ import { Middleware, MiddlewareAPI, applyMiddleware, createStore, Dispatch, Reducer, Action -} from "../../index.d.ts"; +} from "../../"; -declare module "../../index.d.ts" { +declare module "../../" { export interface Dispatch { (asyncAction: (dispatch: Dispatch, getState: () => S) => R): R; } } -type Thunk = (dispatch: Dispatch, getState: () => S) => O; +type Thunk = (dispatch: Dispatch, getState?: () => S) => O; const thunkMiddleware: Middleware = ({dispatch, getState}: MiddlewareAPI) => @@ -52,7 +52,7 @@ const storeWithThunkMiddleware = createStore( ); storeWithThunkMiddleware.dispatch( - (dispatch, getState) => { + (dispatch: Dispatch, getState: () => State) => { const todos: string[] = getState().todos; dispatch({type: 'ADD_TODO'}) } diff --git a/test/typescript/reducers.ts b/test/typescript/reducers.ts index 215b872349..9e49bce9c9 100644 --- a/test/typescript/reducers.ts +++ b/test/typescript/reducers.ts @@ -1,7 +1,7 @@ import { Reducer, Action, combineReducers, ReducersMapObject -} from "../../index.d.ts"; +} from "../../"; type TodosState = string[]; @@ -47,8 +47,7 @@ type RootState = { counter: CounterState; } - -const rootReducer: Reducer = combineReducers({ +const rootReducer: Reducer = combineReducers({ todos: todosReducer, counter: counterReducer, }) diff --git a/test/typescript/store.ts b/test/typescript/store.ts index 2939a237dd..0de19af192 100644 --- a/test/typescript/store.ts +++ b/test/typescript/store.ts @@ -1,7 +1,7 @@ import { Store, createStore, Reducer, Action, StoreEnhancer, GenericStoreEnhancer, StoreCreator, StoreEnhancerStoreCreator, Unsubscribe -} from "../../index.d.ts"; +} from "../../"; type State = { @@ -22,10 +22,10 @@ const storeWithPreloadedState: Store = createStore(reducer, { }); const genericEnhancer: GenericStoreEnhancer = (next: StoreEnhancerStoreCreator) => next; -const specificEnhencer: StoreEnhancer = next => next; +const specificEnhancer: StoreEnhancer = next => next; const storeWithGenericEnhancer: Store = createStore(reducer, genericEnhancer); -const storeWithSpecificEnhancer: Store = createStore(reducer, specificEnhencer); +const storeWithSpecificEnhancer: Store = createStore(reducer, specificEnhancer); const storeWithPreloadedStateAndEnhancer: Store = createStore(reducer, { todos: [] diff --git a/yarn.lock b/yarn.lock index abbf0a201f..e310a94394 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2719,7 +2719,11 @@ lodash.flatten@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" -lodash.isarguments@^3.0.0, lodash.isarguments@~3.0.7: +lodash.isarguments@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + +lodash.isarguments@~3.0.7: version "3.0.9" resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.0.9.tgz#3c4994a4210f340d49ccfafa62176296207d8675" @@ -4310,17 +4314,17 @@ typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" -typescript-definition-tester@0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/typescript-definition-tester/-/typescript-definition-tester-0.0.4.tgz#94b9edc4fe803b47f5f64ff5ddaf8eed1196156c" +typescript-definition-tester@0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/typescript-definition-tester/-/typescript-definition-tester-0.0.5.tgz#91c574d78ea05b81ed81244d50ec30d8240c356f" dependencies: assertion-error "^1.0.1" dts-bundle "^0.2.0" lodash "^3.6.0" -typescript@^1.8.0: - version "1.8.10" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-1.8.10.tgz#b475d6e0dff0bf50f296e5ca6ef9fbb5c7320f1e" +typescript@^2.1.0: + version "2.1.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.1.4.tgz#b53b69fb841126acb1dd4b397d21daba87572251" uglify-js@^2.6, uglify-js@~2.7.3: version "2.7.5"