This commit is contained in:
Dan Abramov 2015-09-28 18:38:33 +03:00
parent 26c66fef63
commit 4428e8818d
10 changed files with 142 additions and 169 deletions

View File

@ -28,8 +28,8 @@
"node-libs-browser": "^0.5.2",
"react-dock": "^0.1.0",
"react-hot-loader": "^1.3.0",
"redux-devtools": "^3.0.0-alpha-6",
"redux-devtools-log-monitor": "^1.0.0-alpha-6",
"redux-devtools": "^3.0.0-alpha-7",
"redux-devtools-log-monitor": "^1.0.0-alpha-7",
"webpack": "^1.9.11",
"webpack-dev-server": "^1.9.0"
}

View File

@ -6,7 +6,7 @@ import DevTools from '../containers/DevTools';
const finalCreateStore = compose(
applyMiddleware(thunk),
DevTools.enhance,
DevTools.instrument(),
persistState(
window.location.href.match(
/[?&]debug_session=([^&]+)\b/

View File

@ -1,13 +1,13 @@
import { bindActionCreators } from 'redux';
export default function bindActionCreatorsRecursively(actionCreators, dispatch) {
export default function bindActionCreatorsDeep(actionCreators, dispatch) {
return Object.keys(actionCreators).reduce((result, key) => {
if (!actionCreators[key]) {
return result;
}
switch (typeof actionCreators[key]) {
case 'object':
result[key] = bindActionCreatorsRecursively(actionCreators[key], dispatch);
result[key] = bindActionCreatorsDeep(actionCreators[key], dispatch);
break;
case 'function':
result[key] = bindActionCreators(actionCreators[key], dispatch);

View File

@ -1,7 +1,7 @@
import { bindActionCreators } from 'redux';
import bindActionCreatorsRecursively from './bindActionCreatorsRecursively';
import bindActionCreatorsDeep from './bindActionCreatorsDeep';
import { connect } from 'react-redux';
import { ActionCreators as devToolsActionCreators } from './enhance';
import { ActionCreators as historyActionCreators } from './instrument';
export default function connectMonitor({
component,
@ -10,15 +10,15 @@ export default function connectMonitor({
}) {
function mapStateToProps(state) {
return {
devToolsState: state.devToolsState,
historyState: state.historyState,
monitorState: state.monitorState
};
}
function mapDispatchToProps(dispatch) {
return {
devToolsActions: bindActionCreators(devToolsActionCreators, dispatch),
monitorActions: bindActionCreatorsRecursively(actionCreators, dispatch)
historyActions: bindActionCreators(historyActionCreators, dispatch),
monitorActions: bindActionCreatorsDeep(actionCreators, dispatch)
};
}

View File

@ -1,5 +1,5 @@
import React, { Component, PropTypes } from 'react';
import enhance from './enhance';
import instrument from './instrument';
import connectMonitor from './connectMonitor';
export default function createDevTools(monitor) {
@ -10,12 +10,12 @@ export default function createDevTools(monitor) {
store: PropTypes.object.isRequired
};
static enhance = enhance(Monitor.reducer);
static instrument = () => instrument(Monitor.reducer);
render() {
return (
<Monitor {...this.props}
store={this.context.store.devToolsStore} />
store={this.context.store.instrumentedStore} />
);
}
};

View File

@ -1,3 +1,3 @@
export { default, ActionCreators, ActionTypes } from './enhance';
export { default as instrument, ActionTypes } from './instrument';
export { default as persistState } from './persistState';
export { default as createDevTools } from './createDevTools';

View File

@ -1,6 +1,6 @@
import { combineReducers } from 'redux';
const ActionTypes = {
export const ActionTypes = {
PERFORM_ACTION: 'PERFORM_ACTION',
RESET: 'RESET',
ROLLBACK: 'ROLLBACK',
@ -10,10 +10,41 @@ const ActionTypes = {
JUMP_TO_STATE: 'JUMP_TO_STATE'
};
const INIT_ACTION = {
type: '@@INIT'
/**
* Action creators to change the History state.
*/
export const ActionCreators = {
performAction(action) {
return { type: ActionTypes.PERFORM_ACTION, action, timestamp: Date.now() };
},
reset() {
return { type: ActionTypes.RESET, timestamp: Date.now() };
},
rollback() {
return { type: ActionTypes.ROLLBACK, timestamp: Date.now() };
},
commit() {
return { type: ActionTypes.COMMIT, timestamp: Date.now() };
},
sweep() {
return { type: ActionTypes.SWEEP };
},
toggleAction(index) {
return { type: ActionTypes.TOGGLE_ACTION, index };
},
jumpToState(index) {
return { type: ActionTypes.JUMP_TO_STATE, index };
}
};
const INIT_ACTION = { type: '@@INIT' };
function toggle(obj, key) {
const clone = { ...obj };
if (clone[key]) {
@ -52,8 +83,6 @@ function computeNextEntry(reducer, action, state, error) {
/**
* Runs the reducer on all actions to get a fresh computation log.
* It's probably a good idea to do this only if the code has changed,
* but until we have some tests we'll just do it every time an action fires.
*/
function recomputeStates(reducer, committedState, stagedActions, skippedActions) {
const computedStates = [];
@ -77,10 +106,10 @@ function recomputeStates(reducer, committedState, stagedActions, skippedActions)
}
/**
* Lifts the app state reducer into a DevTools state reducer.
* Creates a history state reducer from an app's reducer.
*/
function createDevToolsStateReducer(reducer, initialCommittedState) {
const initialState = {
function createHistoryReducer(reducer, initialCommittedState) {
const initialHistoryState = {
committedState: initialCommittedState,
stagedActions: [INIT_ACTION],
skippedActions: {},
@ -89,9 +118,9 @@ function createDevToolsStateReducer(reducer, initialCommittedState) {
};
/**
* Manages how the DevTools actions modify the DevTools state.
* Manages how the history actions modify the history state.
*/
return function devToolsState(state = initialState, action) {
return (historyState = initialHistoryState, historyAction) => {
let shouldRecomputeStates = true;
let {
committedState,
@ -100,34 +129,34 @@ function createDevToolsStateReducer(reducer, initialCommittedState) {
computedStates,
currentStateIndex,
timestamps
} = state;
} = historyState;
switch (action.type) {
switch (historyAction.type) {
case ActionTypes.RESET:
committedState = initialState;
committedState = initialCommittedState;
stagedActions = [INIT_ACTION];
skippedActions = {};
currentStateIndex = 0;
timestamps = [action.timestamp];
timestamps = [historyAction.timestamp];
break;
case ActionTypes.COMMIT:
committedState = computedStates[currentStateIndex].state;
stagedActions = [INIT_ACTION];
skippedActions = {};
currentStateIndex = 0;
timestamps = [action.timestamp];
timestamps = [historyAction.timestamp];
break;
case ActionTypes.ROLLBACK:
stagedActions = [INIT_ACTION];
skippedActions = {};
currentStateIndex = 0;
timestamps = [action.timestamp];
timestamps = [historyAction.timestamp];
break;
case ActionTypes.TOGGLE_ACTION:
skippedActions = toggle(skippedActions, action.index);
skippedActions = toggle(skippedActions, historyAction.index);
break;
case ActionTypes.JUMP_TO_STATE:
currentStateIndex = action.index;
currentStateIndex = historyAction.index;
// Optimization: we know the history has not changed.
shouldRecomputeStates = false;
break;
@ -142,8 +171,8 @@ function createDevToolsStateReducer(reducer, initialCommittedState) {
currentStateIndex++;
}
stagedActions = [...stagedActions, action.action];
timestamps = [...timestamps, action.timestamp];
stagedActions = [...stagedActions, historyAction.action];
timestamps = [...timestamps, historyAction.timestamp];
// Optimization: we know that the past has not changed.
shouldRecomputeStates = false;
@ -151,7 +180,7 @@ function createDevToolsStateReducer(reducer, initialCommittedState) {
const previousEntry = computedStates[computedStates.length - 1];
const nextEntry = computeNextEntry(
reducer,
action.action,
historyAction.action,
previousEntry.state,
previousEntry.error
);
@ -182,44 +211,32 @@ function createDevToolsStateReducer(reducer, initialCommittedState) {
}
/**
* Lifts an app action to a DevTools action.
* Provides a view into the History state that matches the current app state.
*/
function liftAction(action) {
const liftedAction = {
type: ActionTypes.PERFORM_ACTION,
action,
timestamp: Date.now()
};
return liftedAction;
}
/**
* Unlifts the DevTools state to the app state.
*/
function unliftState(liftedState) {
const { computedStates, currentStateIndex } = liftedState.devToolsState;
function selectAppState(instrumentedState) {
const { computedStates, currentStateIndex } = instrumentedState.historyState;
const { state } = computedStates[currentStateIndex];
return state;
}
/**
* Unlifts the DevTools store to act like the app's store.
* Deinstruments the History store to act like the app's store.
*/
function mapToComputedStateStore(devToolsStore, wrapReducer) {
function selectAppStore(instrumentedStore, instrumentReducer) {
let lastDefinedState;
return {
...devToolsStore,
...instrumentedStore,
devToolsStore,
instrumentedStore,
dispatch(action) {
devToolsStore.dispatch(liftAction(action));
instrumentedStore.dispatch(ActionCreators.performAction(action));
return action;
},
getState() {
const state = unliftState(devToolsStore.getState());
const state = selectAppState(instrumentedStore.getState());
if (state !== undefined) {
lastDefinedState = state;
}
@ -227,51 +244,25 @@ function mapToComputedStateStore(devToolsStore, wrapReducer) {
},
replaceReducer(nextReducer) {
devToolsStore.replaceReducer(wrapReducer(nextReducer));
instrumentedStore.replaceReducer(instrumentReducer(nextReducer));
}
};
}
/**
* Action creators to change the DevTools state.
* Redux History store enhancer.
*/
export const ActionCreators = {
reset() {
return { type: ActionTypes.RESET, timestamp: Date.now() };
},
rollback() {
return { type: ActionTypes.ROLLBACK, timestamp: Date.now() };
},
commit() {
return { type: ActionTypes.COMMIT, timestamp: Date.now() };
},
sweep() {
return { type: ActionTypes.SWEEP };
},
toggleAction(index) {
return { type: ActionTypes.TOGGLE_ACTION, index };
},
jumpToState(index) {
return { type: ActionTypes.JUMP_TO_STATE, index };
}
};
/**
* Redux DevTools store enhancer.
*/
export default function enhance(monitorReducer = () => null) {
return next => (reducer, initialState) => {
const wrapReducer = (r) => combineReducers({
devToolsState: createDevToolsStateReducer(r, initialState),
monitorState: monitorReducer
export default function instrument(monitorReducer = () => null) {
return createStore => (reducer, initialState) => {
function instrumentReducer(r) {
const historyReducer = createHistoryReducer(r, initialState);
return ({ historyState, monitorState } = {}, action) => ({
historyState: historyReducer(historyState, action),
monitorState: monitorReducer(monitorState, action)
});
}
const devToolsStore = next(wrapReducer(reducer));
return mapToComputedStateStore(devToolsStore, wrapReducer);
const instrumentedStore = createStore(instrumentReducer(reducer));
return selectAppStore(instrumentedStore, instrumentReducer);
};
}

View File

@ -1,42 +1,22 @@
export default function persistState(sessionId, stateDeserializer = null, actionDeserializer = null) {
const identity = _ => _;
export default function persistState(sessionId, deserializeState = identity, deserializeAction = identity) {
if (!sessionId) {
return next => (...args) => next(...args);
}
function deserializeState(fullState) {
return {
...fullState,
committedState: stateDeserializer(fullState.committedState),
computedStates: fullState.computedStates.map((computedState) => {
function deserialize({ historyState, ...rest }) {
return {
...rest,
historyState: {
...historyState,
stagedActions: historyState.stagedActions.map(deserializeAction),
committedState: deserializeState(historyState.committedState),
computedStates: historyState.computedStates.map(computedState => ({
...computedState,
state: stateDeserializer(computedState.state)
state: deserializeState(computedState.state)
}))
}
};
})
};
}
function deserializeActions(fullState) {
return {
...fullState,
stagedActions: fullState.stagedActions.map((action) => {
return actionDeserializer(action);
})
};
}
function deserialize(fullState) {
if (!fullState) {
return fullState;
}
let deserializedState = fullState;
if (typeof stateDeserializer === 'function') {
deserializedState = deserializeState(deserializedState);
}
if (typeof actionDeserializer === 'function') {
deserializedState = deserializeActions(deserializedState);
}
return deserializedState;
}
return next => (reducer, initialState) => {
@ -44,8 +24,11 @@ export default function persistState(sessionId, stateDeserializer = null, action
let finalInitialState;
try {
finalInitialState = deserialize(JSON.parse(localStorage.getItem(key))) || initialState;
const json = localStorage.getItem(key);
if (json) {
finalInitialState = deserialize(JSON.parse(json)) || initialState;
next(reducer, initialState);
}
} catch (e) {
console.warn('Could not read debug session from localStorage:', e);
try {

View File

@ -1,6 +1,6 @@
import expect, { spyOn } from 'expect';
import { createStore } from 'redux';
import devTools, { ActionCreators } from '../src/devTools';
import instrument, { ActionCreators } from '../src/instrument';
function counter(state = 0, action) {
switch (action.type) {
@ -27,13 +27,13 @@ function doubleCounter(state = 0, action) {
}
}
describe('devTools', () => {
describe('instrument', () => {
let store;
let devToolsStore;
let instrumentedStore;
beforeEach(() => {
store = devTools()(createStore)(counter);
devToolsStore = store.devToolsStore;
store = instrument()(createStore)(counter);
instrumentedStore = store.instrumentedStore;
});
it('should perform actions', () => {
@ -49,20 +49,20 @@ describe('devTools', () => {
store.dispatch({ type: 'INCREMENT' });
expect(store.getState()).toBe(2);
devToolsStore.dispatch(ActionCreators.commit());
instrumentedStore.dispatch(ActionCreators.commit());
expect(store.getState()).toBe(2);
store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'INCREMENT' });
expect(store.getState()).toBe(4);
devToolsStore.dispatch(ActionCreators.rollback());
instrumentedStore.dispatch(ActionCreators.rollback());
expect(store.getState()).toBe(2);
store.dispatch({ type: 'DECREMENT' });
expect(store.getState()).toBe(1);
devToolsStore.dispatch(ActionCreators.rollback());
instrumentedStore.dispatch(ActionCreators.rollback());
expect(store.getState()).toBe(2);
});
@ -70,19 +70,19 @@ describe('devTools', () => {
store.dispatch({ type: 'INCREMENT' });
expect(store.getState()).toBe(1);
devToolsStore.dispatch(ActionCreators.commit());
instrumentedStore.dispatch(ActionCreators.commit());
expect(store.getState()).toBe(1);
store.dispatch({ type: 'INCREMENT' });
expect(store.getState()).toBe(2);
devToolsStore.dispatch(ActionCreators.rollback());
instrumentedStore.dispatch(ActionCreators.rollback());
expect(store.getState()).toBe(1);
store.dispatch({ type: 'INCREMENT' });
expect(store.getState()).toBe(2);
devToolsStore.dispatch(ActionCreators.reset());
instrumentedStore.dispatch(ActionCreators.reset());
expect(store.getState()).toBe(0);
});
@ -93,10 +93,10 @@ describe('devTools', () => {
store.dispatch({ type: 'INCREMENT' });
expect(store.getState()).toBe(1);
devToolsStore.dispatch(ActionCreators.toggleAction(2));
instrumentedStore.dispatch(ActionCreators.toggleAction(2));
expect(store.getState()).toBe(2);
devToolsStore.dispatch(ActionCreators.toggleAction(2));
instrumentedStore.dispatch(ActionCreators.toggleAction(2));
expect(store.getState()).toBe(1);
});
@ -108,16 +108,16 @@ describe('devTools', () => {
store.dispatch({ type: 'INCREMENT' });
expect(store.getState()).toBe(2);
devToolsStore.dispatch(ActionCreators.toggleAction(2));
instrumentedStore.dispatch(ActionCreators.toggleAction(2));
expect(store.getState()).toBe(3);
devToolsStore.dispatch(ActionCreators.sweep());
instrumentedStore.dispatch(ActionCreators.sweep());
expect(store.getState()).toBe(3);
devToolsStore.dispatch(ActionCreators.toggleAction(2));
instrumentedStore.dispatch(ActionCreators.toggleAction(2));
expect(store.getState()).toBe(2);
devToolsStore.dispatch(ActionCreators.sweep());
instrumentedStore.dispatch(ActionCreators.sweep());
expect(store.getState()).toBe(2);
});
@ -127,19 +127,19 @@ describe('devTools', () => {
store.dispatch({ type: 'INCREMENT' });
expect(store.getState()).toBe(1);
devToolsStore.dispatch(ActionCreators.jumpToState(0));
instrumentedStore.dispatch(ActionCreators.jumpToState(0));
expect(store.getState()).toBe(0);
devToolsStore.dispatch(ActionCreators.jumpToState(1));
instrumentedStore.dispatch(ActionCreators.jumpToState(1));
expect(store.getState()).toBe(1);
devToolsStore.dispatch(ActionCreators.jumpToState(2));
instrumentedStore.dispatch(ActionCreators.jumpToState(2));
expect(store.getState()).toBe(0);
store.dispatch({ type: 'INCREMENT' });
expect(store.getState()).toBe(0);
devToolsStore.dispatch(ActionCreators.jumpToState(4));
instrumentedStore.dispatch(ActionCreators.jumpToState(4));
expect(store.getState()).toBe(2);
});
@ -155,17 +155,17 @@ describe('devTools', () => {
it('should catch and record errors', () => {
let spy = spyOn(console, 'error');
let storeWithBug = devTools()(createStore)(counterWithBug);
let storeWithBug = instrument()(createStore)(counterWithBug);
storeWithBug.dispatch({ type: 'INCREMENT' });
storeWithBug.dispatch({ type: 'DECREMENT' });
storeWithBug.dispatch({ type: 'INCREMENT' });
let devStoreState = storeWithBug.devToolsStore.getState();
expect(devStoreState.computedStates[2].error).toMatch(
let historyState = storeWithBug.instrumentedStore.getState().historyState;
expect(historyState.computedStates[2].error).toMatch(
/ReferenceError/
);
expect(devStoreState.computedStates[3].error).toMatch(
expect(historyState.computedStates[3].error).toMatch(
/Interrupted by an error up the chain/
);
expect(spy.calls[0].arguments[0]).toMatch(
@ -176,7 +176,7 @@ describe('devTools', () => {
});
it('should return the last non-undefined state from getState', () => {
let storeWithBug = devTools()(createStore)(counterWithBug);
let storeWithBug = instrument()(createStore)(counterWithBug);
storeWithBug.dispatch({ type: 'INCREMENT' });
storeWithBug.dispatch({ type: 'INCREMENT' });
expect(storeWithBug.getState()).toBe(2);
@ -187,7 +187,7 @@ describe('devTools', () => {
it('should not recompute states on every action', () => {
let reducerCalls = 0;
let monitoredStore = devTools()(createStore)(() => reducerCalls++);
let monitoredStore = instrument()(createStore)(() => reducerCalls++);
expect(reducerCalls).toBe(1);
monitoredStore.dispatch({ type: 'INCREMENT' });
monitoredStore.dispatch({ type: 'INCREMENT' });
@ -197,8 +197,8 @@ describe('devTools', () => {
it('should not recompute states when jumping to state', () => {
let reducerCalls = 0;
let monitoredStore = devTools()(createStore)(() => reducerCalls++);
let monitoredDevToolsStore = monitoredStore.devToolsStore;
let monitoredStore = instrument()(createStore)(() => reducerCalls++);
let monitoredInstrumentedStore = monitoredStore.instrumentedStore;
expect(reducerCalls).toBe(1);
monitoredStore.dispatch({ type: 'INCREMENT' });
@ -206,13 +206,13 @@ describe('devTools', () => {
monitoredStore.dispatch({ type: 'INCREMENT' });
expect(reducerCalls).toBe(4);
monitoredDevToolsStore.dispatch(ActionCreators.jumpToState(0));
monitoredInstrumentedStore.dispatch(ActionCreators.jumpToState(0));
expect(reducerCalls).toBe(4);
monitoredDevToolsStore.dispatch(ActionCreators.jumpToState(1));
monitoredInstrumentedStore.dispatch(ActionCreators.jumpToState(1));
expect(reducerCalls).toBe(4);
monitoredDevToolsStore.dispatch(ActionCreators.jumpToState(3));
monitoredInstrumentedStore.dispatch(ActionCreators.jumpToState(3));
expect(reducerCalls).toBe(4);
});
});

View File

@ -1,6 +1,5 @@
import expect from 'expect';
import devTools from '../src/devTools';
import persistState from '../src/persistState';
import { instrument, persistState } from '../src';
import { compose, createStore } from 'redux';
describe('persistState', () => {
@ -40,7 +39,7 @@ describe('persistState', () => {
};
it('should persist state', () => {
const finalCreateStore = compose(devTools(), persistState('id'))(createStore);
const finalCreateStore = compose(instrument(), persistState('id'))(createStore);
const store = finalCreateStore(reducer);
expect(store.getState()).toBe(0);
@ -53,7 +52,7 @@ describe('persistState', () => {
});
it('should not persist state if no session id', () => {
const finalCreateStore = compose(devTools(), persistState())(createStore);
const finalCreateStore = compose(instrument(), persistState())(createStore);
const store = finalCreateStore(reducer);
expect(store.getState()).toBe(0);
@ -67,7 +66,7 @@ describe('persistState', () => {
it('should run with a custom state deserializer', () => {
const oneLess = state => state === undefined ? -1 : state - 1;
const finalCreateStore = compose(devTools(), persistState('id', oneLess))(createStore);
const finalCreateStore = compose(instrument(), persistState('id', oneLess))(createStore);
const store = finalCreateStore(reducer);
expect(store.getState()).toBe(0);
@ -81,7 +80,7 @@ describe('persistState', () => {
it('should run with a custom action deserializer', () => {
const incToDec = action => action.type === 'INCREMENT' ? { type: 'DECREMENT' } : action;
const finalCreateStore = compose(devTools(), persistState('id', null, incToDec))(createStore);
const finalCreateStore = compose(instrument(), persistState('id', undefined, incToDec))(createStore);
const store = finalCreateStore(reducer);
expect(store.getState()).toBe(0);
@ -95,7 +94,7 @@ describe('persistState', () => {
it('should warn if read from localStorage fails', () => {
const spy = expect.spyOn(console, 'warn');
const finalCreateStore = compose(devTools(), persistState('id'))(createStore);
const finalCreateStore = compose(instrument(), persistState('id'))(createStore);
delete global.localStorage.getItem;
finalCreateStore(reducer);
@ -108,7 +107,7 @@ describe('persistState', () => {
});
it('should warn if write to localStorage fails', () => {
const spy = expect.spyOn(console, 'warn');
const finalCreateStore = compose(devTools(), persistState('id'))(createStore);
const finalCreateStore = compose(instrument(), persistState('id'))(createStore);
delete global.localStorage.setItem;
const store = finalCreateStore(reducer);