diff --git a/.babelrc b/.babelrc
index 15d27ad9b4..166ed4e2d6 100644
--- a/.babelrc
+++ b/.babelrc
@@ -1,4 +1,5 @@
{
"stage": 0,
- "loose": "all"
+ "loose": "all",
+ "plugins": ["object-assign"]
}
diff --git a/examples/counter/containers/App.js b/examples/counter/containers/App.js
index edca2dd1f2..ae75692994 100644
--- a/examples/counter/containers/App.js
+++ b/examples/counter/containers/App.js
@@ -1,9 +1,14 @@
import React, { Component } from 'react';
import CounterApp from './CounterApp';
-import { createStore, applyMiddleware, combineReducers } from 'redux';
+import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
import { Provider } from 'react-redux';
import * as reducers from '../reducers';
+import devTools from '../redux-devtools/index';
+import persistState from '../redux-devtools/persistState';
+import DebugPanel from '../redux-devtools/DebugPanel';
+import ReduxMonitor from '../redux-devtools/ReduxMonitor';
+
// TODO: move into a separate project
function thunk({ dispatch, getState }) {
return next => action =>
@@ -12,16 +17,28 @@ function thunk({ dispatch, getState }) {
next(action);
}
-const createStoreWithMiddleware = applyMiddleware(thunk)(createStore);
+const finalCreateStore = compose(
+ applyMiddleware(),
+ devTools(),
+ persistState(window.location.href.match(/[?&]debug_session=([^&]+)\b/)),
+ createStore
+);
+
const reducer = combineReducers(reducers);
-const store = createStoreWithMiddleware(reducer);
+const store = finalCreateStore(combineReducers(reducers));
export default class App extends Component {
render() {
return (
-
- {() => }
-
+
);
}
}
diff --git a/examples/counter/redux-devtools/DebugPanel.js b/examples/counter/redux-devtools/DebugPanel.js
new file mode 100644
index 0000000000..8c0b289007
--- /dev/null
+++ b/examples/counter/redux-devtools/DebugPanel.js
@@ -0,0 +1,46 @@
+import React, { PropTypes } from 'react';
+
+export default class DebugPanel {
+ static propTypes = {
+ left: PropTypes.bool,
+ right: PropTypes.bool,
+ bottom: PropTypes.bool,
+ top: PropTypes.bool
+ };
+
+ render() {
+ if (process.env.NODE_ENV === 'production') {
+ return null;
+ }
+
+ let { left, right, bottom, top } = this.props;
+ if (typeof left === 'undefined' && typeof right === 'undefined') {
+ right = true;
+ }
+ if (typeof top === 'undefined' && typeof bottom === 'undefined') {
+ bottom = true;
+ }
+
+ return (
+
+ {this.props.children}
+
+ );
+ }
+}
diff --git a/examples/counter/redux-devtools/Entry.js b/examples/counter/redux-devtools/Entry.js
new file mode 100644
index 0000000000..6447b768f8
--- /dev/null
+++ b/examples/counter/redux-devtools/Entry.js
@@ -0,0 +1,119 @@
+import React, { PropTypes } from 'react';
+
+function hsvToRgb(h, s, v) {
+ const i = Math.floor(h);
+ const f = h - i;
+ const p = v * (1 - s);
+ const q = v * (1 - f * s);
+ const t = v * (1 - (1 - f) * s);
+ const mod = i % 6;
+ const r = [v, q, p, p, t, v][mod];
+ const g = [t, v, v, q, p, p][mod];
+ const b = [p, p, t, v, v, q][mod];
+
+ return {
+ r: Math.round(r * 255),
+ g: Math.round(g * 255),
+ b: Math.round(b * 255)
+ };
+}
+
+function colorFromString(token) {
+ token = token.split('');
+ token = token.concat(token.reverse());
+ const number = token
+ .reduce((sum, char) => sum + char.charCodeAt(0), 0) *
+ Math.abs(Math.sin(token.length));
+
+ const h = Math.round((number * (180 / Math.PI) * token.length) % 360);
+ const s = number % 100 / 100;
+ const v = 1;
+
+ return hsvToRgb(h, s, v);
+}
+
+export default class Entry {
+ static propTypes = {
+ index: PropTypes.number.isRequired,
+ state: PropTypes.object.isRequired,
+ action: PropTypes.object.isRequired,
+ select: PropTypes.func.isRequired,
+ error: PropTypes.string,
+ onActionClick: PropTypes.func.isRequired,
+ collapsed: PropTypes.bool
+ };
+
+ printState(state, error) {
+ if (!error) {
+ try {
+ return JSON.stringify(this.props.select(state));
+ } catch (err) {
+ error = 'Error selecting state.';
+ }
+ }
+
+ return (
+
+ ({error})
+
+ );
+ }
+
+ handleActionClick(e) {
+ const { index, onActionClick } = this.props;
+ if (index > 0) {
+ onActionClick(index);
+ }
+ }
+
+ render() {
+ const { index, error, action, state, collapsed, onActionClick } = this.props;
+ const { type = '' } = action;
+ const { r, g, b } = colorFromString(action.type);
+
+ return (
+
+ );
+ }
+}
diff --git a/examples/counter/redux-devtools/Monitor.js b/examples/counter/redux-devtools/Monitor.js
new file mode 100644
index 0000000000..baddec54ac
--- /dev/null
+++ b/examples/counter/redux-devtools/Monitor.js
@@ -0,0 +1,130 @@
+import React, { PropTypes, findDOMNode } from 'react';
+import Entry from './Entry';
+
+export default class Monitor {
+ static propTypes = {
+ computedStates: PropTypes.array.isRequired,
+ stagedActions: PropTypes.array.isRequired,
+ skippedActions: PropTypes.object.isRequired,
+ reset: PropTypes.func.isRequired,
+ commit: PropTypes.func.isRequired,
+ rollback: PropTypes.func.isRequired,
+ sweep: PropTypes.func.isRequired,
+ toggleAction: PropTypes.func.isRequired,
+ select: PropTypes.func.isRequired
+ };
+
+ static defaultProps = {
+ select: (state) => state
+ };
+
+ componentWillReceiveProps(nextProps) {
+ if (this.props.stagedActions.length < nextProps.stagedActions.length) {
+ const scrollableNode = findDOMNode(this).parentElement;
+ const { scrollTop, offsetHeight, scrollHeight } = scrollableNode;
+
+ this.scrollDown = Math.abs(
+ scrollHeight - (scrollTop + offsetHeight)
+ ) < 20;
+ } else {
+ this.scrollDown = false;
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (
+ prevProps.stagedActions.length < this.props.stagedActions.length &&
+ this.scrollDown
+ ) {
+ const scrollableNode = findDOMNode(this).parentElement;
+ const { scrollTop, offsetHeight, scrollHeight } = scrollableNode;
+
+ scrollableNode.scrollTop = scrollHeight - offsetHeight;
+ this.scrollDown = false;
+ }
+ }
+
+ handleRollback() {
+ this.props.rollback();
+ }
+
+ handleSweep() {
+ this.props.sweep();
+ }
+
+ handleCommit() {
+ this.props.commit();
+ }
+
+ handleToggleAction(index) {
+ this.props.toggleAction(index);
+ }
+
+ handleReset() {
+ this.props.reset();
+ }
+
+ render() {
+ const elements = [];
+ const { skippedActions, stagedActions, computedStates, select } = this.props;
+
+ for (let i = 0; i < stagedActions.length; i++) {
+ const action = stagedActions[i];
+ const { state, error } = computedStates[i];
+
+ elements.push(
+
+ );
+ }
+
+ return (
+
+
+ {elements}
+
+ {computedStates.length > 1 &&
+
+ Rollback
+
+ }
+ {Object.keys(skippedActions).some(key => skippedActions[key]) &&
+
+ {' • '}
+
+ Sweep
+
+
+ }
+ {computedStates.length > 1 &&
+
+
+ {' • '}
+
+
+ Commit
+
+
+ }
+
+
+ );
+ }
+}
diff --git a/examples/counter/redux-devtools/ReduxMonitor.js b/examples/counter/redux-devtools/ReduxMonitor.js
new file mode 100644
index 0000000000..46ede1e468
--- /dev/null
+++ b/examples/counter/redux-devtools/ReduxMonitor.js
@@ -0,0 +1,41 @@
+import React, { PropTypes } from 'react';
+import { bindActionCreators } from 'redux';
+import { Provider, Connector } from 'react-redux';
+import { ActionCreators } from './index';
+import Monitor from './Monitor';
+
+export default class ReduxMonitor {
+ static propTypes = {
+ store: PropTypes.shape({
+ devToolsStore: PropTypes.shape({
+ dispatch: PropTypes.func.isRequired
+ }).isRequired
+ }).isRequired,
+ select: PropTypes.func
+ };
+
+ render() {
+ const { devToolsStore } = this.props.store;
+ return (
+
+ {this.renderRoot}
+
+ );
+ }
+
+ renderRoot = () => {
+ return (
+
+ {this.renderMonitor}
+
+ );
+ };
+
+ renderMonitor = ({ dispatch, ...props }) => {
+ return (
+
+ );
+ };
+}
diff --git a/examples/counter/redux-devtools/index.js b/examples/counter/redux-devtools/index.js
new file mode 100644
index 0000000000..62392ab5eb
--- /dev/null
+++ b/examples/counter/redux-devtools/index.js
@@ -0,0 +1,206 @@
+const ActionTypes = {
+ PERFORM_ACTION: 'PERFORM_ACTION',
+ RESET: 'RESET',
+ ROLLBACK: 'ROLLBACK',
+ COMMIT: 'COMMIT',
+ SWEEP: 'SWEEP',
+ TOGGLE_ACTION: 'TOGGLE_ACTION'
+};
+
+const INIT_ACTION = {
+ type: '@@INIT'
+};
+
+function last(arr) {
+ return arr[arr.length - 1];
+}
+
+function toggle(obj, key) {
+ obj = { ...obj };
+ if (obj[key]) {
+ delete obj[key];
+ } else {
+ obj[key] = true;
+ }
+ return obj;
+}
+
+/**
+ * Computes the next entry in the log by applying an action.
+ */
+function computeNextEntry(reducer, action, state, error) {
+ if (error) {
+ return {
+ state,
+ error: 'Interrupted by an error up the chain'
+ };
+ }
+
+ try {
+ state = reducer(state, action);
+ } catch (err) {
+ error = err.toString();
+ }
+
+ return { 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 = [];
+
+ for (let i = 0; i < stagedActions.length; i++) {
+ const action = stagedActions[i];
+
+ const previousEntry = computedStates[i - 1];
+ const previousState = previousEntry ? previousEntry.state : committedState;
+ const previousError = previousEntry ? previousEntry.error : undefined;
+
+ const shouldSkip = Boolean(skippedActions[i]);
+ const entry = shouldSkip ?
+ previousEntry :
+ computeNextEntry(reducer, action, previousState, previousError);
+
+ computedStates.push(entry);
+ }
+
+ return computedStates;
+}
+
+
+/**
+ * Lifts the app state reducer into a DevTools state reducer.
+ */
+function liftReducer(reducer, initialState) {
+ const initialLiftedState = {
+ committedState: initialState,
+ stagedActions: [INIT_ACTION],
+ skippedActions: {}
+ };
+
+ /**
+ * Manages how the DevTools actions modify the DevTools state.
+ */
+ return function liftedReducer(liftedState = initialLiftedState, liftedAction) {
+ let {
+ committedState,
+ stagedActions,
+ skippedActions,
+ computedStates
+ } = liftedState;
+
+ switch (liftedAction.type) {
+ case ActionTypes.RESET:
+ committedState = initialState;
+ stagedActions = [INIT_ACTION];
+ skippedActions = {};
+ break;
+ case ActionTypes.COMMIT:
+ committedState = last(computedStates).state;
+ stagedActions = [INIT_ACTION];
+ skippedActions = {};
+ break;
+ case ActionTypes.ROLLBACK:
+ stagedActions = [INIT_ACTION];
+ skippedActions = {};
+ break;
+ case ActionTypes.TOGGLE_ACTION:
+ const { index } = liftedAction;
+ skippedActions = toggle(skippedActions, index);
+ break;
+ case ActionTypes.SWEEP:
+ stagedActions = stagedActions.filter((_, i) => !skippedActions[i]);
+ skippedActions = {};
+ break;
+ case ActionTypes.PERFORM_ACTION:
+ const { action } = liftedAction;
+ stagedActions = [...stagedActions, action];
+ break;
+ }
+
+ computedStates = recomputeStates(
+ reducer,
+ committedState,
+ stagedActions,
+ skippedActions
+ );
+
+ return {
+ committedState,
+ stagedActions,
+ skippedActions,
+ computedStates
+ };
+ };
+}
+
+/**
+ * Lifts an app action to a DevTools action.
+ */
+function liftAction(action) {
+ const liftedAction = { type: ActionTypes.PERFORM_ACTION, action };
+ return liftedAction;
+}
+
+/**
+ * Unlifts the DevTools state to the app state.
+ */
+function unliftState(liftedState) {
+ const { computedStates } = liftedState;
+ const { state } = last(computedStates);
+ return state;
+}
+
+/**
+ * Unlifts the DevTools store to act like the app's store.
+ */
+function unliftStore(liftedStore) {
+ return {
+ ...liftedStore,
+ devToolsStore: liftedStore,
+ dispatch(action) {
+ liftedStore.dispatch(liftAction(action));
+ return action;
+ },
+ getState() {
+ return unliftState(liftedStore.getState());
+ }
+ };
+}
+
+/**
+ * Action creators to change the DevTools state.
+ */
+export const ActionCreators = {
+ reset() {
+ return { type: ActionTypes.RESET };
+ },
+ rollback() {
+ return { type: ActionTypes.ROLLBACK };
+ },
+ commit() {
+ return { type: ActionTypes.COMMIT };
+ },
+ sweep() {
+ return { type: ActionTypes.SWEEP };
+ },
+ toggleAction(index) {
+ return { type: ActionTypes.TOGGLE_ACTION, index };
+ }
+};
+
+/**
+ * Redux DevTools middleware.
+ */
+export default function devTools() {
+ return next => (reducer, initialState) => {
+ const liftedReducer = liftReducer(reducer, initialState);
+ const liftedStore = next(liftedReducer);
+ const store = unliftStore(liftedStore);
+ return store;
+ };
+}
diff --git a/examples/counter/redux-devtools/persistState.js b/examples/counter/redux-devtools/persistState.js
new file mode 100644
index 0000000000..5a438b319f
--- /dev/null
+++ b/examples/counter/redux-devtools/persistState.js
@@ -0,0 +1,30 @@
+export default function persistState(sessionId) {
+ if (!sessionId) {
+ return next => (...args) => next(...args);
+ }
+
+ return next => (reducer, initialState) => {
+ const key = `redux-dev-session-${sessionId}`;
+
+ try {
+ initialState = JSON.parse(localStorage.getItem(key)) || initialState;
+ next(reducer, initialState);
+ } catch (e) {
+ try {
+ localStorage.removeItem(key);
+ } finally {
+ initialState = undefined;
+ }
+ }
+
+ const store = next(reducer, initialState);
+
+ return Object.assign(Object.create(store), {
+ dispatch(action) {
+ store.dispatch(action);
+ localStorage.setItem(key, JSON.stringify(store.getState()));
+ return action;
+ }
+ });
+ };
+}
diff --git a/package.json b/package.json
index 86f808b435..751d38edbd 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"babel-core": "^5.6.18",
"babel-eslint": "^3.1.15",
"babel-loader": "^5.1.4",
+ "babel-plugin-object-assign": "^1.2.0",
"eslint": "^0.23",
"eslint-config-airbnb": "0.0.6",
"eslint-plugin-react": "^2.3.0",