Figure out monitor composition

This commit is contained in:
Dan Abramov 2015-09-28 03:52:10 +03:00
parent 1edf2d25c8
commit c1256ed8ff
9 changed files with 277 additions and 78 deletions

View File

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

View File

@ -1,8 +1,9 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { DevToolsProvider } from 'redux-devtools';
import CounterApp from './CounterApp'; import CounterApp from './CounterApp';
import Dock from 'react-dock';
import LogMonitor from 'redux-devtools-log-monitor'; import LogMonitor from 'redux-devtools-log-monitor';
import DockMonitor from '../dock/DockMonitor';
export default class Root extends Component { export default class Root extends Component {
render() { render() {
@ -12,9 +13,11 @@ export default class Root extends Component {
<Provider store={store}> <Provider store={store}>
<CounterApp /> <CounterApp />
</Provider> </Provider>
<Dock position='right' isVisible dimMode='none'> <DevToolsProvider store={store}>
<LogMonitor store={store} /> <DockMonitor>
</Dock> <LogMonitor />
</DockMonitor>
</DevToolsProvider>
</div> </div>
); );
} }

View File

@ -0,0 +1,109 @@
//
// TODO: extract to a separate project.
//
import React, { Component, PropTypes } from 'react';
import Dock from 'react-dock';
import MapProvider from './MapProvider';
import { connect } from 'react-redux';
import { combineReducers } from 'redux';
const TOGGLE_VISIBILITY = '@@redux-devtools/dock/TOGGLE_VISIBILITY';
function toggleVisibility() {
return { type: TOGGLE_VISIBILITY };
}
const CHANGE_POSITION = '@@redux-devtools/dock/CHANGE_POSITION';
function changePosition() {
return { type: CHANGE_POSITION };
}
const POSITIONS = ['left', 'top', 'right', 'bottom'];
function wrapReducer(options = {}) {
const {
isVisible: initialIsVisible = true,
position: initialPosition = 'right'
} = options;
function position(state = initialPosition, action) {
return (action.type === CHANGE_POSITION) ?
POSITIONS[(POSITIONS.indexOf(state) + 1) % POSITIONS.length] :
state;
}
function isVisible(state = initialIsVisible, action) {
return (action.type === TOGGLE_VISIBILITY) ?
!state :
state;
}
return childMonitorReducer => combineReducers({
childMonitorState: childMonitorReducer,
position,
isVisible
});
}
function mapUpstreamStateToDownstreamState(state) {
return {
devToolsState: state.devToolsState,
monitorState: state.monitorState.childMonitorState
};
}
@connect(
state => state.monitorState,
{ toggleVisibility, changePosition }
)
export default class DockMonitor extends Component {
static propTypes = {
position: PropTypes.oneOf(['left', 'top', 'right', 'bottom']).isRequired,
isVisible: PropTypes.bool.isRequired,
childMonitorState: PropTypes.any,
toggleVisibility: PropTypes.func.isRequired,
changePosition: PropTypes.func.isRequired
};
componentDidMount() {
this.handleKeyDown = this.handleKeyDown.bind(this);
window.addEventListener('keydown', this.handleKeyDown);
}
componentWillUnmount() {
window.removeEventListener('keydown', this.handleKeyDown);
}
handleKeyDown(e) {
if (!e.ctrlKey) {
return;
}
e.preventDefault();
const key = event.keyCode || event.which;
const char = String.fromCharCode(key);
switch (char) {
case 'H':
this.props.toggleVisibility();
break;
case 'D':
this.props.changePosition();
break;
}
}
render() {
const { position, isVisible, children } = this.props;
return (
<Dock position={position}
isVisible={isVisible}
dimMode='none'>
<MapProvider mapState={mapUpstreamStateToDownstreamState}>
{children}
</MapProvider>
</Dock>
);
}
}
DockMonitor.wrapReducer = wrapReducer;

View File

@ -0,0 +1,52 @@
//
// TODO: extract to a separate project.
//
import React, { Children, Component, PropTypes } from 'react';
const identity = _ => _;
function mapStore(store, { mapAction = identity, mapState = identity }) {
return {
...store,
dispatch(action) {
return store.dispatch(mapAction(action));
},
subscribe(listener) {
return store.subscribe(listener);
},
getState() {
return mapState(store.getState());
}
};
}
export default class MapProvider extends Component {
static propTypes = {
mapAction: PropTypes.func,
mapState: PropTypes.func
};
static contextTypes = {
store: PropTypes.object.isRequired
};
static childContextTypes = {
store: PropTypes.object.isRequired
};
getChildContext() {
return {
store: this.store
};
}
constructor(props, context) {
super(props, context);
this.store = mapStore(context.store, props);
}
render() {
return Children.only(this.props.children);
}
}

View File

@ -1,15 +1,28 @@
import { createStore, applyMiddleware, compose } from 'redux'; import { createStore, applyMiddleware, compose } from 'redux';
import { devTools, persistState } from 'redux-devtools'; import devTools, { persistState } from 'redux-devtools';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import rootReducer from '../reducers'; import rootReducer from '../reducers';
import { createMonitorReducer } from 'redux-devtools-log-monitor'; import DockMonitor from '../dock/DockMonitor';
import LogMonitor from 'redux-devtools-log-monitor';
const finalCreateStore = compose( const finalCreateStore = compose(
applyMiddleware(thunk), applyMiddleware(
devTools(createMonitorReducer({ thunk
isVisibleOnLoad: true ),
})), devTools(
persistState(window.location.href.match(/[?&]debug_session=([^&]+)\b/)) LogMonitor.createReducer({
preserveScrollTop: true
}),
DockMonitor.wrapReducer({
position: 'right',
isVisible: true
})
),
persistState(
window.location.href.match(
/[?&]debug_session=([^&]+)\b/
)
)
)(createStore); )(createStore);
export default function configureStore(initialState) { export default function configureStore(initialState) {

37
src/DevToolsProvider.js Normal file
View File

@ -0,0 +1,37 @@
import React, { Component } from 'react';
import { Provider } from 'react-redux';
export default class DevToolsProvider extends Component {
static propTypes = {
store(props, propName, componentName) {
if (!props.store) {
return new Error('Required prop `store` was not specified in `' + componentName + '`.');
}
if (!props.store.devToolsStore) {
return new Error(
'Could not find the DevTools store inside the `store` prop passed to `' +
componentName +
'`. Have you applied the devTools() store enhancer?'
);
}
}
};
render() {
const { store, children } = this.props;
if (!store) {
return null;
}
const { devToolsStore } = store;
if (!devToolsStore) {
return null;
}
return (
<Provider store={devToolsStore}>
{children}
</Provider>
);
}
}

View File

@ -16,38 +16,6 @@ export default function connectMonitor(monitorActionCreators = {}) {
} }
const ConnectedMonitor = connect(mapStateToProps, mapDispatchToProps)(Monitor); const ConnectedMonitor = connect(mapStateToProps, mapDispatchToProps)(Monitor);
class DevTools extends Component {
static Monitor = Monitor;
static propTypes = {
store(props, propName, componentName) {
if (!props.store) {
return new Error('Required prop `store` was not specified in `' + componentName + '`.');
}
if (!props.store.devToolsStore) {
return new Error(
'Could not find the DevTools store inside the `store` prop passed to `' +
componentName +
'`. Have you applied the devTools() store enhancer?'
);
}
}
};
render() {
const { store } = this.props;
if (!store) {
return null;
}
const { devToolsStore } = store;
if (!devToolsStore) {
return null;
}
return <ConnectedMonitor {...this.props} store={devToolsStore} />;
}
}
return DevTools; return DevTools;
}; };

View File

@ -1,3 +1,5 @@
import { combineReducers, compose } from 'redux';
const ActionTypes = { const ActionTypes = {
PERFORM_ACTION: 'PERFORM_ACTION', PERFORM_ACTION: 'PERFORM_ACTION',
RESET: 'RESET', RESET: 'RESET',
@ -77,20 +79,19 @@ function recomputeStates(reducer, committedState, stagedActions, skippedActions)
/** /**
* Lifts the app state reducer into a DevTools state reducer. * Lifts the app state reducer into a DevTools state reducer.
*/ */
function liftReducer(reducer, monitorReducer, initialState) { function createDevToolsStateReducer(reducer, initialCommittedState) {
const initialLiftedState = { const initialState = {
committedState: initialState, committedState: initialCommittedState,
stagedActions: [INIT_ACTION], stagedActions: [INIT_ACTION],
skippedActions: {}, skippedActions: {},
currentStateIndex: 0, currentStateIndex: 0,
monitorState: monitorReducer(undefined, INIT_ACTION),
timestamps: [Date.now()] timestamps: [Date.now()]
}; };
/** /**
* Manages how the DevTools actions modify the DevTools state. * Manages how the DevTools actions modify the DevTools state.
*/ */
return function liftedReducer(liftedState = initialLiftedState, liftedAction) { return function devToolsState(state = initialState, action) {
let shouldRecomputeStates = true; let shouldRecomputeStates = true;
let { let {
committedState, committedState,
@ -98,36 +99,35 @@ function liftReducer(reducer, monitorReducer, initialState) {
skippedActions, skippedActions,
computedStates, computedStates,
currentStateIndex, currentStateIndex,
monitorState,
timestamps timestamps
} = liftedState; } = state;
switch (liftedAction.type) { switch (action.type) {
case ActionTypes.RESET: case ActionTypes.RESET:
committedState = initialState; committedState = initialState;
stagedActions = [INIT_ACTION]; stagedActions = [INIT_ACTION];
skippedActions = {}; skippedActions = {};
currentStateIndex = 0; currentStateIndex = 0;
timestamps = [liftedAction.timestamp]; timestamps = [action.timestamp];
break; break;
case ActionTypes.COMMIT: case ActionTypes.COMMIT:
committedState = computedStates[currentStateIndex].state; committedState = computedStates[currentStateIndex].state;
stagedActions = [INIT_ACTION]; stagedActions = [INIT_ACTION];
skippedActions = {}; skippedActions = {};
currentStateIndex = 0; currentStateIndex = 0;
timestamps = [liftedAction.timestamp]; timestamps = [action.timestamp];
break; break;
case ActionTypes.ROLLBACK: case ActionTypes.ROLLBACK:
stagedActions = [INIT_ACTION]; stagedActions = [INIT_ACTION];
skippedActions = {}; skippedActions = {};
currentStateIndex = 0; currentStateIndex = 0;
timestamps = [liftedAction.timestamp]; timestamps = [action.timestamp];
break; break;
case ActionTypes.TOGGLE_ACTION: case ActionTypes.TOGGLE_ACTION:
skippedActions = toggle(skippedActions, liftedAction.index); skippedActions = toggle(skippedActions, action.index);
break; break;
case ActionTypes.JUMP_TO_STATE: case ActionTypes.JUMP_TO_STATE:
currentStateIndex = liftedAction.index; currentStateIndex = action.index;
// Optimization: we know the history has not changed. // Optimization: we know the history has not changed.
shouldRecomputeStates = false; shouldRecomputeStates = false;
break; break;
@ -142,8 +142,8 @@ function liftReducer(reducer, monitorReducer, initialState) {
currentStateIndex++; currentStateIndex++;
} }
stagedActions = [...stagedActions, liftedAction.action]; stagedActions = [...stagedActions, action.action];
timestamps = [...timestamps, liftedAction.timestamp]; timestamps = [...timestamps, action.timestamp];
// Optimization: we know that the past has not changed. // Optimization: we know that the past has not changed.
shouldRecomputeStates = false; shouldRecomputeStates = false;
@ -151,7 +151,7 @@ function liftReducer(reducer, monitorReducer, initialState) {
const previousEntry = computedStates[computedStates.length - 1]; const previousEntry = computedStates[computedStates.length - 1];
const nextEntry = computeNextEntry( const nextEntry = computeNextEntry(
reducer, reducer,
liftedAction.action, action.action,
previousEntry.state, previousEntry.state,
previousEntry.error previousEntry.error
); );
@ -170,15 +170,12 @@ function liftReducer(reducer, monitorReducer, initialState) {
); );
} }
monitorState = monitorReducer(monitorState, liftedAction);
return { return {
committedState, committedState,
stagedActions, stagedActions,
skippedActions, skippedActions,
computedStates, computedStates,
currentStateIndex, currentStateIndex,
monitorState,
timestamps timestamps
}; };
}; };
@ -200,7 +197,7 @@ function liftAction(action) {
* Unlifts the DevTools state to the app state. * Unlifts the DevTools state to the app state.
*/ */
function unliftState(liftedState) { function unliftState(liftedState) {
const { computedStates, currentStateIndex } = liftedState; const { computedStates, currentStateIndex } = liftedState.devToolsState;
const { state } = computedStates[currentStateIndex]; const { state } = computedStates[currentStateIndex];
return state; return state;
} }
@ -208,24 +205,29 @@ function unliftState(liftedState) {
/** /**
* Unlifts the DevTools store to act like the app's store. * Unlifts the DevTools store to act like the app's store.
*/ */
function unliftStore(liftedStore, monitorReducer) { function mapToComputedStateStore(devToolsStore, wrapReducer) {
let lastDefinedState; let lastDefinedState;
return { return {
...liftedStore, ...devToolsStore,
devToolsStore: liftedStore,
devToolsStore,
dispatch(action) { dispatch(action) {
liftedStore.dispatch(liftAction(action)); devToolsStore.dispatch(liftAction(action));
return action; return action;
}, },
getState() { getState() {
const state = unliftState(liftedStore.getState()); const state = unliftState(devToolsStore.getState());
if (state !== undefined) { if (state !== undefined) {
lastDefinedState = state; lastDefinedState = state;
} }
return lastDefinedState; return lastDefinedState;
}, },
replaceReducer(nextReducer) { replaceReducer(nextReducer) {
liftedStore.replaceReducer(liftReducer(nextReducer, monitorReducer)); devToolsStore.replaceReducer(wrapReducer(nextReducer));
} }
}; };
} }
@ -237,18 +239,23 @@ export const ActionCreators = {
reset() { reset() {
return { type: ActionTypes.RESET, timestamp: Date.now() }; return { type: ActionTypes.RESET, timestamp: Date.now() };
}, },
rollback() { rollback() {
return { type: ActionTypes.ROLLBACK, timestamp: Date.now() }; return { type: ActionTypes.ROLLBACK, timestamp: Date.now() };
}, },
commit() { commit() {
return { type: ActionTypes.COMMIT, timestamp: Date.now() }; return { type: ActionTypes.COMMIT, timestamp: Date.now() };
}, },
sweep() { sweep() {
return { type: ActionTypes.SWEEP }; return { type: ActionTypes.SWEEP };
}, },
toggleAction(index) { toggleAction(index) {
return { type: ActionTypes.TOGGLE_ACTION, index }; return { type: ActionTypes.TOGGLE_ACTION, index };
}, },
jumpToState(index) { jumpToState(index) {
return { type: ActionTypes.JUMP_TO_STATE, index }; return { type: ActionTypes.JUMP_TO_STATE, index };
} }
@ -257,11 +264,21 @@ export const ActionCreators = {
/** /**
* Redux DevTools store enhancer. * Redux DevTools store enhancer.
*/ */
export default function devTools(monitorReducer = () => undefined) { export default function devTools(
monitorReducer = () => null,
...monitorReducerWrappers
) {
return next => (reducer, initialState) => { return next => (reducer, initialState) => {
const liftedReducer = liftReducer(reducer, monitorReducer, initialState); const finalMonitorReducer = compose(
const liftedStore = next(liftedReducer); ...monitorReducerWrappers.slice().reverse()
const store = unliftStore(liftedStore, monitorReducer); )(monitorReducer);
return store;
const wrapReducer = (r) => combineReducers({
devToolsState: createDevToolsStateReducer(r, initialState),
monitorState: finalMonitorReducer
});
const devToolsStore = next(wrapReducer(reducer));
return mapToComputedStateStore(devToolsStore, wrapReducer);
}; };
} }

View File

@ -1,3 +1,3 @@
export { default as devTools } from './devTools'; export { default, ActionCreators, ActionTypes } from './devTools';
export { default as DevToolsProvider } from './DevToolsProvider';
export { default as persistState } from './persistState'; export { default as persistState } from './persistState';
export { default as connectMonitor } from './connectMonitor';