From 6279ce8350b80d7976f36091d9e6ae69cbbf5b34 Mon Sep 17 00:00:00 2001 From: Zalmoxisus Date: Sun, 2 Dec 2018 23:49:12 +0200 Subject: [PATCH] Merge `redux-devtools-instrument` to `packages` --- .gitignore | 1 + packages/redux-devtools-instrument/.babelrc | 3 + .../redux-devtools-instrument/.eslintignore | 4 + packages/redux-devtools-instrument/.eslintrc | 20 + packages/redux-devtools-instrument/LICENSE.md | 21 + packages/redux-devtools-instrument/README.md | 57 ++ .../redux-devtools-instrument/package.json | 59 ++ .../src/instrument.js | 685 +++++++++++++ .../test/instrument.spec.js | 953 ++++++++++++++++++ 9 files changed, 1803 insertions(+) create mode 100644 packages/redux-devtools-instrument/.babelrc create mode 100644 packages/redux-devtools-instrument/.eslintignore create mode 100644 packages/redux-devtools-instrument/.eslintrc create mode 100644 packages/redux-devtools-instrument/LICENSE.md create mode 100644 packages/redux-devtools-instrument/README.md create mode 100644 packages/redux-devtools-instrument/package.json create mode 100644 packages/redux-devtools-instrument/src/instrument.js create mode 100644 packages/redux-devtools-instrument/test/instrument.spec.js diff --git a/.gitignore b/.gitignore index c0f0a024..b7a60ee8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules .DS_Store lib coverage +.idea diff --git a/packages/redux-devtools-instrument/.babelrc b/packages/redux-devtools-instrument/.babelrc new file mode 100644 index 00000000..9d06ebbb --- /dev/null +++ b/packages/redux-devtools-instrument/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015-loose", "stage-0"] +} diff --git a/packages/redux-devtools-instrument/.eslintignore b/packages/redux-devtools-instrument/.eslintignore new file mode 100644 index 00000000..0d38857e --- /dev/null +++ b/packages/redux-devtools-instrument/.eslintignore @@ -0,0 +1,4 @@ +lib +**/node_modules +**/webpack.config.js +examples/**/server.js \ No newline at end of file diff --git a/packages/redux-devtools-instrument/.eslintrc b/packages/redux-devtools-instrument/.eslintrc new file mode 100644 index 00000000..47dc0576 --- /dev/null +++ b/packages/redux-devtools-instrument/.eslintrc @@ -0,0 +1,20 @@ +{ + "extends": "eslint-config-airbnb", + "env": { + "browser": true, + "mocha": true, + "node": true + }, + "rules": { + "react/jsx-uses-react": 2, + "react/jsx-uses-vars": 2, + "react/react-in-jsx-scope": 2, + "no-console": 0, + // Temporarily disabled due to babel-eslint issues: + "block-scoped-var": 0, + "padded-blocks": 0, + }, + "plugins": [ + "react" + ] +} diff --git a/packages/redux-devtools-instrument/LICENSE.md b/packages/redux-devtools-instrument/LICENSE.md new file mode 100644 index 00000000..af2353dc --- /dev/null +++ b/packages/redux-devtools-instrument/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Dan Abramov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/redux-devtools-instrument/README.md b/packages/redux-devtools-instrument/README.md new file mode 100644 index 00000000..724bd863 --- /dev/null +++ b/packages/redux-devtools-instrument/README.md @@ -0,0 +1,57 @@ +Redux DevTools Instrumentation +============================== + +Redux enhancer used along with [Redux DevTools](https://github.com/gaearon/redux-devtools) or [Remote Redux DevTools](https://github.com/zalmoxisus/remote-redux-devtools). + +### Installation + +``` +npm install --save-dev redux-devtools-instrument +``` + +### Usage + +Add the store enhancer: + +##### `store/configureStore.js` + +```js +import { createStore, applyMiddleware, compose } from 'redux'; +import thunk from 'redux-thunk'; +import devTools from 'remote-redux-devtools'; +import reducer from '../reducers'; + +// Usually you import the reducer from the monitor +// or apply with createDevTools as explained in Redux DevTools +const monitorReducer = (state = {}, action) => state; + +export default function configureStore(initialState) { + const enhancer = compose( + applyMiddleware(...middlewares), + // other enhancers and applyMiddleware should be added before the instrumentation + instrument(monitorReducer, { maxAge: 50 }) + ); + + // Note: passing enhancer as last argument requires redux@>=3.1.0 + return createStore(reducer, initialState, enhancer); +} +``` + +### API + +`instrument(monitorReducer, [options])` + +- arguments + - **monitorReducer** *function* called whenever an action is dispatched ([see the example of a monitor reducer](https://github.com/gaearon/redux-devtools-log-monitor/blob/master/src/reducers.js#L13)). + - **options** *object* + - **maxAge** *number* or *function*(currentLiftedAction, previousLiftedState) - maximum allowed actions to be stored on the history tree, the oldest actions are removed once `maxAge` is reached. Can be generated dynamically with a function getting current action as argument. + - **shouldCatchErrors** *boolean* - if specified as `true`, whenever there's an exception in reducers, the monitors will show the error message, and next actions will not be dispatched. + - **shouldRecordChanges** *boolean* - if specified as `false`, it will not record the changes till `pauseRecording(false)` is dispatched. Default is `true`. + - **pauseActionType** *string* - if specified, whenever `pauseRecording(false)` lifted action is dispatched and there are actions in the history log, will add this action type. If not specified, will commit when paused. + - **shouldStartLocked** *boolean* - if specified as `true`, it will not allow any non-monitor actions to be dispatched till `lockChanges(false)` is dispatched. Default is `false`. + - **shouldHotReload** *boolean* - if set to `false`, will not recompute the states on hot reloading (or on replacing the reducers). Default to `true`. + - **shouldIncludeCallstack** *boolean* - if set to `true`, will include callstack for every dispatched action. Default to `false`. + +### License + +MIT diff --git a/packages/redux-devtools-instrument/package.json b/packages/redux-devtools-instrument/package.json new file mode 100644 index 00000000..c2c4b661 --- /dev/null +++ b/packages/redux-devtools-instrument/package.json @@ -0,0 +1,59 @@ +{ + "name": "redux-devtools-instrument", + "version": "1.9.3", + "description": "Redux DevTools instrumentation", + "main": "lib/instrument.js", + "scripts": { + "clean": "rimraf lib", + "build": "babel src --out-dir lib", + "lint": "eslint src test", + "test": "NODE_ENV=test mocha --compilers js:babel-core/register --recursive", + "test:watch": "NODE_ENV=test mocha --compilers js:babel-core/register --recursive --watch", + "test:cov": "babel-node ./node_modules/.bin/isparta cover ./node_modules/.bin/_mocha -- --recursive", + "prepublish": "npm run lint && npm run test && npm run clean && npm run build" + }, + "files": [ + "lib", + "src" + ], + "repository": { + "type": "git", + "url": "https://github.com/zalmoxisus/redux-devtools-instrument.git" + }, + "keywords": [ + "redux", + "devtools", + "flux", + "hot reloading", + "time travel", + "live edit" + ], + "author": "Dan Abramov (http://github.com/gaearon)", + "license": "MIT", + "bugs": { + "url": "https://github.com/zalmoxisus/redux-devtools-instrument/issues" + }, + "homepage": "https://github.com/zalmoxisus/redux-devtools-instrument", + "devDependencies": { + "babel-cli": "^6.3.17", + "babel-core": "^6.3.17", + "babel-eslint": "^4.1.6", + "babel-loader": "^6.2.0", + "babel-preset-es2015-loose": "^6.1.3", + "babel-preset-stage-0": "^6.3.13", + "eslint": "^0.23", + "eslint-config-airbnb": "0.0.6", + "eslint-plugin-react": "^2.3.0", + "expect": "^1.6.0", + "isparta": "^3.0.3", + "mocha": "^2.2.5", + "redux": "^4.0.0", + "rimraf": "^2.3.4", + "rxjs": "^5.0.0-beta.6", + "webpack": "^1.11.0" + }, + "dependencies": { + "lodash": "^4.2.0", + "symbol-observable": "^1.0.2" + } +} diff --git a/packages/redux-devtools-instrument/src/instrument.js b/packages/redux-devtools-instrument/src/instrument.js new file mode 100644 index 00000000..a6c08cdf --- /dev/null +++ b/packages/redux-devtools-instrument/src/instrument.js @@ -0,0 +1,685 @@ +import difference from 'lodash/difference'; +import union from 'lodash/union'; +import isPlainObject from 'lodash/isPlainObject'; +import $$observable from 'symbol-observable'; + +export const ActionTypes = { + PERFORM_ACTION: 'PERFORM_ACTION', + RESET: 'RESET', + ROLLBACK: 'ROLLBACK', + COMMIT: 'COMMIT', + SWEEP: 'SWEEP', + TOGGLE_ACTION: 'TOGGLE_ACTION', + SET_ACTIONS_ACTIVE: 'SET_ACTIONS_ACTIVE', + JUMP_TO_STATE: 'JUMP_TO_STATE', + JUMP_TO_ACTION: 'JUMP_TO_ACTION', + REORDER_ACTION: 'REORDER_ACTION', + IMPORT_STATE: 'IMPORT_STATE', + LOCK_CHANGES: 'LOCK_CHANGES', + PAUSE_RECORDING: 'PAUSE_RECORDING' +}; + +/** + * Action creators to change the History state. + */ +export const ActionCreators = { + performAction(action, shouldIncludeCallstack) { + 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?' + ); + } + + return { + type: ActionTypes.PERFORM_ACTION, action, timestamp: Date.now(), + stack: shouldIncludeCallstack ? Error().stack : undefined + }; + }, + + 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(id) { + return { type: ActionTypes.TOGGLE_ACTION, id }; + }, + + setActionsActive(start, end, active=true) { + return { type: ActionTypes.SET_ACTIONS_ACTIVE, start, end, active }; + }, + + reorderAction(actionId, beforeActionId) { + return { type: ActionTypes.REORDER_ACTION, actionId, beforeActionId }; + }, + + jumpToState(index) { + return { type: ActionTypes.JUMP_TO_STATE, index }; + }, + + jumpToAction(actionId) { + return { type: ActionTypes.JUMP_TO_ACTION, actionId }; + }, + + importState(nextLiftedState, noRecompute) { + return { type: ActionTypes.IMPORT_STATE, nextLiftedState, noRecompute }; + }, + + lockChanges(status) { + return { type: ActionTypes.LOCK_CHANGES, status }; + }, + + pauseRecording(status) { + return { type: ActionTypes.PAUSE_RECORDING, status }; + } +}; + +export const INIT_ACTION = { type: '@@INIT' }; + +/** + * Computes the next entry with exceptions catching. + */ +function computeWithTryCatch(reducer, action, state) { + let nextState = state; + let nextError; + try { + nextState = reducer(state, action); + } catch (err) { + nextError = err.toString(); + if ( + typeof window === 'object' && ( + typeof window.chrome !== 'undefined' || + typeof window.process !== 'undefined' && + window.process.type === 'renderer' + )) { + // In Chrome, rethrowing provides better source map support + setTimeout(() => { throw err; }); + } else { + console.error(err); + } + } + + return { + state: nextState, + error: nextError + }; +} + +/** + * Computes the next entry in the log by applying an action. + */ +function computeNextEntry(reducer, action, state, shouldCatchErrors) { + if (!shouldCatchErrors) { + return { state: reducer(state, action) }; + } + return computeWithTryCatch(reducer, action, state); +} + +/** + * Runs the reducer on invalidated actions to get a fresh computation log. + */ +function recomputeStates( + computedStates, + minInvalidatedStateIndex, + reducer, + committedState, + actionsById, + stagedActionIds, + skippedActionIds, + shouldCatchErrors +) { + // Optimization: exit early and return the same reference + // if we know nothing could have changed. + if ( + !computedStates || minInvalidatedStateIndex === -1 || + (minInvalidatedStateIndex >= computedStates.length && + computedStates.length === stagedActionIds.length) + ) { + return computedStates; + } + + const nextComputedStates = computedStates.slice(0, minInvalidatedStateIndex); + for (let i = minInvalidatedStateIndex; i < stagedActionIds.length; i++) { + const actionId = stagedActionIds[i]; + const action = actionsById[actionId].action; + + const previousEntry = nextComputedStates[i - 1]; + const previousState = previousEntry ? previousEntry.state : committedState; + + const shouldSkip = skippedActionIds.indexOf(actionId) > -1; + let entry; + if (shouldSkip) { + entry = previousEntry; + } else { + if (shouldCatchErrors && previousEntry && previousEntry.error) { + entry = { + state: previousState, + error: 'Interrupted by an error up the chain' + }; + } else { + entry = computeNextEntry(reducer, action, previousState, shouldCatchErrors); + } + } + nextComputedStates.push(entry); + } + + return nextComputedStates; +} + +/** + * Lifts an app's action into an action on the lifted store. + */ +export function liftAction(action, shouldIncludeCallstack) { + return ActionCreators.performAction(action, shouldIncludeCallstack); +} + +/** + * Creates a history state reducer from an app's reducer. + */ +export function liftReducerWith(reducer, initialCommittedState, monitorReducer, options) { + const initialLiftedState = { + monitorState: monitorReducer(undefined, {}), + nextActionId: 1, + actionsById: { 0: liftAction(INIT_ACTION) }, + stagedActionIds: [0], + skippedActionIds: [], + committedState: initialCommittedState, + currentStateIndex: 0, + computedStates: [], + isLocked: options.shouldStartLocked === true, + isPaused: options.shouldRecordChanges === false + }; + + /** + * Manages how the history actions modify the history state. + */ + return (liftedState, liftedAction) => { + let { + monitorState, + actionsById, + nextActionId, + stagedActionIds, + skippedActionIds, + committedState, + currentStateIndex, + computedStates, + isLocked, + isPaused + } = liftedState || initialLiftedState; + + if (!liftedState) { + // Prevent mutating initialLiftedState + actionsById = { ...actionsById }; + } + + function commitExcessActions(n) { + // Auto-commits n-number of excess actions. + let excess = n; + let idsToDelete = stagedActionIds.slice(1, excess + 1); + + for (let i = 0; i < idsToDelete.length; i++) { + if (computedStates[i + 1].error) { + // Stop if error is found. Commit actions up to error. + excess = i; + idsToDelete = stagedActionIds.slice(1, excess + 1); + break; + } else { + delete actionsById[idsToDelete[i]]; + } + } + + skippedActionIds = skippedActionIds.filter(id => idsToDelete.indexOf(id) === -1); + stagedActionIds = [0, ...stagedActionIds.slice(excess + 1)]; + committedState = computedStates[excess].state; + computedStates = computedStates.slice(excess); + currentStateIndex = currentStateIndex > excess + ? currentStateIndex - excess + : 0; + } + + function computePausedAction(shouldInit) { + let computedState; + if (shouldInit) { + computedState = computedStates[currentStateIndex]; + monitorState = monitorReducer(monitorState, liftedAction); + } else { + computedState = computeNextEntry( + reducer, liftedAction.action, computedStates[currentStateIndex].state, false + ); + } + if (!options.pauseActionType || nextActionId === 1) { + return { + monitorState, + actionsById: { 0: liftAction(INIT_ACTION) }, + nextActionId: 1, + stagedActionIds: [0], + skippedActionIds: [], + committedState: computedState.state, + currentStateIndex: 0, + computedStates: [computedState], + isLocked, + isPaused: true + }; + } + if (shouldInit) { + if (currentStateIndex === stagedActionIds.length - 1) { + currentStateIndex++; + } + stagedActionIds = [...stagedActionIds, nextActionId]; + nextActionId++; + } + return { + monitorState, + actionsById: { + ...actionsById, + [nextActionId - 1]: liftAction({ type: options.pauseActionType }) + }, + nextActionId, + stagedActionIds, + skippedActionIds, + committedState, + currentStateIndex, + computedStates: [...computedStates.slice(0, stagedActionIds.length - 1), computedState], + isLocked, + isPaused: true + }; + } + + // By default, agressively recompute every state whatever happens. + // This has O(n) performance, so we'll override this to a sensible + // value whenever we feel like we don't have to recompute the states. + let minInvalidatedStateIndex = 0; + + // maxAge number can be changed dynamically + let maxAge = options.maxAge; + if (typeof maxAge === 'function') maxAge = maxAge(liftedAction, liftedState); + + if (/^@@redux\/(INIT|REPLACE)/.test(liftedAction.type)) { + if (options.shouldHotReload === false) { + actionsById = { 0: liftAction(INIT_ACTION) }; + nextActionId = 1; + stagedActionIds = [0]; + skippedActionIds = []; + committedState = computedStates.length === 0 ? initialCommittedState : + computedStates[currentStateIndex].state; + currentStateIndex = 0; + computedStates = []; + } + + // Recompute states on hot reload and init. + minInvalidatedStateIndex = 0; + + if (maxAge && stagedActionIds.length > maxAge) { + // States must be recomputed before committing excess. + computedStates = recomputeStates( + computedStates, + minInvalidatedStateIndex, + reducer, + committedState, + actionsById, + stagedActionIds, + skippedActionIds, + options.shouldCatchErrors + ); + + commitExcessActions(stagedActionIds.length - maxAge); + + // Avoid double computation. + minInvalidatedStateIndex = Infinity; + } + } else { + switch (liftedAction.type) { + case ActionTypes.PERFORM_ACTION: { + if (isLocked) return liftedState || initialLiftedState; + if (isPaused) return computePausedAction(); + + // Auto-commit as new actions come in. + if (maxAge && stagedActionIds.length >= maxAge) { + commitExcessActions(stagedActionIds.length - maxAge + 1); + } + + if (currentStateIndex === stagedActionIds.length - 1) { + currentStateIndex++; + } + const actionId = nextActionId++; + // Mutation! This is the hottest path, and we optimize on purpose. + // It is safe because we set a new key in a cache dictionary. + actionsById[actionId] = liftedAction; + stagedActionIds = [...stagedActionIds, actionId]; + // Optimization: we know that only the new action needs computing. + minInvalidatedStateIndex = stagedActionIds.length - 1; + break; + } + case ActionTypes.RESET: { + // Get back to the state the store was created with. + actionsById = { 0: liftAction(INIT_ACTION) }; + nextActionId = 1; + stagedActionIds = [0]; + skippedActionIds = []; + committedState = initialCommittedState; + currentStateIndex = 0; + computedStates = []; + break; + } + case ActionTypes.COMMIT: { + // Consider the last committed state the new starting point. + // Squash any staged actions into a single committed state. + actionsById = { 0: liftAction(INIT_ACTION) }; + nextActionId = 1; + stagedActionIds = [0]; + skippedActionIds = []; + committedState = computedStates[currentStateIndex].state; + currentStateIndex = 0; + computedStates = []; + break; + } + case ActionTypes.ROLLBACK: { + // Forget about any staged actions. + // Start again from the last committed state. + actionsById = { 0: liftAction(INIT_ACTION) }; + nextActionId = 1; + stagedActionIds = [0]; + skippedActionIds = []; + currentStateIndex = 0; + computedStates = []; + break; + } + case ActionTypes.TOGGLE_ACTION: { + // Toggle whether an action with given ID is skipped. + // Being skipped means it is a no-op during the computation. + const { id: actionId } = liftedAction; + const index = skippedActionIds.indexOf(actionId); + if (index === -1) { + skippedActionIds = [actionId, ...skippedActionIds]; + } else { + skippedActionIds = skippedActionIds.filter(id => id !== actionId); + } + // Optimization: we know history before this action hasn't changed + minInvalidatedStateIndex = stagedActionIds.indexOf(actionId); + break; + } + case ActionTypes.SET_ACTIONS_ACTIVE: { + // Toggle whether an action with given ID is skipped. + // Being skipped means it is a no-op during the computation. + const { start, end, active } = liftedAction; + const actionIds = []; + for (let i = start; i < end; i++) actionIds.push(i); + if (active) { + skippedActionIds = difference(skippedActionIds, actionIds); + } else { + skippedActionIds = union(skippedActionIds, actionIds); + } + + // Optimization: we know history before this action hasn't changed + minInvalidatedStateIndex = stagedActionIds.indexOf(start); + break; + } + case ActionTypes.JUMP_TO_STATE: { + // Without recomputing anything, move the pointer that tell us + // which state is considered the current one. Useful for sliders. + currentStateIndex = liftedAction.index; + // Optimization: we know the history has not changed. + minInvalidatedStateIndex = Infinity; + break; + } + case ActionTypes.JUMP_TO_ACTION: { + // Jumps to a corresponding state to a specific action. + // Useful when filtering actions. + const index = stagedActionIds.indexOf(liftedAction.actionId); + if (index !== -1) currentStateIndex = index; + minInvalidatedStateIndex = Infinity; + break; + } + case ActionTypes.SWEEP: { + // Forget any actions that are currently being skipped. + stagedActionIds = difference(stagedActionIds, skippedActionIds); + skippedActionIds = []; + currentStateIndex = Math.min(currentStateIndex, stagedActionIds.length - 1); + break; + } + case ActionTypes.REORDER_ACTION: { + // Recompute actions in a new order. + const actionId = liftedAction.actionId; + const idx = stagedActionIds.indexOf(actionId); + // do nothing in case the action is already removed or trying to move the first action + if (idx < 1) break; + const beforeActionId = liftedAction.beforeActionId; + let newIdx = stagedActionIds.indexOf(beforeActionId); + if (newIdx < 1) { // move to the beginning or to the end + const count = stagedActionIds.length; + newIdx = beforeActionId > stagedActionIds[count - 1] ? count : 1; + } + const diff = idx - newIdx; + + if (diff > 0) { // move left + stagedActionIds = [ + ...stagedActionIds.slice(0, newIdx), + actionId, + ...stagedActionIds.slice(newIdx, idx), + ...stagedActionIds.slice(idx + 1) + ]; + minInvalidatedStateIndex = newIdx; + } else if (diff < 0) { // move right + stagedActionIds = [ + ...stagedActionIds.slice(0, idx), + ...stagedActionIds.slice(idx + 1, newIdx), + actionId, + ...stagedActionIds.slice(newIdx) + ]; + minInvalidatedStateIndex = idx; + } + break; + } + case ActionTypes.IMPORT_STATE: { + if (Array.isArray(liftedAction.nextLiftedState)) { + // recompute array of actions + actionsById = { 0: liftAction(INIT_ACTION) }; + nextActionId = 1; + stagedActionIds = [0]; + skippedActionIds = []; + currentStateIndex = liftedAction.nextLiftedState.length; + computedStates = []; + committedState = liftedAction.preloadedState; + minInvalidatedStateIndex = 0; + // iterate through actions + liftedAction.nextLiftedState.forEach(action => { + actionsById[nextActionId] = liftAction(action, options.shouldIncludeCallstack); + stagedActionIds.push(nextActionId); + nextActionId++; + }); + } else { + // Completely replace everything. + ({ + monitorState, + actionsById, + nextActionId, + stagedActionIds, + skippedActionIds, + committedState, + currentStateIndex, + computedStates + } = liftedAction.nextLiftedState); + + if (liftedAction.noRecompute) { + minInvalidatedStateIndex = Infinity; + } + } + + break; + } + case ActionTypes.LOCK_CHANGES: { + isLocked = liftedAction.status; + minInvalidatedStateIndex = Infinity; + break; + } + case ActionTypes.PAUSE_RECORDING: { + isPaused = liftedAction.status; + if (isPaused) { + return computePausedAction(true); + } + // Commit when unpausing + actionsById = { 0: liftAction(INIT_ACTION) }; + nextActionId = 1; + stagedActionIds = [0]; + skippedActionIds = []; + committedState = computedStates[currentStateIndex].state; + currentStateIndex = 0; + computedStates = []; + break; + } + default: { + // If the action is not recognized, it's a monitor action. + // Optimization: a monitor action can't change history. + minInvalidatedStateIndex = Infinity; + break; + } + } + } + + computedStates = recomputeStates( + computedStates, + minInvalidatedStateIndex, + reducer, + committedState, + actionsById, + stagedActionIds, + skippedActionIds, + options.shouldCatchErrors + ); + monitorState = monitorReducer(monitorState, liftedAction); + return { + monitorState, + actionsById, + nextActionId, + stagedActionIds, + skippedActionIds, + committedState, + currentStateIndex, + computedStates, + isLocked, + isPaused + }; + }; +} + +/** + * Provides an app's view into the state of the lifted store. + */ +export function unliftState(liftedState) { + const { computedStates, currentStateIndex } = liftedState; + const { state } = computedStates[currentStateIndex]; + return state; +} + +/** + * Provides an app's view into the lifted store. + */ +export function unliftStore(liftedStore, liftReducer, options) { + let lastDefinedState; + const { shouldIncludeCallstack } = options; + + function getState() { + const state = unliftState(liftedStore.getState()); + if (state !== undefined) { + lastDefinedState = state; + } + return lastDefinedState; + } + + return { + ...liftedStore, + + liftedStore, + + dispatch(action) { + liftedStore.dispatch(liftAction(action, shouldIncludeCallstack)); + return action; + }, + + getState, + + replaceReducer(nextReducer) { + liftedStore.replaceReducer(liftReducer(nextReducer)); + }, + + [$$observable]() { + return { + ...liftedStore[$$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 = liftedStore.subscribe(observeState); + return { unsubscribe }; + } + }; + } + }; +} + +/** + * Redux instrumentation store enhancer. + */ +export default function instrument(monitorReducer = () => null, options = {}) { + if (typeof options.maxAge === 'number' && options.maxAge < 2) { + throw new Error( + 'DevTools.instrument({ maxAge }) option, if specified, ' + + 'may not be less than 2.' + ); + } + + return createStore => (reducer, initialState, enhancer) => { + + function liftReducer(r) { + if (typeof r !== 'function') { + if (r && typeof r.default === 'function') { + throw new Error( + 'Expected the reducer to be a function. ' + + 'Instead got an object with a "default" field. ' + + 'Did you pass a module instead of the default export? ' + + 'Try passing require(...).default instead.' + ); + } + throw new Error('Expected the reducer to be a function.'); + } + return liftReducerWith(r, initialState, monitorReducer, options); + } + + const liftedStore = createStore(liftReducer(reducer), enhancer); + if (liftedStore.liftedStore) { + throw new Error( + 'DevTools instrumentation should not be applied more than once. ' + + 'Check your store configuration.' + ); + } + + return unliftStore(liftedStore, liftReducer, options); + }; +} diff --git a/packages/redux-devtools-instrument/test/instrument.spec.js b/packages/redux-devtools-instrument/test/instrument.spec.js new file mode 100644 index 00000000..589e0f5b --- /dev/null +++ b/packages/redux-devtools-instrument/test/instrument.spec.js @@ -0,0 +1,953 @@ +import expect, { spyOn } from 'expect'; +import { createStore, compose } from 'redux'; +import instrument, { ActionCreators } from '../src/instrument'; +import { Observable } from 'rxjs'; +import _ from 'lodash'; + +import 'rxjs/add/observable/from'; + +function counter(state = 0, action) { + switch (action.type) { + case 'INCREMENT': return state + 1; + case 'DECREMENT': return state - 1; + default: return state; + } +} + +function counterWithBug(state = 0, action) { + switch (action.type) { + case 'INCREMENT': return state + 1; + case 'DECREMENT': return mistake - 1; // eslint-disable-line no-undef + case 'SET_UNDEFINED': return undefined; + default: return state; + } +} + +function counterWithAnotherBug(state = 0, action) { + switch (action.type) { + case 'INCREMENT': return mistake + 1; // eslint-disable-line no-undef + case 'DECREMENT': return state - 1; + case 'SET_UNDEFINED': return undefined; + default: return state; + } +} + +function doubleCounter(state = 0, action) { + switch (action.type) { + case 'INCREMENT': return state + 2; + case 'DECREMENT': return state - 2; + default: return state; + } +} + +function counterWithMultiply(state = 0, action) { + switch (action.type) { + case 'INCREMENT': return state + 1; + case 'DECREMENT': return state - 1; + case 'MULTIPLY': return state * 2; + default: return state; + } +} + +describe('instrument', () => { + let store; + let liftedStore; + + beforeEach(() => { + store = createStore(counter, instrument()); + liftedStore = store.liftedStore; + }); + + it('should perform actions', () => { + expect(store.getState()).toBe(0); + store.dispatch({ type: 'INCREMENT' }); + expect(store.getState()).toBe(1); + store.dispatch({ type: 'INCREMENT' }); + expect(store.getState()).toBe(2); + }); + + it('should provide observable', () => { + let lastValue; + let calls = 0; + + Observable.from(store) + .subscribe(state => { + lastValue = state; + calls++; + }); + + expect(lastValue).toBe(0); + store.dispatch({ type: 'INCREMENT' }); + expect(lastValue).toBe(1); + }); + + it('should rollback state to the last committed state', () => { + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'INCREMENT' }); + expect(store.getState()).toBe(2); + + liftedStore.dispatch(ActionCreators.commit()); + expect(store.getState()).toBe(2); + + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'INCREMENT' }); + expect(store.getState()).toBe(4); + + liftedStore.dispatch(ActionCreators.rollback()); + expect(store.getState()).toBe(2); + + store.dispatch({ type: 'DECREMENT' }); + expect(store.getState()).toBe(1); + + liftedStore.dispatch(ActionCreators.rollback()); + expect(store.getState()).toBe(2); + }); + + it('should reset to initial state', () => { + store.dispatch({ type: 'INCREMENT' }); + expect(store.getState()).toBe(1); + + liftedStore.dispatch(ActionCreators.commit()); + expect(store.getState()).toBe(1); + + store.dispatch({ type: 'INCREMENT' }); + expect(store.getState()).toBe(2); + + liftedStore.dispatch(ActionCreators.rollback()); + expect(store.getState()).toBe(1); + + store.dispatch({ type: 'INCREMENT' }); + expect(store.getState()).toBe(2); + + liftedStore.dispatch(ActionCreators.reset()); + expect(store.getState()).toBe(0); + }); + + it('should toggle an action', () => { + // actionId 0 = @@INIT + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'DECREMENT' }); + store.dispatch({ type: 'INCREMENT' }); + expect(store.getState()).toBe(1); + + liftedStore.dispatch(ActionCreators.toggleAction(2)); + expect(store.getState()).toBe(2); + + liftedStore.dispatch(ActionCreators.toggleAction(2)); + expect(store.getState()).toBe(1); + }); + + it('should set multiple action skip', () => { + // actionId 0 = @@INIT + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'INCREMENT' }); + expect(store.getState()).toBe(3); + + liftedStore.dispatch(ActionCreators.setActionsActive(1, 3, false)); + expect(store.getState()).toBe(1); + + liftedStore.dispatch(ActionCreators.setActionsActive(0, 2, true)); + expect(store.getState()).toBe(2); + + liftedStore.dispatch(ActionCreators.setActionsActive(0, 1, true)); + expect(store.getState()).toBe(2); + }); + + it('should sweep disabled actions', () => { + // actionId 0 = @@INIT + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'DECREMENT' }); + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'INCREMENT' }); + + expect(store.getState()).toBe(2); + expect(liftedStore.getState().stagedActionIds).toEqual([0, 1, 2, 3, 4]); + expect(liftedStore.getState().skippedActionIds).toEqual([]); + + liftedStore.dispatch(ActionCreators.toggleAction(2)); + expect(store.getState()).toBe(3); + expect(liftedStore.getState().stagedActionIds).toEqual([0, 1, 2, 3, 4]); + expect(liftedStore.getState().skippedActionIds).toEqual([2]); + + liftedStore.dispatch(ActionCreators.sweep()); + expect(store.getState()).toBe(3); + expect(liftedStore.getState().stagedActionIds).toEqual([0, 1, 3, 4]); + expect(liftedStore.getState().skippedActionIds).toEqual([]); + }); + + it('should jump to state', () => { + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'DECREMENT' }); + store.dispatch({ type: 'INCREMENT' }); + expect(store.getState()).toBe(1); + + liftedStore.dispatch(ActionCreators.jumpToState(0)); + expect(store.getState()).toBe(0); + + liftedStore.dispatch(ActionCreators.jumpToState(1)); + expect(store.getState()).toBe(1); + + liftedStore.dispatch(ActionCreators.jumpToState(2)); + expect(store.getState()).toBe(0); + + store.dispatch({ type: 'INCREMENT' }); + expect(store.getState()).toBe(0); + + liftedStore.dispatch(ActionCreators.jumpToState(4)); + expect(store.getState()).toBe(2); + }); + + it('should jump to action', () => { + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'DECREMENT' }); + store.dispatch({ type: 'INCREMENT' }); + expect(store.getState()).toBe(1); + + liftedStore.dispatch(ActionCreators.jumpToAction(0)); + expect(store.getState()).toBe(0); + + liftedStore.dispatch(ActionCreators.jumpToAction(1)); + expect(store.getState()).toBe(1); + + liftedStore.dispatch(ActionCreators.jumpToAction(10)); + expect(store.getState()).toBe(1); + }); + + it('should reorder actions', () => { + store = createStore(counterWithMultiply, instrument()); + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'DECREMENT' }); + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'MULTIPLY' }); + expect(store.liftedStore.getState().stagedActionIds).toEqual([0, 1, 2, 3, 4]); + expect(store.getState()).toBe(2); + + store.liftedStore.dispatch(ActionCreators.reorderAction(4, 1)); + expect(store.liftedStore.getState().stagedActionIds).toEqual([0, 4, 1, 2, 3]); + expect(store.getState()).toBe(1); + + store.liftedStore.dispatch(ActionCreators.reorderAction(4, 1)); + expect(store.liftedStore.getState().stagedActionIds).toEqual([0, 4, 1, 2, 3]); + expect(store.getState()).toBe(1); + + store.liftedStore.dispatch(ActionCreators.reorderAction(4, 2)); + expect(store.liftedStore.getState().stagedActionIds).toEqual([0, 1, 4, 2, 3]); + expect(store.getState()).toBe(2); + + store.liftedStore.dispatch(ActionCreators.reorderAction(1, 10)); + expect(store.liftedStore.getState().stagedActionIds).toEqual([0, 4, 2, 3, 1]); + expect(store.getState()).toBe(1); + + store.liftedStore.dispatch(ActionCreators.reorderAction(10, 1)); + expect(store.liftedStore.getState().stagedActionIds).toEqual([0, 4, 2, 3, 1]); + expect(store.getState()).toBe(1); + + store.liftedStore.dispatch(ActionCreators.reorderAction(1, -2)); + expect(store.liftedStore.getState().stagedActionIds).toEqual([0, 1, 4, 2, 3]); + expect(store.getState()).toBe(2); + + store.liftedStore.dispatch(ActionCreators.reorderAction(0, 1)); + expect(store.liftedStore.getState().stagedActionIds).toEqual([0, 1, 4, 2, 3]); + expect(store.getState()).toBe(2); + }); + + it('should replace the reducer', () => { + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'DECREMENT' }); + store.dispatch({ type: 'INCREMENT' }); + expect(store.getState()).toBe(1); + + store.replaceReducer(doubleCounter); + expect(store.getState()).toBe(2); + }); + + it('should replace the reducer without recomputing actions', () => { + store = createStore(counter, instrument(undefined, { shouldHotReload: false })); + expect(store.getState()).toBe(0); + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'DECREMENT' }); + store.dispatch({ type: 'INCREMENT' }); + expect(store.getState()).toBe(1); + + store.replaceReducer(doubleCounter); + expect(store.getState()).toBe(1); + store.dispatch({ type: 'INCREMENT' }); + expect(store.getState()).toBe(3); + + store.replaceReducer(() => ({ test: true })); + expect(store.getState()).toEqual({ test: true }); + }); + + it('should catch and record errors', () => { + let spy = spyOn(console, 'error'); + let storeWithBug = createStore( + counterWithBug, + instrument(undefined, { shouldCatchErrors: true }) + ); + + storeWithBug.dispatch({ type: 'INCREMENT' }); + storeWithBug.dispatch({ type: 'DECREMENT' }); + storeWithBug.dispatch({ type: 'INCREMENT' }); + + let { computedStates } = storeWithBug.liftedStore.getState(); + expect(computedStates[2].error).toMatch( + /ReferenceError/ + ); + expect(computedStates[3].error).toMatch( + /Interrupted by an error up the chain/ + ); + expect(spy.calls[0].arguments[0].toString()).toMatch( + /ReferenceError/ + ); + + spy.restore(); + }); + + it('should catch invalid action type', () => { + expect(() => { + store.dispatch({ type: undefined }); + }).toThrow( + 'Actions may not have an undefined "type" property. ' + + 'Have you misspelled a constant?' + ); + }); + + it('should catch invalid action type', () => { + function ActionClass() { + this.type = 'test'; + } + + expect(() => { + store.dispatch(new ActionClass()); + }).toThrow( + 'Actions must be plain objects. ' + + 'Use custom middleware for async actions.' + ); + }); + + it('should return the last non-undefined state from getState', () => { + let storeWithBug = createStore(counterWithBug, instrument()); + storeWithBug.dispatch({ type: 'INCREMENT' }); + storeWithBug.dispatch({ type: 'INCREMENT' }); + expect(storeWithBug.getState()).toBe(2); + + storeWithBug.dispatch({ type: 'SET_UNDEFINED' }); + expect(storeWithBug.getState()).toBe(2); + }); + + it('should not recompute states on every action', () => { + let reducerCalls = 0; + let monitoredStore = createStore(() => reducerCalls++, instrument()); + expect(reducerCalls).toBe(1); + monitoredStore.dispatch({ type: 'INCREMENT' }); + monitoredStore.dispatch({ type: 'INCREMENT' }); + monitoredStore.dispatch({ type: 'INCREMENT' }); + expect(reducerCalls).toBe(4); + }); + + it('should not recompute old states when toggling an action', () => { + let reducerCalls = 0; + let monitoredStore = createStore(() => reducerCalls++, instrument()); + let monitoredLiftedStore = monitoredStore.liftedStore; + + expect(reducerCalls).toBe(1); + // actionId 0 = @@INIT + monitoredStore.dispatch({ type: 'INCREMENT' }); + monitoredStore.dispatch({ type: 'INCREMENT' }); + monitoredStore.dispatch({ type: 'INCREMENT' }); + expect(reducerCalls).toBe(4); + + monitoredLiftedStore.dispatch(ActionCreators.toggleAction(3)); + expect(reducerCalls).toBe(4); + + monitoredLiftedStore.dispatch(ActionCreators.toggleAction(3)); + expect(reducerCalls).toBe(5); + + monitoredLiftedStore.dispatch(ActionCreators.toggleAction(2)); + expect(reducerCalls).toBe(6); + + monitoredLiftedStore.dispatch(ActionCreators.toggleAction(2)); + expect(reducerCalls).toBe(8); + + monitoredLiftedStore.dispatch(ActionCreators.toggleAction(1)); + expect(reducerCalls).toBe(10); + + monitoredLiftedStore.dispatch(ActionCreators.toggleAction(2)); + expect(reducerCalls).toBe(11); + + monitoredLiftedStore.dispatch(ActionCreators.toggleAction(3)); + expect(reducerCalls).toBe(11); + + monitoredLiftedStore.dispatch(ActionCreators.toggleAction(1)); + expect(reducerCalls).toBe(12); + + monitoredLiftedStore.dispatch(ActionCreators.toggleAction(3)); + expect(reducerCalls).toBe(13); + + monitoredLiftedStore.dispatch(ActionCreators.toggleAction(2)); + expect(reducerCalls).toBe(15); + }); + + it('should not recompute states when jumping to state', () => { + let reducerCalls = 0; + let monitoredStore = createStore(() => reducerCalls++, instrument()); + let monitoredLiftedStore = monitoredStore.liftedStore; + + expect(reducerCalls).toBe(1); + monitoredStore.dispatch({ type: 'INCREMENT' }); + monitoredStore.dispatch({ type: 'INCREMENT' }); + monitoredStore.dispatch({ type: 'INCREMENT' }); + expect(reducerCalls).toBe(4); + + let savedComputedStates = monitoredLiftedStore.getState().computedStates; + + monitoredLiftedStore.dispatch(ActionCreators.jumpToState(0)); + expect(reducerCalls).toBe(4); + + monitoredLiftedStore.dispatch(ActionCreators.jumpToState(1)); + expect(reducerCalls).toBe(4); + + monitoredLiftedStore.dispatch(ActionCreators.jumpToState(3)); + expect(reducerCalls).toBe(4); + + expect(monitoredLiftedStore.getState().computedStates).toBe(savedComputedStates); + }); + + it('should not recompute states on monitor actions', () => { + let reducerCalls = 0; + let monitoredStore = createStore(() => reducerCalls++, instrument()); + let monitoredLiftedStore = monitoredStore.liftedStore; + + expect(reducerCalls).toBe(1); + monitoredStore.dispatch({ type: 'INCREMENT' }); + monitoredStore.dispatch({ type: 'INCREMENT' }); + monitoredStore.dispatch({ type: 'INCREMENT' }); + expect(reducerCalls).toBe(4); + + let savedComputedStates = monitoredLiftedStore.getState().computedStates; + + monitoredLiftedStore.dispatch({ type: 'lol' }); + expect(reducerCalls).toBe(4); + + monitoredLiftedStore.dispatch({ type: 'wat' }); + expect(reducerCalls).toBe(4); + + expect(monitoredLiftedStore.getState().computedStates).toBe(savedComputedStates); + }); + + describe('maxAge option', () => { + let configuredStore; + let configuredLiftedStore; + + beforeEach(() => { + configuredStore = createStore(counter, instrument(undefined, { maxAge: 3 })); + configuredLiftedStore = configuredStore.liftedStore; + }); + + it('should auto-commit earliest non-@@INIT action when maxAge is reached', () => { + configuredStore.dispatch({ type: 'INCREMENT' }); + configuredStore.dispatch({ type: 'INCREMENT' }); + let liftedStoreState = configuredLiftedStore.getState(); + + expect(configuredStore.getState()).toBe(2); + expect(Object.keys(liftedStoreState.actionsById).length).toBe(3); + expect(liftedStoreState.committedState).toBe(undefined); + expect(liftedStoreState.stagedActionIds).toInclude(1); + + // Trigger auto-commit. + configuredStore.dispatch({ type: 'INCREMENT' }); + liftedStoreState = configuredLiftedStore.getState(); + + expect(configuredStore.getState()).toBe(3); + expect(Object.keys(liftedStoreState.actionsById).length).toBe(3); + expect(liftedStoreState.stagedActionIds).toExclude(1); + expect(liftedStoreState.computedStates[0].state).toBe(1); + expect(liftedStoreState.committedState).toBe(1); + expect(liftedStoreState.currentStateIndex).toBe(2); + }); + + it('should remove skipped actions once committed', () => { + configuredStore.dispatch({ type: 'INCREMENT' }); + configuredLiftedStore.dispatch(ActionCreators.toggleAction(1)); + configuredStore.dispatch({ type: 'INCREMENT' }); + expect(configuredLiftedStore.getState().skippedActionIds).toInclude(1); + configuredStore.dispatch({ type: 'INCREMENT' }); + expect(configuredLiftedStore.getState().skippedActionIds).toExclude(1); + }); + + it('should not auto-commit errors', () => { + let spy = spyOn(console, 'error'); + + let storeWithBug = createStore( + counterWithBug, + instrument(undefined, { maxAge: 3, shouldCatchErrors: true }) + ); + let liftedStoreWithBug = storeWithBug.liftedStore; + storeWithBug.dispatch({ type: 'DECREMENT' }); + storeWithBug.dispatch({ type: 'INCREMENT' }); + expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(3); + + storeWithBug.dispatch({ type: 'INCREMENT' }); + expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(4); + + spy.restore(); + }); + + it('should auto-commit actions after hot reload fixes error', () => { + let spy = spyOn(console, 'error'); + + let storeWithBug = createStore( + counterWithBug, + instrument(undefined, { maxAge: 3, shouldCatchErrors: true }) + ); + let liftedStoreWithBug = storeWithBug.liftedStore; + storeWithBug.dispatch({ type: 'DECREMENT' }); + storeWithBug.dispatch({ type: 'DECREMENT' }); + storeWithBug.dispatch({ type: 'INCREMENT' }); + storeWithBug.dispatch({ type: 'DECREMENT' }); + storeWithBug.dispatch({ type: 'DECREMENT' }); + storeWithBug.dispatch({ type: 'DECREMENT' }); + expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(7); + + // Auto-commit 2 actions by "fixing" reducer bug, but introducing another. + storeWithBug.replaceReducer(counterWithAnotherBug); + expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(5); + + // Auto-commit 2 more actions by "fixing" other reducer bug. + storeWithBug.replaceReducer(counter); + expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(3); + + spy.restore(); + }); + + it('should update currentStateIndex when auto-committing', () => { + let liftedStoreState; + let currentComputedState; + + configuredStore.dispatch({ type: 'INCREMENT' }); + configuredStore.dispatch({ type: 'INCREMENT' }); + liftedStoreState = configuredLiftedStore.getState(); + expect(liftedStoreState.currentStateIndex).toBe(2); + + // currentStateIndex should stay at 2 as actions are committed. + configuredStore.dispatch({ type: 'INCREMENT' }); + liftedStoreState = configuredLiftedStore.getState(); + currentComputedState = liftedStoreState.computedStates[liftedStoreState.currentStateIndex]; + expect(liftedStoreState.currentStateIndex).toBe(2); + expect(currentComputedState.state).toBe(3); + }); + + it('should continue to increment currentStateIndex while error blocks commit', () => { + let spy = spyOn(console, 'error'); + + let storeWithBug = createStore( + counterWithBug, + instrument(undefined, { maxAge: 3, shouldCatchErrors: true }) + ); + let liftedStoreWithBug = storeWithBug.liftedStore; + + storeWithBug.dispatch({ type: 'DECREMENT' }); + storeWithBug.dispatch({ type: 'DECREMENT' }); + storeWithBug.dispatch({ type: 'DECREMENT' }); + storeWithBug.dispatch({ type: 'DECREMENT' }); + + let liftedStoreState = liftedStoreWithBug.getState(); + let currentComputedState = liftedStoreState.computedStates[liftedStoreState.currentStateIndex]; + expect(liftedStoreState.currentStateIndex).toBe(4); + expect(currentComputedState.state).toBe(0); + expect(currentComputedState.error).toExist(); + + spy.restore(); + }); + + it('should adjust currentStateIndex correctly when multiple actions are committed', () => { + let spy = spyOn(console, 'error'); + + let storeWithBug = createStore( + counterWithBug, + instrument(undefined, { maxAge: 3, shouldCatchErrors: true }) + ); + let liftedStoreWithBug = storeWithBug.liftedStore; + + storeWithBug.dispatch({ type: 'DECREMENT' }); + storeWithBug.dispatch({ type: 'DECREMENT' }); + storeWithBug.dispatch({ type: 'DECREMENT' }); + storeWithBug.dispatch({ type: 'DECREMENT' }); + + // Auto-commit 2 actions by "fixing" reducer bug. + storeWithBug.replaceReducer(counter); + let liftedStoreState = liftedStoreWithBug.getState(); + let currentComputedState = liftedStoreState.computedStates[liftedStoreState.currentStateIndex]; + expect(liftedStoreState.currentStateIndex).toBe(2); + expect(currentComputedState.state).toBe(-4); + + spy.restore(); + }); + + it('should not allow currentStateIndex to drop below 0', () => { + let spy = spyOn(console, 'error'); + + let storeWithBug = createStore( + counterWithBug, + instrument(undefined, { maxAge: 3, shouldCatchErrors: true }) + ); + let liftedStoreWithBug = storeWithBug.liftedStore; + + storeWithBug.dispatch({ type: 'DECREMENT' }); + storeWithBug.dispatch({ type: 'DECREMENT' }); + storeWithBug.dispatch({ type: 'DECREMENT' }); + storeWithBug.dispatch({ type: 'DECREMENT' }); + liftedStoreWithBug.dispatch(ActionCreators.jumpToState(1)); + + // Auto-commit 2 actions by "fixing" reducer bug. + storeWithBug.replaceReducer(counter); + let liftedStoreState = liftedStoreWithBug.getState(); + let currentComputedState = liftedStoreState.computedStates[liftedStoreState.currentStateIndex]; + expect(liftedStoreState.currentStateIndex).toBe(0); + expect(currentComputedState.state).toBe(-2); + + spy.restore(); + }); + + it('should use dynamic maxAge', () => { + let max = 3; + const getMaxAge = expect.createSpy().andCall(() => max); + store = createStore(counter, instrument(undefined, { maxAge: getMaxAge })); + + expect(getMaxAge.calls.length).toEqual(1); + store.dispatch({ type: 'INCREMENT' }); + expect(getMaxAge.calls.length).toEqual(2); + store.dispatch({ type: 'INCREMENT' }); + expect(getMaxAge.calls.length).toEqual(3); + let liftedStoreState = store.liftedStore.getState(); + + expect(getMaxAge.calls[0].arguments[0].type).toInclude('INIT'); + expect(getMaxAge.calls[0].arguments[1]).toBe(undefined); + expect(getMaxAge.calls[1].arguments[0].type).toBe('PERFORM_ACTION'); + expect(getMaxAge.calls[1].arguments[1].nextActionId).toBe(1); + expect(getMaxAge.calls[1].arguments[1].stagedActionIds).toEqual([0]); + expect(getMaxAge.calls[2].arguments[1].nextActionId).toBe(2); + expect(getMaxAge.calls[2].arguments[1].stagedActionIds).toEqual([0, 1]); + + expect(store.getState()).toBe(2); + expect(Object.keys(liftedStoreState.actionsById).length).toBe(3); + expect(liftedStoreState.committedState).toBe(undefined); + expect(liftedStoreState.stagedActionIds).toInclude(1); + + // Trigger auto-commit. + store.dispatch({ type: 'INCREMENT' }); + liftedStoreState = store.liftedStore.getState(); + + expect(store.getState()).toBe(3); + expect(Object.keys(liftedStoreState.actionsById).length).toBe(3); + expect(liftedStoreState.stagedActionIds).toExclude(1); + expect(liftedStoreState.computedStates[0].state).toBe(1); + expect(liftedStoreState.committedState).toBe(1); + expect(liftedStoreState.currentStateIndex).toBe(2); + + max = 4; + store.dispatch({ type: 'INCREMENT' }); + liftedStoreState = store.liftedStore.getState(); + + expect(store.getState()).toBe(4); + expect(Object.keys(liftedStoreState.actionsById).length).toBe(4); + expect(liftedStoreState.stagedActionIds).toExclude(1); + expect(liftedStoreState.computedStates[0].state).toBe(1); + expect(liftedStoreState.committedState).toBe(1); + expect(liftedStoreState.currentStateIndex).toBe(3); + + max = 3; + store.dispatch({ type: 'INCREMENT' }); + liftedStoreState = store.liftedStore.getState(); + + expect(store.getState()).toBe(5); + expect(Object.keys(liftedStoreState.actionsById).length).toBe(3); + expect(liftedStoreState.stagedActionIds).toExclude(1); + expect(liftedStoreState.computedStates[0].state).toBe(3); + expect(liftedStoreState.committedState).toBe(3); + expect(liftedStoreState.currentStateIndex).toBe(2); + + store.dispatch({ type: 'INCREMENT' }); + liftedStoreState = store.liftedStore.getState(); + + expect(store.getState()).toBe(6); + expect(Object.keys(liftedStoreState.actionsById).length).toBe(3); + expect(liftedStoreState.stagedActionIds).toExclude(1); + expect(liftedStoreState.computedStates[0].state).toBe(4); + expect(liftedStoreState.committedState).toBe(4); + expect(liftedStoreState.currentStateIndex).toBe(2); + }); + + it('should throw error when maxAge < 2', () => { + expect(() => { + createStore(counter, instrument(undefined, { maxAge: 1 })); + }).toThrow(/may not be less than 2/); + }); + }); + + describe('Import State', () => { + let monitoredStore; + let monitoredLiftedStore; + let exportedState; + + beforeEach(() => { + monitoredStore = createStore(counter, instrument()); + monitoredLiftedStore = monitoredStore.liftedStore; + // Set up state to export + monitoredStore.dispatch({ type: 'INCREMENT' }); + monitoredStore.dispatch({ type: 'INCREMENT' }); + monitoredStore.dispatch({ type: 'INCREMENT' }); + + exportedState = monitoredLiftedStore.getState(); + }); + + it('should replay all the steps when a state is imported', () => { + let importMonitoredStore = createStore(counter, instrument()); + let importMonitoredLiftedStore = importMonitoredStore.liftedStore; + + importMonitoredLiftedStore.dispatch(ActionCreators.importState(exportedState)); + expect(importMonitoredLiftedStore.getState()).toEqual(exportedState); + }); + + it('should replace the existing action log with the one imported', () => { + let importMonitoredStore = createStore(counter, instrument()); + let importMonitoredLiftedStore = importMonitoredStore.liftedStore; + + importMonitoredStore.dispatch({ type: 'DECREMENT' }); + importMonitoredStore.dispatch({ type: 'DECREMENT' }); + + importMonitoredLiftedStore.dispatch(ActionCreators.importState(exportedState)); + expect(importMonitoredLiftedStore.getState()).toEqual(exportedState); + }); + + it('should allow for state to be imported without replaying actions', () => { + let importMonitoredStore = createStore(counter, instrument()); + let importMonitoredLiftedStore = importMonitoredStore.liftedStore; + + let noComputedExportedState = Object.assign({}, exportedState); + delete noComputedExportedState.computedStates; + + importMonitoredLiftedStore.dispatch(ActionCreators.importState(noComputedExportedState, true)); + + let expectedImportedState = Object.assign({}, noComputedExportedState, { + computedStates: undefined + }); + expect(importMonitoredLiftedStore.getState()).toEqual(expectedImportedState); + }); + + it('should include callstack', () => { + let importMonitoredStore = createStore(counter, instrument(undefined, { shouldIncludeCallstack: true })); + let importMonitoredLiftedStore = importMonitoredStore.liftedStore; + + importMonitoredStore.dispatch({ type: 'DECREMENT' }); + importMonitoredStore.dispatch({ type: 'DECREMENT' }); + + const oldState = importMonitoredLiftedStore.getState(); + expect(oldState.actionsById[0].stack).toBe(undefined); + expect(oldState.actionsById[1].stack).toBeA('string'); + + importMonitoredLiftedStore.dispatch(ActionCreators.importState(oldState)); + expect(importMonitoredLiftedStore.getState()).toEqual(oldState); + expect(importMonitoredLiftedStore.getState().actionsById[0].stack).toBe(undefined); + expect(importMonitoredLiftedStore.getState().actionsById[1]).toEqual(oldState.actionsById[1]); + }); + }); + + function filterStackAndTimestamps(state) { + state.actionsById = _.mapValues(state.actionsById, (action) => { + delete action.timestamp; + delete action.stack; + return action; + }); + return state; + } + + describe('Import Actions', () => { + let monitoredStore; + let monitoredLiftedStore; + let exportedState; + let savedActions = [ + { type: 'INCREMENT' }, + { type: 'INCREMENT' }, + { type: 'INCREMENT' } + ]; + + beforeEach(() => { + monitoredStore = createStore(counter, instrument()); + monitoredLiftedStore = monitoredStore.liftedStore; + // Pass actions through component + savedActions.forEach(action => monitoredStore.dispatch(action)); + // get the final state + exportedState = filterStackAndTimestamps(monitoredLiftedStore.getState()); + }); + + it('should replay all the steps when a state is imported', () => { + let importMonitoredStore = createStore(counter, instrument()); + let importMonitoredLiftedStore = importMonitoredStore.liftedStore; + + importMonitoredLiftedStore.dispatch(ActionCreators.importState(savedActions)); + expect(filterStackAndTimestamps(importMonitoredLiftedStore.getState())).toEqual(exportedState); + }); + + it('should replace the existing action log with the one imported', () => { + let importMonitoredStore = createStore(counter, instrument()); + let importMonitoredLiftedStore = importMonitoredStore.liftedStore; + + importMonitoredStore.dispatch({ type: 'DECREMENT' }); + importMonitoredStore.dispatch({ type: 'DECREMENT' }); + + importMonitoredLiftedStore.dispatch(ActionCreators.importState(savedActions)); + expect(filterStackAndTimestamps(importMonitoredLiftedStore.getState())).toEqual(exportedState); + }); + + it('should include callstack', () => { + let importMonitoredStore = createStore(counter, instrument(undefined, { shouldIncludeCallstack: true })); + let importMonitoredLiftedStore = importMonitoredStore.liftedStore; + + importMonitoredStore.dispatch({ type: 'DECREMENT' }); + importMonitoredStore.dispatch({ type: 'DECREMENT' }); + + importMonitoredLiftedStore.dispatch(ActionCreators.importState(savedActions)); + expect(importMonitoredLiftedStore.getState().actionsById[0].stack).toBe(undefined); + expect(importMonitoredLiftedStore.getState().actionsById[1].stack).toBeA('string'); + expect(filterStackAndTimestamps(importMonitoredLiftedStore.getState())).toEqual(exportedState); + }); + }); + + describe('Lock Changes', () => { + it('should lock', () => { + store.dispatch({ type: 'INCREMENT' }); + store.liftedStore.dispatch({ type: 'LOCK_CHANGES', status: true }); + expect(store.liftedStore.getState().isLocked).toBe(true); + expect(store.liftedStore.getState().nextActionId).toBe(2); + expect(store.getState()).toBe(1); + + store.dispatch({ type: 'INCREMENT' }); + expect(store.liftedStore.getState().nextActionId).toBe(2); + expect(store.getState()).toBe(1); + + liftedStore.dispatch(ActionCreators.toggleAction(1)); + expect(store.getState()).toBe(0); + liftedStore.dispatch(ActionCreators.toggleAction(1)); + expect(store.getState()).toBe(1); + + store.liftedStore.dispatch({ type: 'LOCK_CHANGES', status: false }); + expect(store.liftedStore.getState().isLocked).toBe(false); + expect(store.liftedStore.getState().nextActionId).toBe(2); + + store.dispatch({ type: 'INCREMENT' }); + expect(store.liftedStore.getState().nextActionId).toBe(3); + expect(store.getState()).toBe(2); + }); + it('should start locked', () => { + store = createStore(counter, instrument(undefined, { shouldStartLocked: true })); + store.dispatch({ type: 'INCREMENT' }); + expect(store.liftedStore.getState().isLocked).toBe(true); + expect(store.liftedStore.getState().nextActionId).toBe(1); + expect(store.getState()).toBe(0); + + const savedActions = [{ type: 'INCREMENT' }, { type: 'INCREMENT' }]; + store.liftedStore.dispatch(ActionCreators.importState(savedActions)); + expect(store.liftedStore.getState().nextActionId).toBe(3); + expect(store.getState()).toBe(2); + + store.liftedStore.dispatch({ type: 'LOCK_CHANGES', status: false }); + expect(store.liftedStore.getState().isLocked).toBe(false); + + store.dispatch({ type: 'INCREMENT' }); + expect(store.liftedStore.getState().nextActionId).toBe(4); + expect(store.getState()).toBe(3); + }); + }); + + describe('Pause recording', () => { + it('should pause', () => { + expect(store.liftedStore.getState().isPaused).toBe(false); + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'INCREMENT' }); + expect(store.liftedStore.getState().nextActionId).toBe(3); + expect(store.getState()).toBe(2); + + store.liftedStore.dispatch(ActionCreators.pauseRecording(true)); + expect(store.liftedStore.getState().isPaused).toBe(true); + expect(store.liftedStore.getState().nextActionId).toBe(1); + expect(store.liftedStore.getState().actionsById[0].action).toEqual({ type: '@@INIT' }); + expect(store.getState()).toBe(2); + + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'INCREMENT' }); + expect(store.liftedStore.getState().nextActionId).toBe(1); + expect(store.liftedStore.getState().actionsById[0].action).toEqual({ type: '@@INIT' }); + expect(store.getState()).toBe(4); + + store.liftedStore.dispatch(ActionCreators.pauseRecording(false)); + expect(store.liftedStore.getState().isPaused).toBe(false); + + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'INCREMENT' }); + expect(store.liftedStore.getState().nextActionId).toBe(3); + expect(store.liftedStore.getState().actionsById[2].action).toEqual({ type: 'INCREMENT' }); + expect(store.getState()).toBe(6); + }); + it('should maintain the history while paused', () => { + store = createStore(counter, instrument(undefined, { pauseActionType: '@@PAUSED' })); + store.dispatch({ type: 'INCREMENT' }); + store.dispatch({ type: 'INCREMENT' }); + expect(store.getState()).toBe(2); + expect(store.liftedStore.getState().nextActionId).toBe(3); + expect(store.liftedStore.getState().isPaused).toBe(false); + + store.liftedStore.dispatch(ActionCreators.pauseRecording(true)); + expect(store.liftedStore.getState().isPaused).toBe(true); + expect(store.liftedStore.getState().nextActionId).toBe(4); + expect(store.getState()).toBe(2); + + store.dispatch({ type: 'INCREMENT' }); + expect(store.liftedStore.getState().nextActionId).toBe(4); + store.dispatch({ type: 'INCREMENT' }); + expect(store.liftedStore.getState().nextActionId).toBe(4); + expect(store.getState()).toBe(4); + + store.liftedStore.dispatch(ActionCreators.pauseRecording(false)); + expect(store.liftedStore.getState().isPaused).toBe(false); + expect(store.liftedStore.getState().nextActionId).toBe(1); + expect(store.getState()).toBe(4); + store.dispatch({ type: 'INCREMENT' }); + expect(store.liftedStore.getState().nextActionId).toBe(2); + expect(store.getState()).toBe(5); + + store.liftedStore.dispatch(ActionCreators.commit()); + store.liftedStore.dispatch(ActionCreators.pauseRecording(true)); + store.dispatch({ type: 'INCREMENT' }); + expect(store.liftedStore.getState().nextActionId).toBe(1); + expect(store.getState()).toBe(6); + }); + }); + + it('throws if reducer is not a function', () => { + expect(() => + createStore(undefined, instrument()) + ).toThrow('Expected the reducer to be a function.'); + }); + + it('warns if the reducer is not a function but has a default field that is', () => { + expect(() => + createStore(({ 'default': () => {} }), instrument()) + ).toThrow( + 'Expected the reducer to be a function. ' + + 'Instead got an object with a "default" field. ' + + 'Did you pass a module instead of the default export? ' + + 'Try passing require(...).default instead.' + ); + }); + + it('throws if there are more than one instrument enhancer included', () => { + expect(() => { + createStore(counter, compose(instrument(), instrument())); + }).toThrow( + 'DevTools instrumentation should not be applied more than once. ' + + 'Check your store configuration.' + ); + }); +});