├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── package.json ├── src └── instrument.js └── test └── instrument.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015-loose", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | **/node_modules 3 | **/webpack.config.js 4 | examples/**/server.js -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-airbnb", 3 | "env": { 4 | "browser": true, 5 | "mocha": true, 6 | "node": true 7 | }, 8 | "rules": { 9 | "react/jsx-uses-react": 2, 10 | "react/jsx-uses-vars": 2, 11 | "react/react-in-jsx-scope": 2, 12 | "no-console": 0, 13 | // Temporarily disabled due to babel-eslint issues: 14 | "block-scoped-var": 0, 15 | "padded-blocks": 0, 16 | }, 17 | "plugins": [ 18 | "react" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .DS_Store 4 | lib 5 | coverage 6 | .idea 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5" 4 | 5 | script: 6 | - npm run lint 7 | - npm test 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | See all notable changes on [Releases](https://github.com/gaearon/redux-devtools/releases) page. 5 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Dan Abramov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Redux DevTools Instrumentation 2 | ============================== 3 | 4 | > This package was merged into [`redux-devtools`](https://github.com/reduxjs/redux-devtools) monorepo. Please refer to that repository for the latest updates, issues and pull requests. 5 | 6 | Redux enhancer used along with [Redux DevTools](https://github.com/reduxjs/redux-devtools) or [Remote Redux DevTools](https://github.com/zalmoxisus/remote-redux-devtools). 7 | 8 | ### Installation 9 | 10 | ``` 11 | npm install --save-dev redux-devtools-instrument 12 | ``` 13 | 14 | ### Usage 15 | 16 | Add the store enhancer: 17 | 18 | ##### `store/configureStore.js` 19 | 20 | ```js 21 | import { createStore, applyMiddleware, compose } from 'redux'; 22 | import thunk from 'redux-thunk'; 23 | import devTools from 'remote-redux-devtools'; 24 | import reducer from '../reducers'; 25 | 26 | // Usually you import the reducer from the monitor 27 | // or apply with createDevTools as explained in Redux DevTools 28 | const monitorReducer = (state = {}, action) => state; 29 | 30 | export default function configureStore(initialState) { 31 | const enhancer = compose( 32 | applyMiddleware(...middlewares), 33 | // other enhancers and applyMiddleware should be added before the instrumentation 34 | instrument(monitorReducer, { maxAge: 50 }) 35 | ); 36 | 37 | // Note: passing enhancer as last argument requires redux@>=3.1.0 38 | return createStore(reducer, initialState, enhancer); 39 | } 40 | ``` 41 | 42 | ### API 43 | 44 | `instrument(monitorReducer, [options])` 45 | 46 | - arguments 47 | - **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)). 48 | - **options** *object* 49 | - **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. 50 | - **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. 51 | - **shouldRecordChanges** *boolean* - if specified as `false`, it will not record the changes till `pauseRecording(false)` is dispatched. Default is `true`. 52 | - **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. 53 | - **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`. 54 | - **shouldHotReload** *boolean* - if set to `false`, will not recompute the states on hot reloading (or on replacing the reducers). Default to `true`. 55 | - **trace** *boolean* or *function* - if set to `true`, will include stack trace for every dispatched action. You can use a function (with action object as argument) which should return `new Error().stack` string, getting the stack outside of reducers. Default to `false`. 56 | - **traceLimit** *number* - maximum stack trace frames to be stored (in case `trace` option was provided as `true`). By default it's `10`. If `trace` option is a function, `traceLimit` will have no effect, that should be handled there like so: `trace: () => new Error().stack.split('\n').slice(0, limit+1).join('\n')` (`+1` is needed for Chrome where's an extra 1st frame for `Error\n`). 57 | 58 | ### License 59 | 60 | MIT 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-devtools-instrument", 3 | "version": "1.9.3", 4 | "description": "Redux DevTools instrumentation", 5 | "main": "lib/instrument.js", 6 | "scripts": { 7 | "clean": "rimraf lib", 8 | "build": "babel src --out-dir lib", 9 | "lint": "eslint src test", 10 | "test": "NODE_ENV=test mocha --compilers js:babel-core/register --recursive", 11 | "test:watch": "NODE_ENV=test mocha --compilers js:babel-core/register --recursive --watch", 12 | "test:cov": "babel-node ./node_modules/.bin/isparta cover ./node_modules/.bin/_mocha -- --recursive", 13 | "prepublish": "npm run lint && npm run test && npm run clean && npm run build" 14 | }, 15 | "files": [ 16 | "lib", 17 | "src" 18 | ], 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/zalmoxisus/redux-devtools-instrument.git" 22 | }, 23 | "keywords": [ 24 | "redux", 25 | "devtools", 26 | "flux", 27 | "hot reloading", 28 | "time travel", 29 | "live edit" 30 | ], 31 | "author": "Dan Abramov (http://github.com/gaearon)", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/zalmoxisus/redux-devtools-instrument/issues" 35 | }, 36 | "homepage": "https://github.com/zalmoxisus/redux-devtools-instrument", 37 | "devDependencies": { 38 | "babel-cli": "^6.3.17", 39 | "babel-core": "^6.3.17", 40 | "babel-eslint": "^4.1.6", 41 | "babel-loader": "^6.2.0", 42 | "babel-preset-es2015-loose": "^6.1.3", 43 | "babel-preset-stage-0": "^6.3.13", 44 | "eslint": "^0.23", 45 | "eslint-config-airbnb": "0.0.6", 46 | "eslint-plugin-react": "^2.3.0", 47 | "expect": "^1.6.0", 48 | "isparta": "^3.0.3", 49 | "mocha": "^2.2.5", 50 | "redux": "^4.0.0", 51 | "rimraf": "^2.3.4", 52 | "rxjs": "^5.0.0-beta.6", 53 | "webpack": "^1.11.0" 54 | }, 55 | "dependencies": { 56 | "lodash": "^4.2.0", 57 | "symbol-observable": "^1.0.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/instrument.js: -------------------------------------------------------------------------------- 1 | import difference from 'lodash/difference'; 2 | import union from 'lodash/union'; 3 | import isPlainObject from 'lodash/isPlainObject'; 4 | import $$observable from 'symbol-observable'; 5 | 6 | export const ActionTypes = { 7 | PERFORM_ACTION: 'PERFORM_ACTION', 8 | RESET: 'RESET', 9 | ROLLBACK: 'ROLLBACK', 10 | COMMIT: 'COMMIT', 11 | SWEEP: 'SWEEP', 12 | TOGGLE_ACTION: 'TOGGLE_ACTION', 13 | SET_ACTIONS_ACTIVE: 'SET_ACTIONS_ACTIVE', 14 | JUMP_TO_STATE: 'JUMP_TO_STATE', 15 | JUMP_TO_ACTION: 'JUMP_TO_ACTION', 16 | REORDER_ACTION: 'REORDER_ACTION', 17 | IMPORT_STATE: 'IMPORT_STATE', 18 | LOCK_CHANGES: 'LOCK_CHANGES', 19 | PAUSE_RECORDING: 'PAUSE_RECORDING' 20 | }; 21 | 22 | /** 23 | * Action creators to change the History state. 24 | */ 25 | export const ActionCreators = { 26 | performAction(action, trace, traceLimit, toExcludeFromTrace) { 27 | if (!isPlainObject(action)) { 28 | throw new Error( 29 | 'Actions must be plain objects. ' + 30 | 'Use custom middleware for async actions.' 31 | ); 32 | } 33 | 34 | if (typeof action.type === 'undefined') { 35 | throw new Error( 36 | 'Actions may not have an undefined "type" property. ' + 37 | 'Have you misspelled a constant?' 38 | ); 39 | } 40 | 41 | let stack; 42 | if (trace) { 43 | let extraFrames = 0; 44 | if (typeof trace === 'function') { 45 | stack = trace(action); 46 | } else { 47 | const error = Error(); 48 | let prevStackTraceLimit; 49 | if (Error.captureStackTrace) { 50 | if (Error.stackTraceLimit < traceLimit) { 51 | prevStackTraceLimit = Error.stackTraceLimit; 52 | Error.stackTraceLimit = traceLimit; 53 | } 54 | Error.captureStackTrace(error, toExcludeFromTrace); 55 | } else { 56 | extraFrames = 3; 57 | } 58 | stack = error.stack; 59 | if (prevStackTraceLimit) Error.stackTraceLimit = prevStackTraceLimit; 60 | if (extraFrames || typeof Error.stackTraceLimit !== 'number' || Error.stackTraceLimit > traceLimit) { 61 | const frames = stack.split('\n'); 62 | if (frames.length > traceLimit) { 63 | stack = frames.slice(0, traceLimit + extraFrames + (frames[0] === 'Error' ? 1 : 0)).join('\n'); 64 | } 65 | } 66 | } 67 | } 68 | 69 | return { type: ActionTypes.PERFORM_ACTION, action, timestamp: Date.now(), stack }; 70 | }, 71 | 72 | reset() { 73 | return { type: ActionTypes.RESET, timestamp: Date.now() }; 74 | }, 75 | 76 | rollback() { 77 | return { type: ActionTypes.ROLLBACK, timestamp: Date.now() }; 78 | }, 79 | 80 | commit() { 81 | return { type: ActionTypes.COMMIT, timestamp: Date.now() }; 82 | }, 83 | 84 | sweep() { 85 | return { type: ActionTypes.SWEEP }; 86 | }, 87 | 88 | toggleAction(id) { 89 | return { type: ActionTypes.TOGGLE_ACTION, id }; 90 | }, 91 | 92 | setActionsActive(start, end, active=true) { 93 | return { type: ActionTypes.SET_ACTIONS_ACTIVE, start, end, active }; 94 | }, 95 | 96 | reorderAction(actionId, beforeActionId) { 97 | return { type: ActionTypes.REORDER_ACTION, actionId, beforeActionId }; 98 | }, 99 | 100 | jumpToState(index) { 101 | return { type: ActionTypes.JUMP_TO_STATE, index }; 102 | }, 103 | 104 | jumpToAction(actionId) { 105 | return { type: ActionTypes.JUMP_TO_ACTION, actionId }; 106 | }, 107 | 108 | importState(nextLiftedState, noRecompute) { 109 | return { type: ActionTypes.IMPORT_STATE, nextLiftedState, noRecompute }; 110 | }, 111 | 112 | lockChanges(status) { 113 | return { type: ActionTypes.LOCK_CHANGES, status }; 114 | }, 115 | 116 | pauseRecording(status) { 117 | return { type: ActionTypes.PAUSE_RECORDING, status }; 118 | } 119 | }; 120 | 121 | export const INIT_ACTION = { type: '@@INIT' }; 122 | 123 | /** 124 | * Computes the next entry with exceptions catching. 125 | */ 126 | function computeWithTryCatch(reducer, action, state) { 127 | let nextState = state; 128 | let nextError; 129 | try { 130 | nextState = reducer(state, action); 131 | } catch (err) { 132 | nextError = err.toString(); 133 | if ( 134 | typeof window === 'object' && ( 135 | typeof window.chrome !== 'undefined' || 136 | typeof window.process !== 'undefined' && 137 | window.process.type === 'renderer' 138 | )) { 139 | // In Chrome, rethrowing provides better source map support 140 | setTimeout(() => { throw err; }); 141 | } else { 142 | console.error(err); 143 | } 144 | } 145 | 146 | return { 147 | state: nextState, 148 | error: nextError 149 | }; 150 | } 151 | 152 | /** 153 | * Computes the next entry in the log by applying an action. 154 | */ 155 | function computeNextEntry(reducer, action, state, shouldCatchErrors) { 156 | if (!shouldCatchErrors) { 157 | return { state: reducer(state, action) }; 158 | } 159 | return computeWithTryCatch(reducer, action, state); 160 | } 161 | 162 | /** 163 | * Runs the reducer on invalidated actions to get a fresh computation log. 164 | */ 165 | function recomputeStates( 166 | computedStates, 167 | minInvalidatedStateIndex, 168 | reducer, 169 | committedState, 170 | actionsById, 171 | stagedActionIds, 172 | skippedActionIds, 173 | shouldCatchErrors 174 | ) { 175 | // Optimization: exit early and return the same reference 176 | // if we know nothing could have changed. 177 | if ( 178 | !computedStates || minInvalidatedStateIndex === -1 || 179 | (minInvalidatedStateIndex >= computedStates.length && 180 | computedStates.length === stagedActionIds.length) 181 | ) { 182 | return computedStates; 183 | } 184 | 185 | const nextComputedStates = computedStates.slice(0, minInvalidatedStateIndex); 186 | for (let i = minInvalidatedStateIndex; i < stagedActionIds.length; i++) { 187 | const actionId = stagedActionIds[i]; 188 | const action = actionsById[actionId].action; 189 | 190 | const previousEntry = nextComputedStates[i - 1]; 191 | const previousState = previousEntry ? previousEntry.state : committedState; 192 | 193 | const shouldSkip = skippedActionIds.indexOf(actionId) > -1; 194 | let entry; 195 | if (shouldSkip) { 196 | entry = previousEntry; 197 | } else { 198 | if (shouldCatchErrors && previousEntry && previousEntry.error) { 199 | entry = { 200 | state: previousState, 201 | error: 'Interrupted by an error up the chain' 202 | }; 203 | } else { 204 | entry = computeNextEntry(reducer, action, previousState, shouldCatchErrors); 205 | } 206 | } 207 | nextComputedStates.push(entry); 208 | } 209 | 210 | return nextComputedStates; 211 | } 212 | 213 | /** 214 | * Lifts an app's action into an action on the lifted store. 215 | */ 216 | export function liftAction(action, trace, traceLimit, toExcludeFromTrace) { 217 | return ActionCreators.performAction(action, trace, traceLimit, toExcludeFromTrace); 218 | } 219 | 220 | /** 221 | * Creates a history state reducer from an app's reducer. 222 | */ 223 | export function liftReducerWith(reducer, initialCommittedState, monitorReducer, options) { 224 | const initialLiftedState = { 225 | monitorState: monitorReducer(undefined, {}), 226 | nextActionId: 1, 227 | actionsById: { 0: liftAction(INIT_ACTION) }, 228 | stagedActionIds: [0], 229 | skippedActionIds: [], 230 | committedState: initialCommittedState, 231 | currentStateIndex: 0, 232 | computedStates: [], 233 | isLocked: options.shouldStartLocked === true, 234 | isPaused: options.shouldRecordChanges === false 235 | }; 236 | 237 | /** 238 | * Manages how the history actions modify the history state. 239 | */ 240 | return (liftedState, liftedAction) => { 241 | let { 242 | monitorState, 243 | actionsById, 244 | nextActionId, 245 | stagedActionIds, 246 | skippedActionIds, 247 | committedState, 248 | currentStateIndex, 249 | computedStates, 250 | isLocked, 251 | isPaused 252 | } = liftedState || initialLiftedState; 253 | 254 | if (!liftedState) { 255 | // Prevent mutating initialLiftedState 256 | actionsById = { ...actionsById }; 257 | } 258 | 259 | function commitExcessActions(n) { 260 | // Auto-commits n-number of excess actions. 261 | let excess = n; 262 | let idsToDelete = stagedActionIds.slice(1, excess + 1); 263 | 264 | for (let i = 0; i < idsToDelete.length; i++) { 265 | if (computedStates[i + 1].error) { 266 | // Stop if error is found. Commit actions up to error. 267 | excess = i; 268 | idsToDelete = stagedActionIds.slice(1, excess + 1); 269 | break; 270 | } else { 271 | delete actionsById[idsToDelete[i]]; 272 | } 273 | } 274 | 275 | skippedActionIds = skippedActionIds.filter(id => idsToDelete.indexOf(id) === -1); 276 | stagedActionIds = [0, ...stagedActionIds.slice(excess + 1)]; 277 | committedState = computedStates[excess].state; 278 | computedStates = computedStates.slice(excess); 279 | currentStateIndex = currentStateIndex > excess 280 | ? currentStateIndex - excess 281 | : 0; 282 | } 283 | 284 | function computePausedAction(shouldInit) { 285 | let computedState; 286 | if (shouldInit) { 287 | computedState = computedStates[currentStateIndex]; 288 | monitorState = monitorReducer(monitorState, liftedAction); 289 | } else { 290 | computedState = computeNextEntry( 291 | reducer, liftedAction.action, computedStates[currentStateIndex].state, false 292 | ); 293 | } 294 | if (!options.pauseActionType || nextActionId === 1) { 295 | return { 296 | monitorState, 297 | actionsById: { 0: liftAction(INIT_ACTION) }, 298 | nextActionId: 1, 299 | stagedActionIds: [0], 300 | skippedActionIds: [], 301 | committedState: computedState.state, 302 | currentStateIndex: 0, 303 | computedStates: [computedState], 304 | isLocked, 305 | isPaused: true 306 | }; 307 | } 308 | if (shouldInit) { 309 | if (currentStateIndex === stagedActionIds.length - 1) { 310 | currentStateIndex++; 311 | } 312 | stagedActionIds = [...stagedActionIds, nextActionId]; 313 | nextActionId++; 314 | } 315 | return { 316 | monitorState, 317 | actionsById: { 318 | ...actionsById, 319 | [nextActionId - 1]: liftAction({ type: options.pauseActionType }) 320 | }, 321 | nextActionId, 322 | stagedActionIds, 323 | skippedActionIds, 324 | committedState, 325 | currentStateIndex, 326 | computedStates: [...computedStates.slice(0, stagedActionIds.length - 1), computedState], 327 | isLocked, 328 | isPaused: true 329 | }; 330 | } 331 | 332 | // By default, agressively recompute every state whatever happens. 333 | // This has O(n) performance, so we'll override this to a sensible 334 | // value whenever we feel like we don't have to recompute the states. 335 | let minInvalidatedStateIndex = 0; 336 | 337 | // maxAge number can be changed dynamically 338 | let maxAge = options.maxAge; 339 | if (typeof maxAge === 'function') maxAge = maxAge(liftedAction, liftedState); 340 | 341 | if (/^@@redux\/(INIT|REPLACE)/.test(liftedAction.type)) { 342 | if (options.shouldHotReload === false) { 343 | actionsById = { 0: liftAction(INIT_ACTION) }; 344 | nextActionId = 1; 345 | stagedActionIds = [0]; 346 | skippedActionIds = []; 347 | committedState = computedStates.length === 0 ? initialCommittedState : 348 | computedStates[currentStateIndex].state; 349 | currentStateIndex = 0; 350 | computedStates = []; 351 | } 352 | 353 | // Recompute states on hot reload and init. 354 | minInvalidatedStateIndex = 0; 355 | 356 | if (maxAge && stagedActionIds.length > maxAge) { 357 | // States must be recomputed before committing excess. 358 | computedStates = recomputeStates( 359 | computedStates, 360 | minInvalidatedStateIndex, 361 | reducer, 362 | committedState, 363 | actionsById, 364 | stagedActionIds, 365 | skippedActionIds, 366 | options.shouldCatchErrors 367 | ); 368 | 369 | commitExcessActions(stagedActionIds.length - maxAge); 370 | 371 | // Avoid double computation. 372 | minInvalidatedStateIndex = Infinity; 373 | } 374 | } else { 375 | switch (liftedAction.type) { 376 | case ActionTypes.PERFORM_ACTION: { 377 | if (isLocked) return liftedState || initialLiftedState; 378 | if (isPaused) return computePausedAction(); 379 | 380 | // Auto-commit as new actions come in. 381 | if (maxAge && stagedActionIds.length >= maxAge) { 382 | commitExcessActions(stagedActionIds.length - maxAge + 1); 383 | } 384 | 385 | if (currentStateIndex === stagedActionIds.length - 1) { 386 | currentStateIndex++; 387 | } 388 | const actionId = nextActionId++; 389 | // Mutation! This is the hottest path, and we optimize on purpose. 390 | // It is safe because we set a new key in a cache dictionary. 391 | actionsById[actionId] = liftedAction; 392 | stagedActionIds = [...stagedActionIds, actionId]; 393 | // Optimization: we know that only the new action needs computing. 394 | minInvalidatedStateIndex = stagedActionIds.length - 1; 395 | break; 396 | } 397 | case ActionTypes.RESET: { 398 | // Get back to the state the store was created with. 399 | actionsById = { 0: liftAction(INIT_ACTION) }; 400 | nextActionId = 1; 401 | stagedActionIds = [0]; 402 | skippedActionIds = []; 403 | committedState = initialCommittedState; 404 | currentStateIndex = 0; 405 | computedStates = []; 406 | break; 407 | } 408 | case ActionTypes.COMMIT: { 409 | // Consider the last committed state the new starting point. 410 | // Squash any staged actions into a single committed state. 411 | actionsById = { 0: liftAction(INIT_ACTION) }; 412 | nextActionId = 1; 413 | stagedActionIds = [0]; 414 | skippedActionIds = []; 415 | committedState = computedStates[currentStateIndex].state; 416 | currentStateIndex = 0; 417 | computedStates = []; 418 | break; 419 | } 420 | case ActionTypes.ROLLBACK: { 421 | // Forget about any staged actions. 422 | // Start again from the last committed state. 423 | actionsById = { 0: liftAction(INIT_ACTION) }; 424 | nextActionId = 1; 425 | stagedActionIds = [0]; 426 | skippedActionIds = []; 427 | currentStateIndex = 0; 428 | computedStates = []; 429 | break; 430 | } 431 | case ActionTypes.TOGGLE_ACTION: { 432 | // Toggle whether an action with given ID is skipped. 433 | // Being skipped means it is a no-op during the computation. 434 | const { id: actionId } = liftedAction; 435 | const index = skippedActionIds.indexOf(actionId); 436 | if (index === -1) { 437 | skippedActionIds = [actionId, ...skippedActionIds]; 438 | } else { 439 | skippedActionIds = skippedActionIds.filter(id => id !== actionId); 440 | } 441 | // Optimization: we know history before this action hasn't changed 442 | minInvalidatedStateIndex = stagedActionIds.indexOf(actionId); 443 | break; 444 | } 445 | case ActionTypes.SET_ACTIONS_ACTIVE: { 446 | // Toggle whether an action with given ID is skipped. 447 | // Being skipped means it is a no-op during the computation. 448 | const { start, end, active } = liftedAction; 449 | const actionIds = []; 450 | for (let i = start; i < end; i++) actionIds.push(i); 451 | if (active) { 452 | skippedActionIds = difference(skippedActionIds, actionIds); 453 | } else { 454 | skippedActionIds = union(skippedActionIds, actionIds); 455 | } 456 | 457 | // Optimization: we know history before this action hasn't changed 458 | minInvalidatedStateIndex = stagedActionIds.indexOf(start); 459 | break; 460 | } 461 | case ActionTypes.JUMP_TO_STATE: { 462 | // Without recomputing anything, move the pointer that tell us 463 | // which state is considered the current one. Useful for sliders. 464 | currentStateIndex = liftedAction.index; 465 | // Optimization: we know the history has not changed. 466 | minInvalidatedStateIndex = Infinity; 467 | break; 468 | } 469 | case ActionTypes.JUMP_TO_ACTION: { 470 | // Jumps to a corresponding state to a specific action. 471 | // Useful when filtering actions. 472 | const index = stagedActionIds.indexOf(liftedAction.actionId); 473 | if (index !== -1) currentStateIndex = index; 474 | minInvalidatedStateIndex = Infinity; 475 | break; 476 | } 477 | case ActionTypes.SWEEP: { 478 | // Forget any actions that are currently being skipped. 479 | stagedActionIds = difference(stagedActionIds, skippedActionIds); 480 | skippedActionIds = []; 481 | currentStateIndex = Math.min(currentStateIndex, stagedActionIds.length - 1); 482 | break; 483 | } 484 | case ActionTypes.REORDER_ACTION: { 485 | // Recompute actions in a new order. 486 | const actionId = liftedAction.actionId; 487 | const idx = stagedActionIds.indexOf(actionId); 488 | // do nothing in case the action is already removed or trying to move the first action 489 | if (idx < 1) break; 490 | const beforeActionId = liftedAction.beforeActionId; 491 | let newIdx = stagedActionIds.indexOf(beforeActionId); 492 | if (newIdx < 1) { // move to the beginning or to the end 493 | const count = stagedActionIds.length; 494 | newIdx = beforeActionId > stagedActionIds[count - 1] ? count : 1; 495 | } 496 | const diff = idx - newIdx; 497 | 498 | if (diff > 0) { // move left 499 | stagedActionIds = [ 500 | ...stagedActionIds.slice(0, newIdx), 501 | actionId, 502 | ...stagedActionIds.slice(newIdx, idx), 503 | ...stagedActionIds.slice(idx + 1) 504 | ]; 505 | minInvalidatedStateIndex = newIdx; 506 | } else if (diff < 0) { // move right 507 | stagedActionIds = [ 508 | ...stagedActionIds.slice(0, idx), 509 | ...stagedActionIds.slice(idx + 1, newIdx), 510 | actionId, 511 | ...stagedActionIds.slice(newIdx) 512 | ]; 513 | minInvalidatedStateIndex = idx; 514 | } 515 | break; 516 | } 517 | case ActionTypes.IMPORT_STATE: { 518 | if (Array.isArray(liftedAction.nextLiftedState)) { 519 | // recompute array of actions 520 | actionsById = { 0: liftAction(INIT_ACTION) }; 521 | nextActionId = 1; 522 | stagedActionIds = [0]; 523 | skippedActionIds = []; 524 | currentStateIndex = liftedAction.nextLiftedState.length; 525 | computedStates = []; 526 | committedState = liftedAction.preloadedState; 527 | minInvalidatedStateIndex = 0; 528 | // iterate through actions 529 | liftedAction.nextLiftedState.forEach(action => { 530 | actionsById[nextActionId] = liftAction(action, options.trace || options.shouldIncludeCallstack); 531 | stagedActionIds.push(nextActionId); 532 | nextActionId++; 533 | }); 534 | } else { 535 | // Completely replace everything. 536 | ({ 537 | monitorState, 538 | actionsById, 539 | nextActionId, 540 | stagedActionIds, 541 | skippedActionIds, 542 | committedState, 543 | currentStateIndex, 544 | computedStates 545 | } = liftedAction.nextLiftedState); 546 | 547 | if (liftedAction.noRecompute) { 548 | minInvalidatedStateIndex = Infinity; 549 | } 550 | } 551 | 552 | break; 553 | } 554 | case ActionTypes.LOCK_CHANGES: { 555 | isLocked = liftedAction.status; 556 | minInvalidatedStateIndex = Infinity; 557 | break; 558 | } 559 | case ActionTypes.PAUSE_RECORDING: { 560 | isPaused = liftedAction.status; 561 | if (isPaused) { 562 | return computePausedAction(true); 563 | } 564 | // Commit when unpausing 565 | actionsById = { 0: liftAction(INIT_ACTION) }; 566 | nextActionId = 1; 567 | stagedActionIds = [0]; 568 | skippedActionIds = []; 569 | committedState = computedStates[currentStateIndex].state; 570 | currentStateIndex = 0; 571 | computedStates = []; 572 | break; 573 | } 574 | default: { 575 | // If the action is not recognized, it's a monitor action. 576 | // Optimization: a monitor action can't change history. 577 | minInvalidatedStateIndex = Infinity; 578 | break; 579 | } 580 | } 581 | } 582 | 583 | computedStates = recomputeStates( 584 | computedStates, 585 | minInvalidatedStateIndex, 586 | reducer, 587 | committedState, 588 | actionsById, 589 | stagedActionIds, 590 | skippedActionIds, 591 | options.shouldCatchErrors 592 | ); 593 | monitorState = monitorReducer(monitorState, liftedAction); 594 | return { 595 | monitorState, 596 | actionsById, 597 | nextActionId, 598 | stagedActionIds, 599 | skippedActionIds, 600 | committedState, 601 | currentStateIndex, 602 | computedStates, 603 | isLocked, 604 | isPaused 605 | }; 606 | }; 607 | } 608 | 609 | /** 610 | * Provides an app's view into the state of the lifted store. 611 | */ 612 | export function unliftState(liftedState) { 613 | const { computedStates, currentStateIndex } = liftedState; 614 | const { state } = computedStates[currentStateIndex]; 615 | return state; 616 | } 617 | 618 | /** 619 | * Provides an app's view into the lifted store. 620 | */ 621 | export function unliftStore(liftedStore, liftReducer, options) { 622 | let lastDefinedState; 623 | const trace = options.trace || options.shouldIncludeCallstack; 624 | const traceLimit = options.traceLimit || 10; 625 | 626 | function getState() { 627 | const state = unliftState(liftedStore.getState()); 628 | if (state !== undefined) { 629 | lastDefinedState = state; 630 | } 631 | return lastDefinedState; 632 | } 633 | 634 | function dispatch(action) { 635 | liftedStore.dispatch(liftAction(action, trace, traceLimit, dispatch)); 636 | return action; 637 | } 638 | 639 | return { 640 | ...liftedStore, 641 | 642 | liftedStore, 643 | 644 | dispatch, 645 | 646 | getState, 647 | 648 | replaceReducer(nextReducer) { 649 | liftedStore.replaceReducer(liftReducer(nextReducer)); 650 | }, 651 | 652 | [$$observable]() { 653 | return { 654 | ...liftedStore[$$observable](), 655 | subscribe(observer) { 656 | if (typeof observer !== 'object') { 657 | throw new TypeError('Expected the observer to be an object.'); 658 | } 659 | 660 | function observeState() { 661 | if (observer.next) { 662 | observer.next(getState()); 663 | } 664 | } 665 | 666 | observeState(); 667 | const unsubscribe = liftedStore.subscribe(observeState); 668 | return { unsubscribe }; 669 | } 670 | }; 671 | } 672 | }; 673 | } 674 | 675 | /** 676 | * Redux instrumentation store enhancer. 677 | */ 678 | export default function instrument(monitorReducer = () => null, options = {}) { 679 | if (typeof options.maxAge === 'number' && options.maxAge < 2) { 680 | throw new Error( 681 | 'DevTools.instrument({ maxAge }) option, if specified, ' + 682 | 'may not be less than 2.' 683 | ); 684 | } 685 | 686 | return createStore => (reducer, initialState, enhancer) => { 687 | 688 | function liftReducer(r) { 689 | if (typeof r !== 'function') { 690 | if (r && typeof r.default === 'function') { 691 | throw new Error( 692 | 'Expected the reducer to be a function. ' + 693 | 'Instead got an object with a "default" field. ' + 694 | 'Did you pass a module instead of the default export? ' + 695 | 'Try passing require(...).default instead.' 696 | ); 697 | } 698 | throw new Error('Expected the reducer to be a function.'); 699 | } 700 | return liftReducerWith(r, initialState, monitorReducer, options); 701 | } 702 | 703 | const liftedStore = createStore(liftReducer(reducer), enhancer); 704 | if (liftedStore.liftedStore) { 705 | throw new Error( 706 | 'DevTools instrumentation should not be applied more than once. ' + 707 | 'Check your store configuration.' 708 | ); 709 | } 710 | 711 | return unliftStore(liftedStore, liftReducer, options); 712 | }; 713 | } 714 | -------------------------------------------------------------------------------- /test/instrument.spec.js: -------------------------------------------------------------------------------- 1 | import expect, { spyOn } from 'expect'; 2 | import { createStore, compose } from 'redux'; 3 | import instrument, { ActionCreators } from '../src/instrument'; 4 | import { Observable } from 'rxjs'; 5 | import _ from 'lodash'; 6 | 7 | import 'rxjs/add/observable/from'; 8 | 9 | function counter(state = 0, action) { 10 | switch (action.type) { 11 | case 'INCREMENT': return state + 1; 12 | case 'DECREMENT': return state - 1; 13 | default: return state; 14 | } 15 | } 16 | 17 | function counterWithBug(state = 0, action) { 18 | switch (action.type) { 19 | case 'INCREMENT': return state + 1; 20 | case 'DECREMENT': return mistake - 1; // eslint-disable-line no-undef 21 | case 'SET_UNDEFINED': return undefined; 22 | default: return state; 23 | } 24 | } 25 | 26 | function counterWithAnotherBug(state = 0, action) { 27 | switch (action.type) { 28 | case 'INCREMENT': return mistake + 1; // eslint-disable-line no-undef 29 | case 'DECREMENT': return state - 1; 30 | case 'SET_UNDEFINED': return undefined; 31 | default: return state; 32 | } 33 | } 34 | 35 | function doubleCounter(state = 0, action) { 36 | switch (action.type) { 37 | case 'INCREMENT': return state + 2; 38 | case 'DECREMENT': return state - 2; 39 | default: return state; 40 | } 41 | } 42 | 43 | function counterWithMultiply(state = 0, action) { 44 | switch (action.type) { 45 | case 'INCREMENT': return state + 1; 46 | case 'DECREMENT': return state - 1; 47 | case 'MULTIPLY': return state * 2; 48 | default: return state; 49 | } 50 | } 51 | 52 | describe('instrument', () => { 53 | let store; 54 | let liftedStore; 55 | 56 | beforeEach(() => { 57 | store = createStore(counter, instrument()); 58 | liftedStore = store.liftedStore; 59 | }); 60 | 61 | it('should perform actions', () => { 62 | expect(store.getState()).toBe(0); 63 | store.dispatch({ type: 'INCREMENT' }); 64 | expect(store.getState()).toBe(1); 65 | store.dispatch({ type: 'INCREMENT' }); 66 | expect(store.getState()).toBe(2); 67 | }); 68 | 69 | it('should provide observable', () => { 70 | let lastValue; 71 | let calls = 0; 72 | 73 | Observable.from(store) 74 | .subscribe(state => { 75 | lastValue = state; 76 | calls++; 77 | }); 78 | 79 | expect(lastValue).toBe(0); 80 | store.dispatch({ type: 'INCREMENT' }); 81 | expect(lastValue).toBe(1); 82 | }); 83 | 84 | it('should rollback state to the last committed state', () => { 85 | store.dispatch({ type: 'INCREMENT' }); 86 | store.dispatch({ type: 'INCREMENT' }); 87 | expect(store.getState()).toBe(2); 88 | 89 | liftedStore.dispatch(ActionCreators.commit()); 90 | expect(store.getState()).toBe(2); 91 | 92 | store.dispatch({ type: 'INCREMENT' }); 93 | store.dispatch({ type: 'INCREMENT' }); 94 | expect(store.getState()).toBe(4); 95 | 96 | liftedStore.dispatch(ActionCreators.rollback()); 97 | expect(store.getState()).toBe(2); 98 | 99 | store.dispatch({ type: 'DECREMENT' }); 100 | expect(store.getState()).toBe(1); 101 | 102 | liftedStore.dispatch(ActionCreators.rollback()); 103 | expect(store.getState()).toBe(2); 104 | }); 105 | 106 | it('should reset to initial state', () => { 107 | store.dispatch({ type: 'INCREMENT' }); 108 | expect(store.getState()).toBe(1); 109 | 110 | liftedStore.dispatch(ActionCreators.commit()); 111 | expect(store.getState()).toBe(1); 112 | 113 | store.dispatch({ type: 'INCREMENT' }); 114 | expect(store.getState()).toBe(2); 115 | 116 | liftedStore.dispatch(ActionCreators.rollback()); 117 | expect(store.getState()).toBe(1); 118 | 119 | store.dispatch({ type: 'INCREMENT' }); 120 | expect(store.getState()).toBe(2); 121 | 122 | liftedStore.dispatch(ActionCreators.reset()); 123 | expect(store.getState()).toBe(0); 124 | }); 125 | 126 | it('should toggle an action', () => { 127 | // actionId 0 = @@INIT 128 | store.dispatch({ type: 'INCREMENT' }); 129 | store.dispatch({ type: 'DECREMENT' }); 130 | store.dispatch({ type: 'INCREMENT' }); 131 | expect(store.getState()).toBe(1); 132 | 133 | liftedStore.dispatch(ActionCreators.toggleAction(2)); 134 | expect(store.getState()).toBe(2); 135 | 136 | liftedStore.dispatch(ActionCreators.toggleAction(2)); 137 | expect(store.getState()).toBe(1); 138 | }); 139 | 140 | it('should set multiple action skip', () => { 141 | // actionId 0 = @@INIT 142 | store.dispatch({ type: 'INCREMENT' }); 143 | store.dispatch({ type: 'INCREMENT' }); 144 | store.dispatch({ type: 'INCREMENT' }); 145 | expect(store.getState()).toBe(3); 146 | 147 | liftedStore.dispatch(ActionCreators.setActionsActive(1, 3, false)); 148 | expect(store.getState()).toBe(1); 149 | 150 | liftedStore.dispatch(ActionCreators.setActionsActive(0, 2, true)); 151 | expect(store.getState()).toBe(2); 152 | 153 | liftedStore.dispatch(ActionCreators.setActionsActive(0, 1, true)); 154 | expect(store.getState()).toBe(2); 155 | }); 156 | 157 | it('should sweep disabled actions', () => { 158 | // actionId 0 = @@INIT 159 | store.dispatch({ type: 'INCREMENT' }); 160 | store.dispatch({ type: 'DECREMENT' }); 161 | store.dispatch({ type: 'INCREMENT' }); 162 | store.dispatch({ type: 'INCREMENT' }); 163 | 164 | expect(store.getState()).toBe(2); 165 | expect(liftedStore.getState().stagedActionIds).toEqual([0, 1, 2, 3, 4]); 166 | expect(liftedStore.getState().skippedActionIds).toEqual([]); 167 | 168 | liftedStore.dispatch(ActionCreators.toggleAction(2)); 169 | expect(store.getState()).toBe(3); 170 | expect(liftedStore.getState().stagedActionIds).toEqual([0, 1, 2, 3, 4]); 171 | expect(liftedStore.getState().skippedActionIds).toEqual([2]); 172 | 173 | liftedStore.dispatch(ActionCreators.sweep()); 174 | expect(store.getState()).toBe(3); 175 | expect(liftedStore.getState().stagedActionIds).toEqual([0, 1, 3, 4]); 176 | expect(liftedStore.getState().skippedActionIds).toEqual([]); 177 | }); 178 | 179 | it('should jump to state', () => { 180 | store.dispatch({ type: 'INCREMENT' }); 181 | store.dispatch({ type: 'DECREMENT' }); 182 | store.dispatch({ type: 'INCREMENT' }); 183 | expect(store.getState()).toBe(1); 184 | 185 | liftedStore.dispatch(ActionCreators.jumpToState(0)); 186 | expect(store.getState()).toBe(0); 187 | 188 | liftedStore.dispatch(ActionCreators.jumpToState(1)); 189 | expect(store.getState()).toBe(1); 190 | 191 | liftedStore.dispatch(ActionCreators.jumpToState(2)); 192 | expect(store.getState()).toBe(0); 193 | 194 | store.dispatch({ type: 'INCREMENT' }); 195 | expect(store.getState()).toBe(0); 196 | 197 | liftedStore.dispatch(ActionCreators.jumpToState(4)); 198 | expect(store.getState()).toBe(2); 199 | }); 200 | 201 | it('should jump to action', () => { 202 | store.dispatch({ type: 'INCREMENT' }); 203 | store.dispatch({ type: 'DECREMENT' }); 204 | store.dispatch({ type: 'INCREMENT' }); 205 | expect(store.getState()).toBe(1); 206 | 207 | liftedStore.dispatch(ActionCreators.jumpToAction(0)); 208 | expect(store.getState()).toBe(0); 209 | 210 | liftedStore.dispatch(ActionCreators.jumpToAction(1)); 211 | expect(store.getState()).toBe(1); 212 | 213 | liftedStore.dispatch(ActionCreators.jumpToAction(10)); 214 | expect(store.getState()).toBe(1); 215 | }); 216 | 217 | it('should reorder actions', () => { 218 | store = createStore(counterWithMultiply, instrument()); 219 | store.dispatch({ type: 'INCREMENT' }); 220 | store.dispatch({ type: 'DECREMENT' }); 221 | store.dispatch({ type: 'INCREMENT' }); 222 | store.dispatch({ type: 'MULTIPLY' }); 223 | expect(store.liftedStore.getState().stagedActionIds).toEqual([0, 1, 2, 3, 4]); 224 | expect(store.getState()).toBe(2); 225 | 226 | store.liftedStore.dispatch(ActionCreators.reorderAction(4, 1)); 227 | expect(store.liftedStore.getState().stagedActionIds).toEqual([0, 4, 1, 2, 3]); 228 | expect(store.getState()).toBe(1); 229 | 230 | store.liftedStore.dispatch(ActionCreators.reorderAction(4, 1)); 231 | expect(store.liftedStore.getState().stagedActionIds).toEqual([0, 4, 1, 2, 3]); 232 | expect(store.getState()).toBe(1); 233 | 234 | store.liftedStore.dispatch(ActionCreators.reorderAction(4, 2)); 235 | expect(store.liftedStore.getState().stagedActionIds).toEqual([0, 1, 4, 2, 3]); 236 | expect(store.getState()).toBe(2); 237 | 238 | store.liftedStore.dispatch(ActionCreators.reorderAction(1, 10)); 239 | expect(store.liftedStore.getState().stagedActionIds).toEqual([0, 4, 2, 3, 1]); 240 | expect(store.getState()).toBe(1); 241 | 242 | store.liftedStore.dispatch(ActionCreators.reorderAction(10, 1)); 243 | expect(store.liftedStore.getState().stagedActionIds).toEqual([0, 4, 2, 3, 1]); 244 | expect(store.getState()).toBe(1); 245 | 246 | store.liftedStore.dispatch(ActionCreators.reorderAction(1, -2)); 247 | expect(store.liftedStore.getState().stagedActionIds).toEqual([0, 1, 4, 2, 3]); 248 | expect(store.getState()).toBe(2); 249 | 250 | store.liftedStore.dispatch(ActionCreators.reorderAction(0, 1)); 251 | expect(store.liftedStore.getState().stagedActionIds).toEqual([0, 1, 4, 2, 3]); 252 | expect(store.getState()).toBe(2); 253 | }); 254 | 255 | it('should replace the reducer', () => { 256 | store.dispatch({ type: 'INCREMENT' }); 257 | store.dispatch({ type: 'DECREMENT' }); 258 | store.dispatch({ type: 'INCREMENT' }); 259 | expect(store.getState()).toBe(1); 260 | 261 | store.replaceReducer(doubleCounter); 262 | expect(store.getState()).toBe(2); 263 | }); 264 | 265 | it('should replace the reducer without recomputing actions', () => { 266 | store = createStore(counter, instrument(undefined, { shouldHotReload: false })); 267 | expect(store.getState()).toBe(0); 268 | store.dispatch({ type: 'INCREMENT' }); 269 | store.dispatch({ type: 'DECREMENT' }); 270 | store.dispatch({ type: 'INCREMENT' }); 271 | expect(store.getState()).toBe(1); 272 | 273 | store.replaceReducer(doubleCounter); 274 | expect(store.getState()).toBe(1); 275 | store.dispatch({ type: 'INCREMENT' }); 276 | expect(store.getState()).toBe(3); 277 | 278 | store.replaceReducer(() => ({ test: true })); 279 | expect(store.getState()).toEqual({ test: true }); 280 | }); 281 | 282 | it('should catch and record errors', () => { 283 | let spy = spyOn(console, 'error'); 284 | let storeWithBug = createStore( 285 | counterWithBug, 286 | instrument(undefined, { shouldCatchErrors: true }) 287 | ); 288 | 289 | storeWithBug.dispatch({ type: 'INCREMENT' }); 290 | storeWithBug.dispatch({ type: 'DECREMENT' }); 291 | storeWithBug.dispatch({ type: 'INCREMENT' }); 292 | 293 | let { computedStates } = storeWithBug.liftedStore.getState(); 294 | expect(computedStates[2].error).toMatch( 295 | /ReferenceError/ 296 | ); 297 | expect(computedStates[3].error).toMatch( 298 | /Interrupted by an error up the chain/ 299 | ); 300 | expect(spy.calls[0].arguments[0].toString()).toMatch( 301 | /ReferenceError/ 302 | ); 303 | 304 | spy.restore(); 305 | }); 306 | 307 | it('should catch invalid action type', () => { 308 | expect(() => { 309 | store.dispatch({ type: undefined }); 310 | }).toThrow( 311 | 'Actions may not have an undefined "type" property. ' + 312 | 'Have you misspelled a constant?' 313 | ); 314 | }); 315 | 316 | it('should catch invalid action type', () => { 317 | function ActionClass() { 318 | this.type = 'test'; 319 | } 320 | 321 | expect(() => { 322 | store.dispatch(new ActionClass()); 323 | }).toThrow( 324 | 'Actions must be plain objects. ' + 325 | 'Use custom middleware for async actions.' 326 | ); 327 | }); 328 | 329 | it('should return the last non-undefined state from getState', () => { 330 | let storeWithBug = createStore(counterWithBug, instrument()); 331 | storeWithBug.dispatch({ type: 'INCREMENT' }); 332 | storeWithBug.dispatch({ type: 'INCREMENT' }); 333 | expect(storeWithBug.getState()).toBe(2); 334 | 335 | storeWithBug.dispatch({ type: 'SET_UNDEFINED' }); 336 | expect(storeWithBug.getState()).toBe(2); 337 | }); 338 | 339 | it('should not recompute states on every action', () => { 340 | let reducerCalls = 0; 341 | let monitoredStore = createStore(() => reducerCalls++, instrument()); 342 | expect(reducerCalls).toBe(1); 343 | monitoredStore.dispatch({ type: 'INCREMENT' }); 344 | monitoredStore.dispatch({ type: 'INCREMENT' }); 345 | monitoredStore.dispatch({ type: 'INCREMENT' }); 346 | expect(reducerCalls).toBe(4); 347 | }); 348 | 349 | it('should not recompute old states when toggling an action', () => { 350 | let reducerCalls = 0; 351 | let monitoredStore = createStore(() => reducerCalls++, instrument()); 352 | let monitoredLiftedStore = monitoredStore.liftedStore; 353 | 354 | expect(reducerCalls).toBe(1); 355 | // actionId 0 = @@INIT 356 | monitoredStore.dispatch({ type: 'INCREMENT' }); 357 | monitoredStore.dispatch({ type: 'INCREMENT' }); 358 | monitoredStore.dispatch({ type: 'INCREMENT' }); 359 | expect(reducerCalls).toBe(4); 360 | 361 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(3)); 362 | expect(reducerCalls).toBe(4); 363 | 364 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(3)); 365 | expect(reducerCalls).toBe(5); 366 | 367 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(2)); 368 | expect(reducerCalls).toBe(6); 369 | 370 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(2)); 371 | expect(reducerCalls).toBe(8); 372 | 373 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(1)); 374 | expect(reducerCalls).toBe(10); 375 | 376 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(2)); 377 | expect(reducerCalls).toBe(11); 378 | 379 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(3)); 380 | expect(reducerCalls).toBe(11); 381 | 382 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(1)); 383 | expect(reducerCalls).toBe(12); 384 | 385 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(3)); 386 | expect(reducerCalls).toBe(13); 387 | 388 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(2)); 389 | expect(reducerCalls).toBe(15); 390 | }); 391 | 392 | it('should not recompute states when jumping to state', () => { 393 | let reducerCalls = 0; 394 | let monitoredStore = createStore(() => reducerCalls++, instrument()); 395 | let monitoredLiftedStore = monitoredStore.liftedStore; 396 | 397 | expect(reducerCalls).toBe(1); 398 | monitoredStore.dispatch({ type: 'INCREMENT' }); 399 | monitoredStore.dispatch({ type: 'INCREMENT' }); 400 | monitoredStore.dispatch({ type: 'INCREMENT' }); 401 | expect(reducerCalls).toBe(4); 402 | 403 | let savedComputedStates = monitoredLiftedStore.getState().computedStates; 404 | 405 | monitoredLiftedStore.dispatch(ActionCreators.jumpToState(0)); 406 | expect(reducerCalls).toBe(4); 407 | 408 | monitoredLiftedStore.dispatch(ActionCreators.jumpToState(1)); 409 | expect(reducerCalls).toBe(4); 410 | 411 | monitoredLiftedStore.dispatch(ActionCreators.jumpToState(3)); 412 | expect(reducerCalls).toBe(4); 413 | 414 | expect(monitoredLiftedStore.getState().computedStates).toBe(savedComputedStates); 415 | }); 416 | 417 | it('should not recompute states on monitor actions', () => { 418 | let reducerCalls = 0; 419 | let monitoredStore = createStore(() => reducerCalls++, instrument()); 420 | let monitoredLiftedStore = monitoredStore.liftedStore; 421 | 422 | expect(reducerCalls).toBe(1); 423 | monitoredStore.dispatch({ type: 'INCREMENT' }); 424 | monitoredStore.dispatch({ type: 'INCREMENT' }); 425 | monitoredStore.dispatch({ type: 'INCREMENT' }); 426 | expect(reducerCalls).toBe(4); 427 | 428 | let savedComputedStates = monitoredLiftedStore.getState().computedStates; 429 | 430 | monitoredLiftedStore.dispatch({ type: 'lol' }); 431 | expect(reducerCalls).toBe(4); 432 | 433 | monitoredLiftedStore.dispatch({ type: 'wat' }); 434 | expect(reducerCalls).toBe(4); 435 | 436 | expect(monitoredLiftedStore.getState().computedStates).toBe(savedComputedStates); 437 | }); 438 | 439 | describe('maxAge option', () => { 440 | let configuredStore; 441 | let configuredLiftedStore; 442 | 443 | beforeEach(() => { 444 | configuredStore = createStore(counter, instrument(undefined, { maxAge: 3 })); 445 | configuredLiftedStore = configuredStore.liftedStore; 446 | }); 447 | 448 | it('should auto-commit earliest non-@@INIT action when maxAge is reached', () => { 449 | configuredStore.dispatch({ type: 'INCREMENT' }); 450 | configuredStore.dispatch({ type: 'INCREMENT' }); 451 | let liftedStoreState = configuredLiftedStore.getState(); 452 | 453 | expect(configuredStore.getState()).toBe(2); 454 | expect(Object.keys(liftedStoreState.actionsById).length).toBe(3); 455 | expect(liftedStoreState.committedState).toBe(undefined); 456 | expect(liftedStoreState.stagedActionIds).toInclude(1); 457 | 458 | // Trigger auto-commit. 459 | configuredStore.dispatch({ type: 'INCREMENT' }); 460 | liftedStoreState = configuredLiftedStore.getState(); 461 | 462 | expect(configuredStore.getState()).toBe(3); 463 | expect(Object.keys(liftedStoreState.actionsById).length).toBe(3); 464 | expect(liftedStoreState.stagedActionIds).toExclude(1); 465 | expect(liftedStoreState.computedStates[0].state).toBe(1); 466 | expect(liftedStoreState.committedState).toBe(1); 467 | expect(liftedStoreState.currentStateIndex).toBe(2); 468 | }); 469 | 470 | it('should remove skipped actions once committed', () => { 471 | configuredStore.dispatch({ type: 'INCREMENT' }); 472 | configuredLiftedStore.dispatch(ActionCreators.toggleAction(1)); 473 | configuredStore.dispatch({ type: 'INCREMENT' }); 474 | expect(configuredLiftedStore.getState().skippedActionIds).toInclude(1); 475 | configuredStore.dispatch({ type: 'INCREMENT' }); 476 | expect(configuredLiftedStore.getState().skippedActionIds).toExclude(1); 477 | }); 478 | 479 | it('should not auto-commit errors', () => { 480 | let spy = spyOn(console, 'error'); 481 | 482 | let storeWithBug = createStore( 483 | counterWithBug, 484 | instrument(undefined, { maxAge: 3, shouldCatchErrors: true }) 485 | ); 486 | let liftedStoreWithBug = storeWithBug.liftedStore; 487 | storeWithBug.dispatch({ type: 'DECREMENT' }); 488 | storeWithBug.dispatch({ type: 'INCREMENT' }); 489 | expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(3); 490 | 491 | storeWithBug.dispatch({ type: 'INCREMENT' }); 492 | expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(4); 493 | 494 | spy.restore(); 495 | }); 496 | 497 | it('should auto-commit actions after hot reload fixes error', () => { 498 | let spy = spyOn(console, 'error'); 499 | 500 | let storeWithBug = createStore( 501 | counterWithBug, 502 | instrument(undefined, { maxAge: 3, shouldCatchErrors: true }) 503 | ); 504 | let liftedStoreWithBug = storeWithBug.liftedStore; 505 | storeWithBug.dispatch({ type: 'DECREMENT' }); 506 | storeWithBug.dispatch({ type: 'DECREMENT' }); 507 | storeWithBug.dispatch({ type: 'INCREMENT' }); 508 | storeWithBug.dispatch({ type: 'DECREMENT' }); 509 | storeWithBug.dispatch({ type: 'DECREMENT' }); 510 | storeWithBug.dispatch({ type: 'DECREMENT' }); 511 | expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(7); 512 | 513 | // Auto-commit 2 actions by "fixing" reducer bug, but introducing another. 514 | storeWithBug.replaceReducer(counterWithAnotherBug); 515 | expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(5); 516 | 517 | // Auto-commit 2 more actions by "fixing" other reducer bug. 518 | storeWithBug.replaceReducer(counter); 519 | expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(3); 520 | 521 | spy.restore(); 522 | }); 523 | 524 | it('should update currentStateIndex when auto-committing', () => { 525 | let liftedStoreState; 526 | let currentComputedState; 527 | 528 | configuredStore.dispatch({ type: 'INCREMENT' }); 529 | configuredStore.dispatch({ type: 'INCREMENT' }); 530 | liftedStoreState = configuredLiftedStore.getState(); 531 | expect(liftedStoreState.currentStateIndex).toBe(2); 532 | 533 | // currentStateIndex should stay at 2 as actions are committed. 534 | configuredStore.dispatch({ type: 'INCREMENT' }); 535 | liftedStoreState = configuredLiftedStore.getState(); 536 | currentComputedState = liftedStoreState.computedStates[liftedStoreState.currentStateIndex]; 537 | expect(liftedStoreState.currentStateIndex).toBe(2); 538 | expect(currentComputedState.state).toBe(3); 539 | }); 540 | 541 | it('should continue to increment currentStateIndex while error blocks commit', () => { 542 | let spy = spyOn(console, 'error'); 543 | 544 | let storeWithBug = createStore( 545 | counterWithBug, 546 | instrument(undefined, { maxAge: 3, shouldCatchErrors: true }) 547 | ); 548 | let liftedStoreWithBug = storeWithBug.liftedStore; 549 | 550 | storeWithBug.dispatch({ type: 'DECREMENT' }); 551 | storeWithBug.dispatch({ type: 'DECREMENT' }); 552 | storeWithBug.dispatch({ type: 'DECREMENT' }); 553 | storeWithBug.dispatch({ type: 'DECREMENT' }); 554 | 555 | let liftedStoreState = liftedStoreWithBug.getState(); 556 | let currentComputedState = liftedStoreState.computedStates[liftedStoreState.currentStateIndex]; 557 | expect(liftedStoreState.currentStateIndex).toBe(4); 558 | expect(currentComputedState.state).toBe(0); 559 | expect(currentComputedState.error).toExist(); 560 | 561 | spy.restore(); 562 | }); 563 | 564 | it('should adjust currentStateIndex correctly when multiple actions are committed', () => { 565 | let spy = spyOn(console, 'error'); 566 | 567 | let storeWithBug = createStore( 568 | counterWithBug, 569 | instrument(undefined, { maxAge: 3, shouldCatchErrors: true }) 570 | ); 571 | let liftedStoreWithBug = storeWithBug.liftedStore; 572 | 573 | storeWithBug.dispatch({ type: 'DECREMENT' }); 574 | storeWithBug.dispatch({ type: 'DECREMENT' }); 575 | storeWithBug.dispatch({ type: 'DECREMENT' }); 576 | storeWithBug.dispatch({ type: 'DECREMENT' }); 577 | 578 | // Auto-commit 2 actions by "fixing" reducer bug. 579 | storeWithBug.replaceReducer(counter); 580 | let liftedStoreState = liftedStoreWithBug.getState(); 581 | let currentComputedState = liftedStoreState.computedStates[liftedStoreState.currentStateIndex]; 582 | expect(liftedStoreState.currentStateIndex).toBe(2); 583 | expect(currentComputedState.state).toBe(-4); 584 | 585 | spy.restore(); 586 | }); 587 | 588 | it('should not allow currentStateIndex to drop below 0', () => { 589 | let spy = spyOn(console, 'error'); 590 | 591 | let storeWithBug = createStore( 592 | counterWithBug, 593 | instrument(undefined, { maxAge: 3, shouldCatchErrors: true }) 594 | ); 595 | let liftedStoreWithBug = storeWithBug.liftedStore; 596 | 597 | storeWithBug.dispatch({ type: 'DECREMENT' }); 598 | storeWithBug.dispatch({ type: 'DECREMENT' }); 599 | storeWithBug.dispatch({ type: 'DECREMENT' }); 600 | storeWithBug.dispatch({ type: 'DECREMENT' }); 601 | liftedStoreWithBug.dispatch(ActionCreators.jumpToState(1)); 602 | 603 | // Auto-commit 2 actions by "fixing" reducer bug. 604 | storeWithBug.replaceReducer(counter); 605 | let liftedStoreState = liftedStoreWithBug.getState(); 606 | let currentComputedState = liftedStoreState.computedStates[liftedStoreState.currentStateIndex]; 607 | expect(liftedStoreState.currentStateIndex).toBe(0); 608 | expect(currentComputedState.state).toBe(-2); 609 | 610 | spy.restore(); 611 | }); 612 | 613 | it('should use dynamic maxAge', () => { 614 | let max = 3; 615 | const getMaxAge = expect.createSpy().andCall(() => max); 616 | store = createStore(counter, instrument(undefined, { maxAge: getMaxAge })); 617 | 618 | expect(getMaxAge.calls.length).toEqual(1); 619 | store.dispatch({ type: 'INCREMENT' }); 620 | expect(getMaxAge.calls.length).toEqual(2); 621 | store.dispatch({ type: 'INCREMENT' }); 622 | expect(getMaxAge.calls.length).toEqual(3); 623 | let liftedStoreState = store.liftedStore.getState(); 624 | 625 | expect(getMaxAge.calls[0].arguments[0].type).toInclude('INIT'); 626 | expect(getMaxAge.calls[0].arguments[1]).toBe(undefined); 627 | expect(getMaxAge.calls[1].arguments[0].type).toBe('PERFORM_ACTION'); 628 | expect(getMaxAge.calls[1].arguments[1].nextActionId).toBe(1); 629 | expect(getMaxAge.calls[1].arguments[1].stagedActionIds).toEqual([0]); 630 | expect(getMaxAge.calls[2].arguments[1].nextActionId).toBe(2); 631 | expect(getMaxAge.calls[2].arguments[1].stagedActionIds).toEqual([0, 1]); 632 | 633 | expect(store.getState()).toBe(2); 634 | expect(Object.keys(liftedStoreState.actionsById).length).toBe(3); 635 | expect(liftedStoreState.committedState).toBe(undefined); 636 | expect(liftedStoreState.stagedActionIds).toInclude(1); 637 | 638 | // Trigger auto-commit. 639 | store.dispatch({ type: 'INCREMENT' }); 640 | liftedStoreState = store.liftedStore.getState(); 641 | 642 | expect(store.getState()).toBe(3); 643 | expect(Object.keys(liftedStoreState.actionsById).length).toBe(3); 644 | expect(liftedStoreState.stagedActionIds).toExclude(1); 645 | expect(liftedStoreState.computedStates[0].state).toBe(1); 646 | expect(liftedStoreState.committedState).toBe(1); 647 | expect(liftedStoreState.currentStateIndex).toBe(2); 648 | 649 | max = 4; 650 | store.dispatch({ type: 'INCREMENT' }); 651 | liftedStoreState = store.liftedStore.getState(); 652 | 653 | expect(store.getState()).toBe(4); 654 | expect(Object.keys(liftedStoreState.actionsById).length).toBe(4); 655 | expect(liftedStoreState.stagedActionIds).toExclude(1); 656 | expect(liftedStoreState.computedStates[0].state).toBe(1); 657 | expect(liftedStoreState.committedState).toBe(1); 658 | expect(liftedStoreState.currentStateIndex).toBe(3); 659 | 660 | max = 3; 661 | store.dispatch({ type: 'INCREMENT' }); 662 | liftedStoreState = store.liftedStore.getState(); 663 | 664 | expect(store.getState()).toBe(5); 665 | expect(Object.keys(liftedStoreState.actionsById).length).toBe(3); 666 | expect(liftedStoreState.stagedActionIds).toExclude(1); 667 | expect(liftedStoreState.computedStates[0].state).toBe(3); 668 | expect(liftedStoreState.committedState).toBe(3); 669 | expect(liftedStoreState.currentStateIndex).toBe(2); 670 | 671 | store.dispatch({ type: 'INCREMENT' }); 672 | liftedStoreState = store.liftedStore.getState(); 673 | 674 | expect(store.getState()).toBe(6); 675 | expect(Object.keys(liftedStoreState.actionsById).length).toBe(3); 676 | expect(liftedStoreState.stagedActionIds).toExclude(1); 677 | expect(liftedStoreState.computedStates[0].state).toBe(4); 678 | expect(liftedStoreState.committedState).toBe(4); 679 | expect(liftedStoreState.currentStateIndex).toBe(2); 680 | }); 681 | 682 | it('should throw error when maxAge < 2', () => { 683 | expect(() => { 684 | createStore(counter, instrument(undefined, { maxAge: 1 })); 685 | }).toThrow(/may not be less than 2/); 686 | }); 687 | }); 688 | 689 | describe('trace option', () => { 690 | let monitoredStore; 691 | let monitoredLiftedStore; 692 | let exportedState; 693 | 694 | it('should not include stack trace', () => { 695 | monitoredStore = createStore(counter, instrument()); 696 | monitoredLiftedStore = monitoredStore.liftedStore; 697 | monitoredStore.dispatch({ type: 'INCREMENT' }); 698 | 699 | exportedState = monitoredLiftedStore.getState(); 700 | expect(exportedState.actionsById[0].stack).toBe(undefined); 701 | expect(exportedState.actionsById[1].stack).toBe(undefined); 702 | }); 703 | 704 | it('should include stack trace', () => { 705 | monitoredStore = createStore(counter, instrument(undefined, { trace: true })); 706 | monitoredLiftedStore = monitoredStore.liftedStore; 707 | monitoredStore.dispatch({ type: 'INCREMENT' }); 708 | 709 | exportedState = monitoredLiftedStore.getState(); 710 | expect(exportedState.actionsById[0].stack).toBe(undefined); 711 | expect(exportedState.actionsById[1].stack).toBeA('string'); 712 | expect(exportedState.actionsById[1].stack).toMatch(/^Error/); 713 | expect(exportedState.actionsById[1].stack).toNotMatch(/instrument.js/); 714 | expect(exportedState.actionsById[1].stack).toContain('instrument.spec.js'); 715 | expect(exportedState.actionsById[1].stack).toContain('/mocha/'); 716 | expect(exportedState.actionsById[1].stack.split('\n').length).toBe(10 + 1); // +1 is for `Error\n` 717 | }); 718 | 719 | it('should include only 3 frames for stack trace', () => { 720 | function fn1() { 721 | monitoredStore = createStore(counter, instrument(undefined, { trace: true, traceLimit: 3 })); 722 | monitoredLiftedStore = monitoredStore.liftedStore; 723 | monitoredStore.dispatch({ type: 'INCREMENT' }); 724 | 725 | exportedState = monitoredLiftedStore.getState(); 726 | expect(exportedState.actionsById[0].stack).toBe(undefined); 727 | expect(exportedState.actionsById[1].stack).toBeA('string'); 728 | expect(exportedState.actionsById[1].stack).toMatch(/at fn1 /); 729 | expect(exportedState.actionsById[1].stack).toMatch(/at fn2 /); 730 | expect(exportedState.actionsById[1].stack).toMatch(/at fn3 /); 731 | expect(exportedState.actionsById[1].stack).toNotMatch(/at fn4 /); 732 | expect(exportedState.actionsById[1].stack).toContain('instrument.spec.js'); 733 | expect(exportedState.actionsById[1].stack.split('\n').length).toBe(3 + 1); 734 | } 735 | function fn2() { return fn1(); } 736 | function fn3() { return fn2(); } 737 | function fn4() { return fn3(); } 738 | fn4(); 739 | }); 740 | 741 | it('should force traceLimit value of 3 when Error.stackTraceLimit is 10', () => { 742 | const stackTraceLimit = Error.stackTraceLimit; 743 | Error.stackTraceLimit = 10; 744 | function fn1() { 745 | monitoredStore = createStore(counter, instrument(undefined, { trace: true, traceLimit: 3 })); 746 | monitoredLiftedStore = monitoredStore.liftedStore; 747 | monitoredStore.dispatch({ type: 'INCREMENT' }); 748 | 749 | exportedState = monitoredLiftedStore.getState(); 750 | expect(exportedState.actionsById[0].stack).toBe(undefined); 751 | expect(exportedState.actionsById[1].stack).toBeA('string'); 752 | expect(exportedState.actionsById[1].stack).toMatch(/at fn1 /); 753 | expect(exportedState.actionsById[1].stack).toMatch(/at fn2 /); 754 | expect(exportedState.actionsById[1].stack).toMatch(/at fn3 /); 755 | expect(exportedState.actionsById[1].stack).toNotMatch(/at fn4 /); 756 | expect(exportedState.actionsById[1].stack).toContain('instrument.spec.js'); 757 | expect(exportedState.actionsById[1].stack.split('\n').length).toBe(3 + 1); 758 | } 759 | function fn2() { return fn1(); } 760 | function fn3() { return fn2(); } 761 | function fn4() { return fn3(); } 762 | fn4(); 763 | Error.stackTraceLimit = stackTraceLimit; 764 | }); 765 | 766 | it('should force traceLimit value of 5 even when Error.stackTraceLimit is 2', () => { 767 | const stackTraceLimit = Error.stackTraceLimit; 768 | Error.stackTraceLimit = 2; 769 | monitoredStore = createStore(counter, instrument(undefined, { trace: true, traceLimit: 5 })); 770 | monitoredLiftedStore = monitoredStore.liftedStore; 771 | monitoredStore.dispatch({ type: 'INCREMENT' }); 772 | Error.stackTraceLimit = stackTraceLimit; 773 | 774 | exportedState = monitoredLiftedStore.getState(); 775 | expect(exportedState.actionsById[0].stack).toBe(undefined); 776 | expect(exportedState.actionsById[1].stack).toBeA('string'); 777 | expect(exportedState.actionsById[1].stack).toMatch(/^Error/); 778 | expect(exportedState.actionsById[1].stack).toContain('instrument.spec.js'); 779 | expect(exportedState.actionsById[1].stack).toContain('/mocha/'); 780 | expect(exportedState.actionsById[1].stack.split('\n').length).toBe(5 + 1); 781 | }); 782 | 783 | it('should force default limit of 10 even when Error.stackTraceLimit is 3', () => { 784 | const stackTraceLimit = Error.stackTraceLimit; 785 | Error.stackTraceLimit = 3; 786 | function fn1() { 787 | monitoredStore = createStore(counter, instrument(undefined, { trace: true })); 788 | monitoredLiftedStore = monitoredStore.liftedStore; 789 | monitoredStore.dispatch({ type: 'INCREMENT' }); 790 | Error.stackTraceLimit = stackTraceLimit; 791 | 792 | exportedState = monitoredLiftedStore.getState(); 793 | expect(exportedState.actionsById[0].stack).toBe(undefined); 794 | expect(exportedState.actionsById[1].stack).toBeA('string'); 795 | expect(exportedState.actionsById[1].stack).toMatch(/at fn1 /); 796 | expect(exportedState.actionsById[1].stack).toMatch(/at fn2 /); 797 | expect(exportedState.actionsById[1].stack).toMatch(/at fn3 /); 798 | expect(exportedState.actionsById[1].stack).toMatch(/at fn4 /); 799 | expect(exportedState.actionsById[1].stack).toContain('instrument.spec.js'); 800 | expect(exportedState.actionsById[1].stack.split('\n').length).toBe(10 + 1); 801 | } 802 | function fn2() { return fn1(); } 803 | function fn3() { return fn2(); } 804 | function fn4() { return fn3(); } 805 | fn4(); 806 | }); 807 | 808 | it('should include 3 extra frames when Error.captureStackTrace not suported', () => { 809 | const captureStackTrace = Error.captureStackTrace; 810 | Error.captureStackTrace = undefined; 811 | monitoredStore = createStore(counter, instrument(undefined, { trace: true, traceLimit: 5 })); 812 | monitoredLiftedStore = monitoredStore.liftedStore; 813 | monitoredStore.dispatch({ type: 'INCREMENT' }); 814 | Error.captureStackTrace = captureStackTrace; 815 | 816 | exportedState = monitoredLiftedStore.getState(); 817 | expect(exportedState.actionsById[0].stack).toBe(undefined); 818 | expect(exportedState.actionsById[1].stack).toBeA('string'); 819 | expect(exportedState.actionsById[1].stack).toMatch(/^Error/); 820 | expect(exportedState.actionsById[1].stack).toContain('instrument.js'); 821 | expect(exportedState.actionsById[1].stack).toContain('instrument.spec.js'); 822 | expect(exportedState.actionsById[1].stack).toContain('/mocha/'); 823 | expect(exportedState.actionsById[1].stack.split('\n').length).toBe(5 + 3 + 1); 824 | }); 825 | 826 | it('should get stack trace from a function', () => { 827 | const traceFn = () => new Error().stack; 828 | monitoredStore = createStore(counter, instrument(undefined, { trace: traceFn })); 829 | monitoredLiftedStore = monitoredStore.liftedStore; 830 | monitoredStore.dispatch({ type: 'INCREMENT' }); 831 | 832 | exportedState = monitoredLiftedStore.getState(); 833 | expect(exportedState.actionsById[0].stack).toBe(undefined); 834 | expect(exportedState.actionsById[1].stack).toBeA('string'); 835 | expect(exportedState.actionsById[1].stack).toContain('at Object.performAction'); 836 | expect(exportedState.actionsById[1].stack).toContain('instrument.js'); 837 | expect(exportedState.actionsById[1].stack).toContain('instrument.spec.js'); 838 | expect(exportedState.actionsById[1].stack).toContain('/mocha/'); 839 | }); 840 | 841 | it('should get stack trace inside setTimeout using a function', (done) => { 842 | const stack = new Error().stack; 843 | setTimeout(() => { 844 | const traceFn = () => stack + new Error().stack; 845 | monitoredStore = createStore(counter, instrument(undefined, { trace: traceFn })); 846 | monitoredLiftedStore = monitoredStore.liftedStore; 847 | monitoredStore.dispatch({ type: 'INCREMENT' }); 848 | 849 | exportedState = monitoredLiftedStore.getState(); 850 | expect(exportedState.actionsById[0].stack).toBe(undefined); 851 | expect(exportedState.actionsById[1].stack).toBeA('string'); 852 | expect(exportedState.actionsById[1].stack).toContain('at Object.performAction'); 853 | expect(exportedState.actionsById[1].stack).toContain('instrument.js'); 854 | expect(exportedState.actionsById[1].stack).toContain('instrument.spec.js'); 855 | expect(exportedState.actionsById[1].stack).toContain('/mocha/'); 856 | done(); 857 | }); 858 | }); 859 | }); 860 | 861 | describe('Import State', () => { 862 | let monitoredStore; 863 | let monitoredLiftedStore; 864 | let exportedState; 865 | 866 | beforeEach(() => { 867 | monitoredStore = createStore(counter, instrument()); 868 | monitoredLiftedStore = monitoredStore.liftedStore; 869 | // Set up state to export 870 | monitoredStore.dispatch({ type: 'INCREMENT' }); 871 | monitoredStore.dispatch({ type: 'INCREMENT' }); 872 | monitoredStore.dispatch({ type: 'INCREMENT' }); 873 | 874 | exportedState = monitoredLiftedStore.getState(); 875 | }); 876 | 877 | it('should replay all the steps when a state is imported', () => { 878 | let importMonitoredStore = createStore(counter, instrument()); 879 | let importMonitoredLiftedStore = importMonitoredStore.liftedStore; 880 | 881 | importMonitoredLiftedStore.dispatch(ActionCreators.importState(exportedState)); 882 | expect(importMonitoredLiftedStore.getState()).toEqual(exportedState); 883 | }); 884 | 885 | it('should replace the existing action log with the one imported', () => { 886 | let importMonitoredStore = createStore(counter, instrument()); 887 | let importMonitoredLiftedStore = importMonitoredStore.liftedStore; 888 | 889 | importMonitoredStore.dispatch({ type: 'DECREMENT' }); 890 | importMonitoredStore.dispatch({ type: 'DECREMENT' }); 891 | 892 | importMonitoredLiftedStore.dispatch(ActionCreators.importState(exportedState)); 893 | expect(importMonitoredLiftedStore.getState()).toEqual(exportedState); 894 | }); 895 | 896 | it('should allow for state to be imported without replaying actions', () => { 897 | let importMonitoredStore = createStore(counter, instrument()); 898 | let importMonitoredLiftedStore = importMonitoredStore.liftedStore; 899 | 900 | let noComputedExportedState = Object.assign({}, exportedState); 901 | delete noComputedExportedState.computedStates; 902 | 903 | importMonitoredLiftedStore.dispatch(ActionCreators.importState(noComputedExportedState, true)); 904 | 905 | let expectedImportedState = Object.assign({}, noComputedExportedState, { 906 | computedStates: undefined 907 | }); 908 | expect(importMonitoredLiftedStore.getState()).toEqual(expectedImportedState); 909 | }); 910 | 911 | it('should include stack trace', () => { 912 | let importMonitoredStore = createStore(counter, instrument(undefined, { trace: true })); 913 | let importMonitoredLiftedStore = importMonitoredStore.liftedStore; 914 | 915 | importMonitoredStore.dispatch({ type: 'DECREMENT' }); 916 | importMonitoredStore.dispatch({ type: 'DECREMENT' }); 917 | 918 | const oldState = importMonitoredLiftedStore.getState(); 919 | expect(oldState.actionsById[0].stack).toBe(undefined); 920 | expect(oldState.actionsById[1].stack).toBeA('string'); 921 | 922 | importMonitoredLiftedStore.dispatch(ActionCreators.importState(oldState)); 923 | expect(importMonitoredLiftedStore.getState()).toEqual(oldState); 924 | expect(importMonitoredLiftedStore.getState().actionsById[0].stack).toBe(undefined); 925 | expect(importMonitoredLiftedStore.getState().actionsById[1]).toEqual(oldState.actionsById[1]); 926 | }); 927 | }); 928 | 929 | function filterStackAndTimestamps(state) { 930 | state.actionsById = _.mapValues(state.actionsById, (action) => { 931 | delete action.timestamp; 932 | delete action.stack; 933 | return action; 934 | }); 935 | return state; 936 | } 937 | 938 | describe('Import Actions', () => { 939 | let monitoredStore; 940 | let monitoredLiftedStore; 941 | let exportedState; 942 | let savedActions = [ 943 | { type: 'INCREMENT' }, 944 | { type: 'INCREMENT' }, 945 | { type: 'INCREMENT' } 946 | ]; 947 | 948 | beforeEach(() => { 949 | monitoredStore = createStore(counter, instrument()); 950 | monitoredLiftedStore = monitoredStore.liftedStore; 951 | // Pass actions through component 952 | savedActions.forEach(action => monitoredStore.dispatch(action)); 953 | // get the final state 954 | exportedState = filterStackAndTimestamps(monitoredLiftedStore.getState()); 955 | }); 956 | 957 | it('should replay all the steps when a state is imported', () => { 958 | let importMonitoredStore = createStore(counter, instrument()); 959 | let importMonitoredLiftedStore = importMonitoredStore.liftedStore; 960 | 961 | importMonitoredLiftedStore.dispatch(ActionCreators.importState(savedActions)); 962 | expect(filterStackAndTimestamps(importMonitoredLiftedStore.getState())).toEqual(exportedState); 963 | }); 964 | 965 | it('should replace the existing action log with the one imported', () => { 966 | let importMonitoredStore = createStore(counter, instrument()); 967 | let importMonitoredLiftedStore = importMonitoredStore.liftedStore; 968 | 969 | importMonitoredStore.dispatch({ type: 'DECREMENT' }); 970 | importMonitoredStore.dispatch({ type: 'DECREMENT' }); 971 | 972 | importMonitoredLiftedStore.dispatch(ActionCreators.importState(savedActions)); 973 | expect(filterStackAndTimestamps(importMonitoredLiftedStore.getState())).toEqual(exportedState); 974 | }); 975 | 976 | it('should include stack trace', () => { 977 | let importMonitoredStore = createStore(counter, instrument(undefined, { trace: true })); 978 | let importMonitoredLiftedStore = importMonitoredStore.liftedStore; 979 | 980 | importMonitoredStore.dispatch({ type: 'DECREMENT' }); 981 | importMonitoredStore.dispatch({ type: 'DECREMENT' }); 982 | 983 | importMonitoredLiftedStore.dispatch(ActionCreators.importState(savedActions)); 984 | expect(importMonitoredLiftedStore.getState().actionsById[0].stack).toBe(undefined); 985 | expect(importMonitoredLiftedStore.getState().actionsById[1].stack).toBeA('string'); 986 | expect(filterStackAndTimestamps(importMonitoredLiftedStore.getState())).toEqual(exportedState); 987 | }); 988 | }); 989 | 990 | describe('Lock Changes', () => { 991 | it('should lock', () => { 992 | store.dispatch({ type: 'INCREMENT' }); 993 | store.liftedStore.dispatch({ type: 'LOCK_CHANGES', status: true }); 994 | expect(store.liftedStore.getState().isLocked).toBe(true); 995 | expect(store.liftedStore.getState().nextActionId).toBe(2); 996 | expect(store.getState()).toBe(1); 997 | 998 | store.dispatch({ type: 'INCREMENT' }); 999 | expect(store.liftedStore.getState().nextActionId).toBe(2); 1000 | expect(store.getState()).toBe(1); 1001 | 1002 | liftedStore.dispatch(ActionCreators.toggleAction(1)); 1003 | expect(store.getState()).toBe(0); 1004 | liftedStore.dispatch(ActionCreators.toggleAction(1)); 1005 | expect(store.getState()).toBe(1); 1006 | 1007 | store.liftedStore.dispatch({ type: 'LOCK_CHANGES', status: false }); 1008 | expect(store.liftedStore.getState().isLocked).toBe(false); 1009 | expect(store.liftedStore.getState().nextActionId).toBe(2); 1010 | 1011 | store.dispatch({ type: 'INCREMENT' }); 1012 | expect(store.liftedStore.getState().nextActionId).toBe(3); 1013 | expect(store.getState()).toBe(2); 1014 | }); 1015 | it('should start locked', () => { 1016 | store = createStore(counter, instrument(undefined, { shouldStartLocked: true })); 1017 | store.dispatch({ type: 'INCREMENT' }); 1018 | expect(store.liftedStore.getState().isLocked).toBe(true); 1019 | expect(store.liftedStore.getState().nextActionId).toBe(1); 1020 | expect(store.getState()).toBe(0); 1021 | 1022 | const savedActions = [{ type: 'INCREMENT' }, { type: 'INCREMENT' }]; 1023 | store.liftedStore.dispatch(ActionCreators.importState(savedActions)); 1024 | expect(store.liftedStore.getState().nextActionId).toBe(3); 1025 | expect(store.getState()).toBe(2); 1026 | 1027 | store.liftedStore.dispatch({ type: 'LOCK_CHANGES', status: false }); 1028 | expect(store.liftedStore.getState().isLocked).toBe(false); 1029 | 1030 | store.dispatch({ type: 'INCREMENT' }); 1031 | expect(store.liftedStore.getState().nextActionId).toBe(4); 1032 | expect(store.getState()).toBe(3); 1033 | }); 1034 | }); 1035 | 1036 | describe('Pause recording', () => { 1037 | it('should pause', () => { 1038 | expect(store.liftedStore.getState().isPaused).toBe(false); 1039 | store.dispatch({ type: 'INCREMENT' }); 1040 | store.dispatch({ type: 'INCREMENT' }); 1041 | expect(store.liftedStore.getState().nextActionId).toBe(3); 1042 | expect(store.getState()).toBe(2); 1043 | 1044 | store.liftedStore.dispatch(ActionCreators.pauseRecording(true)); 1045 | expect(store.liftedStore.getState().isPaused).toBe(true); 1046 | expect(store.liftedStore.getState().nextActionId).toBe(1); 1047 | expect(store.liftedStore.getState().actionsById[0].action).toEqual({ type: '@@INIT' }); 1048 | expect(store.getState()).toBe(2); 1049 | 1050 | store.dispatch({ type: 'INCREMENT' }); 1051 | store.dispatch({ type: 'INCREMENT' }); 1052 | expect(store.liftedStore.getState().nextActionId).toBe(1); 1053 | expect(store.liftedStore.getState().actionsById[0].action).toEqual({ type: '@@INIT' }); 1054 | expect(store.getState()).toBe(4); 1055 | 1056 | store.liftedStore.dispatch(ActionCreators.pauseRecording(false)); 1057 | expect(store.liftedStore.getState().isPaused).toBe(false); 1058 | 1059 | store.dispatch({ type: 'INCREMENT' }); 1060 | store.dispatch({ type: 'INCREMENT' }); 1061 | expect(store.liftedStore.getState().nextActionId).toBe(3); 1062 | expect(store.liftedStore.getState().actionsById[2].action).toEqual({ type: 'INCREMENT' }); 1063 | expect(store.getState()).toBe(6); 1064 | }); 1065 | it('should maintain the history while paused', () => { 1066 | store = createStore(counter, instrument(undefined, { pauseActionType: '@@PAUSED' })); 1067 | store.dispatch({ type: 'INCREMENT' }); 1068 | store.dispatch({ type: 'INCREMENT' }); 1069 | expect(store.getState()).toBe(2); 1070 | expect(store.liftedStore.getState().nextActionId).toBe(3); 1071 | expect(store.liftedStore.getState().isPaused).toBe(false); 1072 | 1073 | store.liftedStore.dispatch(ActionCreators.pauseRecording(true)); 1074 | expect(store.liftedStore.getState().isPaused).toBe(true); 1075 | expect(store.liftedStore.getState().nextActionId).toBe(4); 1076 | expect(store.getState()).toBe(2); 1077 | 1078 | store.dispatch({ type: 'INCREMENT' }); 1079 | expect(store.liftedStore.getState().nextActionId).toBe(4); 1080 | store.dispatch({ type: 'INCREMENT' }); 1081 | expect(store.liftedStore.getState().nextActionId).toBe(4); 1082 | expect(store.getState()).toBe(4); 1083 | 1084 | store.liftedStore.dispatch(ActionCreators.pauseRecording(false)); 1085 | expect(store.liftedStore.getState().isPaused).toBe(false); 1086 | expect(store.liftedStore.getState().nextActionId).toBe(1); 1087 | expect(store.getState()).toBe(4); 1088 | store.dispatch({ type: 'INCREMENT' }); 1089 | expect(store.liftedStore.getState().nextActionId).toBe(2); 1090 | expect(store.getState()).toBe(5); 1091 | 1092 | store.liftedStore.dispatch(ActionCreators.commit()); 1093 | store.liftedStore.dispatch(ActionCreators.pauseRecording(true)); 1094 | store.dispatch({ type: 'INCREMENT' }); 1095 | expect(store.liftedStore.getState().nextActionId).toBe(1); 1096 | expect(store.getState()).toBe(6); 1097 | }); 1098 | }); 1099 | 1100 | it('throws if reducer is not a function', () => { 1101 | expect(() => 1102 | createStore(undefined, instrument()) 1103 | ).toThrow('Expected the reducer to be a function.'); 1104 | }); 1105 | 1106 | it('warns if the reducer is not a function but has a default field that is', () => { 1107 | expect(() => 1108 | createStore(({ 'default': () => {} }), instrument()) 1109 | ).toThrow( 1110 | 'Expected the reducer to be a function. ' + 1111 | 'Instead got an object with a "default" field. ' + 1112 | 'Did you pass a module instead of the default export? ' + 1113 | 'Try passing require(...).default instead.' 1114 | ); 1115 | }); 1116 | 1117 | it('throws if there are more than one instrument enhancer included', () => { 1118 | expect(() => { 1119 | createStore(counter, compose(instrument(), instrument())); 1120 | }).toThrow( 1121 | 'DevTools instrumentation should not be applied more than once. ' + 1122 | 'Check your store configuration.' 1123 | ); 1124 | }); 1125 | }); 1126 | --------------------------------------------------------------------------------