├── .babelrc ├── .gitignore ├── .eslintignore ├── .travis.yml ├── CHANGELOG.md ├── .eslintrc ├── LICENSE.md ├── CODE_OF_CONDUCT.md ├── package.json ├── README.md ├── src └── instrument.js └── test └── instrument.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015-loose", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .DS_Store 4 | lib 5 | coverage 6 | .idea 7 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | **/node_modules 3 | **/webpack.config.js 4 | examples/**/server.js -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-devtools-instrument", 3 | "version": "1.3.1", 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": "^3.5.2", 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": "^0.2.4" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Redux DevTools Instrumentation 2 | ============================== 3 | 4 | Redux enhancer used along with [Redux DevTools](https://github.com/gaearon/redux-devtools) or [Remote Redux DevTools](https://github.com/zalmoxisus/remote-redux-devtools). 5 | 6 | ### Installation 7 | 8 | ``` 9 | npm install --save-dev redux-devtools-instrument 10 | ``` 11 | 12 | ### Usage 13 | 14 | Add the store enhancer: 15 | 16 | ##### `store/configureStore.js` 17 | 18 | ```js 19 | import { createStore, applyMiddleware, compose } from 'redux'; 20 | import thunk from 'redux-thunk'; 21 | import devTools from 'remote-redux-devtools'; 22 | import reducer from '../reducers'; 23 | 24 | // Usually you import the reducer from the monitor 25 | // or apply with createDevTools as explained in Redux DevTools 26 | const monitorReducer = (state = {}, action) => state; 27 | 28 | export default function configureStore(initialState) { 29 | const enhancer = compose( 30 | applyMiddleware(...middlewares), 31 | // other enhancers and applyMiddleware should be added before the instrumentation 32 | instrument(monitorReducer, { maxAge: 50 }) 33 | ); 34 | 35 | // Note: passing enhancer as last argument requires redux@>=3.1.0 36 | return createStore(reducer, initialState, enhancer); 37 | } 38 | ``` 39 | 40 | ### API 41 | 42 | `instrument(monitorReducer, [options])` 43 | 44 | - arguments 45 | - **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)). 46 | - **options** *object* 47 | - **maxAge** *number* - maximum allowed actions to be stored on the history tree, the oldest actions are removed once `maxAge` is reached. 48 | - **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. 49 | - **shouldRecordChanges** *boolean* - if specified as `false`, it will not record the changes till `pauseRecording(false)` is dispatched. Default is `true`. 50 | - **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. 51 | - **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`. 52 | - **shouldHotReload** *boolean* - if set to `false`, will not recompute the states on hot reloading (or on replacing the reducers). Default to `true`. 53 | 54 | ### License 55 | 56 | MIT 57 | -------------------------------------------------------------------------------- /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 | IMPORT_STATE: 'IMPORT_STATE', 16 | LOCK_CHANGES: 'LOCK_CHANGES', 17 | PAUSE_RECORDING: 'PAUSE_RECORDING' 18 | }; 19 | 20 | /** 21 | * Action creators to change the History state. 22 | */ 23 | export const ActionCreators = { 24 | performAction(action) { 25 | if (!isPlainObject(action)) { 26 | throw new Error( 27 | 'Actions must be plain objects. ' + 28 | 'Use custom middleware for async actions.' 29 | ); 30 | } 31 | 32 | if (typeof action.type === 'undefined') { 33 | throw new Error( 34 | 'Actions may not have an undefined "type" property. ' + 35 | 'Have you misspelled a constant?' 36 | ); 37 | } 38 | 39 | return { type: ActionTypes.PERFORM_ACTION, action, timestamp: Date.now() }; 40 | }, 41 | 42 | reset() { 43 | return { type: ActionTypes.RESET, timestamp: Date.now() }; 44 | }, 45 | 46 | rollback() { 47 | return { type: ActionTypes.ROLLBACK, timestamp: Date.now() }; 48 | }, 49 | 50 | commit() { 51 | return { type: ActionTypes.COMMIT, timestamp: Date.now() }; 52 | }, 53 | 54 | sweep() { 55 | return { type: ActionTypes.SWEEP }; 56 | }, 57 | 58 | toggleAction(id) { 59 | return { type: ActionTypes.TOGGLE_ACTION, id }; 60 | }, 61 | 62 | setActionsActive(start, end, active=true) { 63 | return { type: ActionTypes.SET_ACTIONS_ACTIVE, start, end, active }; 64 | }, 65 | 66 | jumpToState(index) { 67 | return { type: ActionTypes.JUMP_TO_STATE, index }; 68 | }, 69 | 70 | importState(nextLiftedState, noRecompute) { 71 | return { type: ActionTypes.IMPORT_STATE, nextLiftedState, noRecompute }; 72 | }, 73 | 74 | lockChanges(status) { 75 | return { type: ActionTypes.LOCK_CHANGES, status }; 76 | }, 77 | 78 | pauseRecording(status) { 79 | return { type: ActionTypes.PAUSE_RECORDING, status }; 80 | } 81 | }; 82 | 83 | export const INIT_ACTION = { type: '@@INIT' }; 84 | 85 | /** 86 | * Computes the next entry with exceptions catching. 87 | */ 88 | function computeWithTryCatch(reducer, action, state) { 89 | let nextState = state; 90 | let nextError; 91 | try { 92 | nextState = reducer(state, action); 93 | } catch (err) { 94 | nextError = err.toString(); 95 | if ( 96 | typeof window === 'object' && ( 97 | typeof window.chrome !== 'undefined' || 98 | typeof window.process !== 'undefined' && 99 | window.process.type === 'renderer' 100 | )) { 101 | // In Chrome, rethrowing provides better source map support 102 | setTimeout(() => { throw err; }); 103 | } else { 104 | console.error(err); 105 | } 106 | } 107 | 108 | return { 109 | state: nextState, 110 | error: nextError 111 | }; 112 | } 113 | 114 | /** 115 | * Computes the next entry in the log by applying an action. 116 | */ 117 | function computeNextEntry(reducer, action, state, shouldCatchErrors) { 118 | if (!shouldCatchErrors) { 119 | return { state: reducer(state, action) }; 120 | } 121 | return computeWithTryCatch(reducer, action, state); 122 | } 123 | 124 | /** 125 | * Runs the reducer on invalidated actions to get a fresh computation log. 126 | */ 127 | function recomputeStates( 128 | computedStates, 129 | minInvalidatedStateIndex, 130 | reducer, 131 | committedState, 132 | actionsById, 133 | stagedActionIds, 134 | skippedActionIds, 135 | shouldCatchErrors 136 | ) { 137 | // Optimization: exit early and return the same reference 138 | // if we know nothing could have changed. 139 | if ( 140 | !computedStates || minInvalidatedStateIndex === -1 || 141 | (minInvalidatedStateIndex >= computedStates.length && 142 | computedStates.length === stagedActionIds.length) 143 | ) { 144 | return computedStates; 145 | } 146 | 147 | const nextComputedStates = computedStates.slice(0, minInvalidatedStateIndex); 148 | for (let i = minInvalidatedStateIndex; i < stagedActionIds.length; i++) { 149 | const actionId = stagedActionIds[i]; 150 | const action = actionsById[actionId].action; 151 | 152 | const previousEntry = nextComputedStates[i - 1]; 153 | const previousState = previousEntry ? previousEntry.state : committedState; 154 | 155 | const shouldSkip = skippedActionIds.indexOf(actionId) > -1; 156 | let entry; 157 | if (shouldSkip) { 158 | entry = previousEntry; 159 | } else { 160 | if (shouldCatchErrors && previousEntry && previousEntry.error) { 161 | entry = { 162 | state: previousState, 163 | error: 'Interrupted by an error up the chain' 164 | }; 165 | } else { 166 | entry = computeNextEntry(reducer, action, previousState, shouldCatchErrors); 167 | } 168 | } 169 | nextComputedStates.push(entry); 170 | } 171 | 172 | return nextComputedStates; 173 | } 174 | 175 | /** 176 | * Lifts an app's action into an action on the lifted store. 177 | */ 178 | export function liftAction(action) { 179 | return ActionCreators.performAction(action); 180 | } 181 | 182 | /** 183 | * Creates a history state reducer from an app's reducer. 184 | */ 185 | export function liftReducerWith(reducer, initialCommittedState, monitorReducer, options) { 186 | const initialLiftedState = { 187 | monitorState: monitorReducer(undefined, {}), 188 | nextActionId: 1, 189 | actionsById: { 0: liftAction(INIT_ACTION) }, 190 | stagedActionIds: [0], 191 | skippedActionIds: [], 192 | committedState: initialCommittedState, 193 | currentStateIndex: 0, 194 | computedStates: [], 195 | isLocked: options.shouldStartLocked === true, 196 | isPaused: options.shouldRecordChanges === false 197 | }; 198 | 199 | /** 200 | * Manages how the history actions modify the history state. 201 | */ 202 | return (liftedState, liftedAction) => { 203 | let { 204 | monitorState, 205 | actionsById, 206 | nextActionId, 207 | stagedActionIds, 208 | skippedActionIds, 209 | committedState, 210 | currentStateIndex, 211 | computedStates, 212 | isLocked, 213 | isPaused 214 | } = liftedState || initialLiftedState; 215 | 216 | if (!liftedState) { 217 | // Prevent mutating initialLiftedState 218 | actionsById = { ...actionsById }; 219 | } 220 | 221 | function commitExcessActions(n) { 222 | // Auto-commits n-number of excess actions. 223 | let excess = n; 224 | let idsToDelete = stagedActionIds.slice(1, excess + 1); 225 | 226 | for (let i = 0; i < idsToDelete.length; i++) { 227 | if (computedStates[i + 1].error) { 228 | // Stop if error is found. Commit actions up to error. 229 | excess = i; 230 | idsToDelete = stagedActionIds.slice(1, excess + 1); 231 | break; 232 | } else { 233 | delete actionsById[idsToDelete[i]]; 234 | } 235 | } 236 | 237 | skippedActionIds = skippedActionIds.filter(id => idsToDelete.indexOf(id) === -1); 238 | stagedActionIds = [0, ...stagedActionIds.slice(excess + 1)]; 239 | committedState = computedStates[excess].state; 240 | computedStates = computedStates.slice(excess); 241 | currentStateIndex = currentStateIndex > excess 242 | ? currentStateIndex - excess 243 | : 0; 244 | } 245 | 246 | function computePausedAction(shouldInit) { 247 | let computedState; 248 | if (shouldInit) { 249 | computedState = computedStates[currentStateIndex]; 250 | monitorState = monitorReducer(monitorState, liftedAction); 251 | } else { 252 | computedState = computeNextEntry( 253 | reducer, liftedAction.action, computedStates[currentStateIndex].state, false 254 | ); 255 | } 256 | if (!options.pauseActionType || nextActionId === 1) { 257 | return { 258 | monitorState, 259 | actionsById: { 0: liftAction(INIT_ACTION) }, 260 | nextActionId: 1, 261 | stagedActionIds: [0], 262 | skippedActionIds: [], 263 | committedState: computedState.state, 264 | currentStateIndex: 0, 265 | computedStates: [computedState], 266 | isLocked, 267 | isPaused: true 268 | }; 269 | } 270 | if (shouldInit) { 271 | if (currentStateIndex === stagedActionIds.length - 1) { 272 | currentStateIndex++; 273 | } 274 | stagedActionIds = [...stagedActionIds, nextActionId]; 275 | nextActionId++; 276 | } 277 | return { 278 | monitorState, 279 | actionsById: { 280 | ...actionsById, 281 | [nextActionId - 1]: liftAction({ type: options.pauseActionType }) 282 | }, 283 | nextActionId, 284 | stagedActionIds, 285 | skippedActionIds, 286 | committedState, 287 | currentStateIndex, 288 | computedStates: [...computedStates.slice(0, stagedActionIds.length - 1), computedState], 289 | isLocked, 290 | isPaused: true 291 | }; 292 | } 293 | 294 | // By default, agressively recompute every state whatever happens. 295 | // This has O(n) performance, so we'll override this to a sensible 296 | // value whenever we feel like we don't have to recompute the states. 297 | let minInvalidatedStateIndex = 0; 298 | 299 | switch (liftedAction.type) { 300 | case ActionTypes.PERFORM_ACTION: { 301 | if (isLocked) return liftedState || initialLiftedState; 302 | if (isPaused) return computePausedAction(); 303 | 304 | // Auto-commit as new actions come in. 305 | if (options.maxAge && stagedActionIds.length === options.maxAge) { 306 | commitExcessActions(1); 307 | } 308 | 309 | if (currentStateIndex === stagedActionIds.length - 1) { 310 | currentStateIndex++; 311 | } 312 | const actionId = nextActionId++; 313 | // Mutation! This is the hottest path, and we optimize on purpose. 314 | // It is safe because we set a new key in a cache dictionary. 315 | actionsById[actionId] = liftedAction; 316 | stagedActionIds = [...stagedActionIds, actionId]; 317 | // Optimization: we know that only the new action needs computing. 318 | minInvalidatedStateIndex = stagedActionIds.length - 1; 319 | break; 320 | } 321 | case ActionTypes.RESET: { 322 | // Get back to the state the store was created with. 323 | actionsById = { 0: liftAction(INIT_ACTION) }; 324 | nextActionId = 1; 325 | stagedActionIds = [0]; 326 | skippedActionIds = []; 327 | committedState = initialCommittedState; 328 | currentStateIndex = 0; 329 | computedStates = []; 330 | break; 331 | } 332 | case ActionTypes.COMMIT: { 333 | // Consider the last committed state the new starting point. 334 | // Squash any staged actions into a single committed state. 335 | actionsById = { 0: liftAction(INIT_ACTION) }; 336 | nextActionId = 1; 337 | stagedActionIds = [0]; 338 | skippedActionIds = []; 339 | committedState = computedStates[currentStateIndex].state; 340 | currentStateIndex = 0; 341 | computedStates = []; 342 | break; 343 | } 344 | case ActionTypes.ROLLBACK: { 345 | // Forget about any staged actions. 346 | // Start again from the last committed state. 347 | actionsById = { 0: liftAction(INIT_ACTION) }; 348 | nextActionId = 1; 349 | stagedActionIds = [0]; 350 | skippedActionIds = []; 351 | currentStateIndex = 0; 352 | computedStates = []; 353 | break; 354 | } 355 | case ActionTypes.TOGGLE_ACTION: { 356 | // Toggle whether an action with given ID is skipped. 357 | // Being skipped means it is a no-op during the computation. 358 | const { id: actionId } = liftedAction; 359 | const index = skippedActionIds.indexOf(actionId); 360 | if (index === -1) { 361 | skippedActionIds = [actionId, ...skippedActionIds]; 362 | } else { 363 | skippedActionIds = skippedActionIds.filter(id => id !== actionId); 364 | } 365 | // Optimization: we know history before this action hasn't changed 366 | minInvalidatedStateIndex = stagedActionIds.indexOf(actionId); 367 | break; 368 | } 369 | case ActionTypes.SET_ACTIONS_ACTIVE: { 370 | // Toggle whether an action with given ID is skipped. 371 | // Being skipped means it is a no-op during the computation. 372 | const { start, end, active } = liftedAction; 373 | const actionIds = []; 374 | for (let i = start; i < end; i++) actionIds.push(i); 375 | if (active) { 376 | skippedActionIds = difference(skippedActionIds, actionIds); 377 | } else { 378 | skippedActionIds = union(skippedActionIds, actionIds); 379 | } 380 | 381 | // Optimization: we know history before this action hasn't changed 382 | minInvalidatedStateIndex = stagedActionIds.indexOf(start); 383 | break; 384 | } 385 | case ActionTypes.JUMP_TO_STATE: { 386 | // Without recomputing anything, move the pointer that tell us 387 | // which state is considered the current one. Useful for sliders. 388 | currentStateIndex = liftedAction.index; 389 | // Optimization: we know the history has not changed. 390 | minInvalidatedStateIndex = Infinity; 391 | break; 392 | } 393 | case ActionTypes.SWEEP: { 394 | // Forget any actions that are currently being skipped. 395 | stagedActionIds = difference(stagedActionIds, skippedActionIds); 396 | skippedActionIds = []; 397 | currentStateIndex = Math.min(currentStateIndex, stagedActionIds.length - 1); 398 | break; 399 | } 400 | case ActionTypes.IMPORT_STATE: { 401 | if (Array.isArray(liftedAction.nextLiftedState)) { 402 | // recompute array of actions 403 | actionsById = { 0: liftAction(INIT_ACTION) }; 404 | nextActionId = 1; 405 | stagedActionIds = [0]; 406 | skippedActionIds = []; 407 | currentStateIndex = liftedAction.nextLiftedState.length; 408 | computedStates = []; 409 | committedState = liftedAction.preloadedState; 410 | minInvalidatedStateIndex = 0; 411 | // iterate through actions 412 | liftedAction.nextLiftedState.forEach(action => { 413 | actionsById[nextActionId] = liftAction(action); 414 | stagedActionIds.push(nextActionId); 415 | nextActionId++; 416 | }); 417 | } else { 418 | // Completely replace everything. 419 | ({ 420 | monitorState, 421 | actionsById, 422 | nextActionId, 423 | stagedActionIds, 424 | skippedActionIds, 425 | committedState, 426 | currentStateIndex, 427 | computedStates 428 | } = liftedAction.nextLiftedState); 429 | 430 | if (liftedAction.noRecompute) { 431 | minInvalidatedStateIndex = Infinity; 432 | } 433 | } 434 | 435 | break; 436 | } 437 | case ActionTypes.LOCK_CHANGES: { 438 | isLocked = liftedAction.status; 439 | minInvalidatedStateIndex = Infinity; 440 | break; 441 | } 442 | case ActionTypes.PAUSE_RECORDING: { 443 | isPaused = liftedAction.status; 444 | if (isPaused) { 445 | return computePausedAction(true); 446 | } else { 447 | // Commit when unpausing 448 | actionsById = { 0: liftAction(INIT_ACTION) }; 449 | nextActionId = 1; 450 | stagedActionIds = [0]; 451 | skippedActionIds = []; 452 | committedState = computedStates[currentStateIndex].state; 453 | currentStateIndex = 0; 454 | computedStates = []; 455 | } 456 | break; 457 | } 458 | case '@@redux/INIT': { 459 | if (options.shouldHotReload === false && liftedState) { 460 | return liftedState; 461 | } 462 | 463 | // Recompute states on hot reload and init. 464 | minInvalidatedStateIndex = 0; 465 | 466 | if (options.maxAge && stagedActionIds.length > options.maxAge) { 467 | // States must be recomputed before committing excess. 468 | computedStates = recomputeStates( 469 | computedStates, 470 | minInvalidatedStateIndex, 471 | reducer, 472 | committedState, 473 | actionsById, 474 | stagedActionIds, 475 | skippedActionIds, 476 | options.shouldCatchErrors 477 | ); 478 | 479 | commitExcessActions(stagedActionIds.length - options.maxAge); 480 | 481 | // Avoid double computation. 482 | minInvalidatedStateIndex = Infinity; 483 | } 484 | 485 | break; 486 | } 487 | default: { 488 | // If the action is not recognized, it's a monitor action. 489 | // Optimization: a monitor action can't change history. 490 | minInvalidatedStateIndex = Infinity; 491 | break; 492 | } 493 | } 494 | 495 | computedStates = recomputeStates( 496 | computedStates, 497 | minInvalidatedStateIndex, 498 | reducer, 499 | committedState, 500 | actionsById, 501 | stagedActionIds, 502 | skippedActionIds, 503 | options.shouldCatchErrors 504 | ); 505 | monitorState = monitorReducer(monitorState, liftedAction); 506 | return { 507 | monitorState, 508 | actionsById, 509 | nextActionId, 510 | stagedActionIds, 511 | skippedActionIds, 512 | committedState, 513 | currentStateIndex, 514 | computedStates, 515 | isLocked, 516 | isPaused 517 | }; 518 | }; 519 | } 520 | 521 | /** 522 | * Provides an app's view into the state of the lifted store. 523 | */ 524 | export function unliftState(liftedState) { 525 | const { computedStates, currentStateIndex } = liftedState; 526 | const { state } = computedStates[currentStateIndex]; 527 | return state; 528 | } 529 | 530 | /** 531 | * Provides an app's view into the lifted store. 532 | */ 533 | export function unliftStore(liftedStore, liftReducer) { 534 | let lastDefinedState; 535 | 536 | function getState() { 537 | const state = unliftState(liftedStore.getState()); 538 | if (state !== undefined) { 539 | lastDefinedState = state; 540 | } 541 | return lastDefinedState; 542 | } 543 | 544 | return { 545 | ...liftedStore, 546 | 547 | liftedStore, 548 | 549 | dispatch(action) { 550 | liftedStore.dispatch(liftAction(action)); 551 | return action; 552 | }, 553 | 554 | getState, 555 | 556 | replaceReducer(nextReducer) { 557 | liftedStore.replaceReducer(liftReducer(nextReducer)); 558 | }, 559 | 560 | [$$observable]() { 561 | return { 562 | ...liftedStore[$$observable](), 563 | subscribe(observer) { 564 | if (typeof observer !== 'object') { 565 | throw new TypeError('Expected the observer to be an object.'); 566 | } 567 | 568 | function observeState() { 569 | if (observer.next) { 570 | observer.next(getState()); 571 | } 572 | } 573 | 574 | observeState(); 575 | const unsubscribe = liftedStore.subscribe(observeState); 576 | return { unsubscribe }; 577 | } 578 | }; 579 | } 580 | }; 581 | } 582 | 583 | /** 584 | * Redux instrumentation store enhancer. 585 | */ 586 | export default function instrument(monitorReducer = () => null, options = {}) { 587 | /* eslint-disable no-eq-null */ 588 | if (options.maxAge != null && options.maxAge < 2) { 589 | /* eslint-enable */ 590 | throw new Error( 591 | 'DevTools.instrument({ maxAge }) option, if specified, ' + 592 | 'may not be less than 2.' 593 | ); 594 | } 595 | 596 | return createStore => (reducer, initialState, enhancer) => { 597 | 598 | function liftReducer(r) { 599 | if (typeof r !== 'function') { 600 | if (r && typeof r.default === 'function') { 601 | throw new Error( 602 | 'Expected the reducer to be a function. ' + 603 | 'Instead got an object with a "default" field. ' + 604 | 'Did you pass a module instead of the default export? ' + 605 | 'Try passing require(...).default instead.' 606 | ); 607 | } 608 | throw new Error('Expected the reducer to be a function.'); 609 | } 610 | return liftReducerWith(r, initialState, monitorReducer, options); 611 | } 612 | 613 | const liftedStore = createStore(liftReducer(reducer), enhancer); 614 | if (liftedStore.liftedStore) { 615 | throw new Error( 616 | 'DevTools instrumentation should not be applied more than once. ' + 617 | 'Check your store configuration.' 618 | ); 619 | } 620 | 621 | return unliftStore(liftedStore, liftReducer); 622 | }; 623 | } 624 | -------------------------------------------------------------------------------- /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 | describe('instrument', () => { 44 | let store; 45 | let liftedStore; 46 | 47 | beforeEach(() => { 48 | store = createStore(counter, instrument()); 49 | liftedStore = store.liftedStore; 50 | }); 51 | 52 | it('should perform actions', () => { 53 | expect(store.getState()).toBe(0); 54 | store.dispatch({ type: 'INCREMENT' }); 55 | expect(store.getState()).toBe(1); 56 | store.dispatch({ type: 'INCREMENT' }); 57 | expect(store.getState()).toBe(2); 58 | }); 59 | 60 | it('should provide observable', () => { 61 | let lastValue; 62 | let calls = 0; 63 | 64 | Observable.from(store) 65 | .subscribe(state => { 66 | lastValue = state; 67 | calls++; 68 | }); 69 | 70 | expect(lastValue).toBe(0); 71 | store.dispatch({ type: 'INCREMENT' }); 72 | expect(lastValue).toBe(1); 73 | }); 74 | 75 | it('should rollback state to the last committed state', () => { 76 | store.dispatch({ type: 'INCREMENT' }); 77 | store.dispatch({ type: 'INCREMENT' }); 78 | expect(store.getState()).toBe(2); 79 | 80 | liftedStore.dispatch(ActionCreators.commit()); 81 | expect(store.getState()).toBe(2); 82 | 83 | store.dispatch({ type: 'INCREMENT' }); 84 | store.dispatch({ type: 'INCREMENT' }); 85 | expect(store.getState()).toBe(4); 86 | 87 | liftedStore.dispatch(ActionCreators.rollback()); 88 | expect(store.getState()).toBe(2); 89 | 90 | store.dispatch({ type: 'DECREMENT' }); 91 | expect(store.getState()).toBe(1); 92 | 93 | liftedStore.dispatch(ActionCreators.rollback()); 94 | expect(store.getState()).toBe(2); 95 | }); 96 | 97 | it('should reset to initial state', () => { 98 | store.dispatch({ type: 'INCREMENT' }); 99 | expect(store.getState()).toBe(1); 100 | 101 | liftedStore.dispatch(ActionCreators.commit()); 102 | expect(store.getState()).toBe(1); 103 | 104 | store.dispatch({ type: 'INCREMENT' }); 105 | expect(store.getState()).toBe(2); 106 | 107 | liftedStore.dispatch(ActionCreators.rollback()); 108 | expect(store.getState()).toBe(1); 109 | 110 | store.dispatch({ type: 'INCREMENT' }); 111 | expect(store.getState()).toBe(2); 112 | 113 | liftedStore.dispatch(ActionCreators.reset()); 114 | expect(store.getState()).toBe(0); 115 | }); 116 | 117 | it('should toggle an action', () => { 118 | // actionId 0 = @@INIT 119 | store.dispatch({ type: 'INCREMENT' }); 120 | store.dispatch({ type: 'DECREMENT' }); 121 | store.dispatch({ type: 'INCREMENT' }); 122 | expect(store.getState()).toBe(1); 123 | 124 | liftedStore.dispatch(ActionCreators.toggleAction(2)); 125 | expect(store.getState()).toBe(2); 126 | 127 | liftedStore.dispatch(ActionCreators.toggleAction(2)); 128 | expect(store.getState()).toBe(1); 129 | }); 130 | 131 | it('should set multiple action skip', () => { 132 | // actionId 0 = @@INIT 133 | store.dispatch({ type: 'INCREMENT' }); 134 | store.dispatch({ type: 'INCREMENT' }); 135 | store.dispatch({ type: 'INCREMENT' }); 136 | expect(store.getState()).toBe(3); 137 | 138 | liftedStore.dispatch(ActionCreators.setActionsActive(1, 3, false)); 139 | expect(store.getState()).toBe(1); 140 | 141 | liftedStore.dispatch(ActionCreators.setActionsActive(0, 2, true)); 142 | expect(store.getState()).toBe(2); 143 | 144 | liftedStore.dispatch(ActionCreators.setActionsActive(0, 1, true)); 145 | expect(store.getState()).toBe(2); 146 | }); 147 | 148 | it('should sweep disabled actions', () => { 149 | // actionId 0 = @@INIT 150 | store.dispatch({ type: 'INCREMENT' }); 151 | store.dispatch({ type: 'DECREMENT' }); 152 | store.dispatch({ type: 'INCREMENT' }); 153 | store.dispatch({ type: 'INCREMENT' }); 154 | 155 | expect(store.getState()).toBe(2); 156 | expect(liftedStore.getState().stagedActionIds).toEqual([0, 1, 2, 3, 4]); 157 | expect(liftedStore.getState().skippedActionIds).toEqual([]); 158 | 159 | liftedStore.dispatch(ActionCreators.toggleAction(2)); 160 | expect(store.getState()).toBe(3); 161 | expect(liftedStore.getState().stagedActionIds).toEqual([0, 1, 2, 3, 4]); 162 | expect(liftedStore.getState().skippedActionIds).toEqual([2]); 163 | 164 | liftedStore.dispatch(ActionCreators.sweep()); 165 | expect(store.getState()).toBe(3); 166 | expect(liftedStore.getState().stagedActionIds).toEqual([0, 1, 3, 4]); 167 | expect(liftedStore.getState().skippedActionIds).toEqual([]); 168 | }); 169 | 170 | it('should jump to state', () => { 171 | store.dispatch({ type: 'INCREMENT' }); 172 | store.dispatch({ type: 'DECREMENT' }); 173 | store.dispatch({ type: 'INCREMENT' }); 174 | expect(store.getState()).toBe(1); 175 | 176 | liftedStore.dispatch(ActionCreators.jumpToState(0)); 177 | expect(store.getState()).toBe(0); 178 | 179 | liftedStore.dispatch(ActionCreators.jumpToState(1)); 180 | expect(store.getState()).toBe(1); 181 | 182 | liftedStore.dispatch(ActionCreators.jumpToState(2)); 183 | expect(store.getState()).toBe(0); 184 | 185 | store.dispatch({ type: 'INCREMENT' }); 186 | expect(store.getState()).toBe(0); 187 | 188 | liftedStore.dispatch(ActionCreators.jumpToState(4)); 189 | expect(store.getState()).toBe(2); 190 | }); 191 | 192 | it('should replace the reducer', () => { 193 | store.dispatch({ type: 'INCREMENT' }); 194 | store.dispatch({ type: 'DECREMENT' }); 195 | store.dispatch({ type: 'INCREMENT' }); 196 | expect(store.getState()).toBe(1); 197 | 198 | store.replaceReducer(doubleCounter); 199 | expect(store.getState()).toBe(2); 200 | }); 201 | 202 | it('should catch and record errors', () => { 203 | let spy = spyOn(console, 'error'); 204 | let storeWithBug = createStore( 205 | counterWithBug, 206 | instrument(undefined, { shouldCatchErrors: true }) 207 | ); 208 | 209 | storeWithBug.dispatch({ type: 'INCREMENT' }); 210 | storeWithBug.dispatch({ type: 'DECREMENT' }); 211 | storeWithBug.dispatch({ type: 'INCREMENT' }); 212 | 213 | let { computedStates } = storeWithBug.liftedStore.getState(); 214 | expect(computedStates[2].error).toMatch( 215 | /ReferenceError/ 216 | ); 217 | expect(computedStates[3].error).toMatch( 218 | /Interrupted by an error up the chain/ 219 | ); 220 | expect(spy.calls[0].arguments[0].toString()).toMatch( 221 | /ReferenceError/ 222 | ); 223 | 224 | spy.restore(); 225 | }); 226 | 227 | it('should catch invalid action type', () => { 228 | expect(() => { 229 | store.dispatch({ type: undefined }); 230 | }).toThrow( 231 | 'Actions may not have an undefined "type" property. ' + 232 | 'Have you misspelled a constant?' 233 | ); 234 | }); 235 | 236 | it('should catch invalid action type', () => { 237 | function ActionClass() { 238 | this.type = 'test'; 239 | } 240 | 241 | expect(() => { 242 | store.dispatch(new ActionClass()); 243 | }).toThrow( 244 | 'Actions must be plain objects. ' + 245 | 'Use custom middleware for async actions.' 246 | ); 247 | }); 248 | 249 | it('should return the last non-undefined state from getState', () => { 250 | let storeWithBug = createStore(counterWithBug, instrument()); 251 | storeWithBug.dispatch({ type: 'INCREMENT' }); 252 | storeWithBug.dispatch({ type: 'INCREMENT' }); 253 | expect(storeWithBug.getState()).toBe(2); 254 | 255 | storeWithBug.dispatch({ type: 'SET_UNDEFINED' }); 256 | expect(storeWithBug.getState()).toBe(2); 257 | }); 258 | 259 | it('should not recompute states on every action', () => { 260 | let reducerCalls = 0; 261 | let monitoredStore = createStore(() => reducerCalls++, instrument()); 262 | expect(reducerCalls).toBe(1); 263 | monitoredStore.dispatch({ type: 'INCREMENT' }); 264 | monitoredStore.dispatch({ type: 'INCREMENT' }); 265 | monitoredStore.dispatch({ type: 'INCREMENT' }); 266 | expect(reducerCalls).toBe(4); 267 | }); 268 | 269 | it('should not recompute old states when toggling an action', () => { 270 | let reducerCalls = 0; 271 | let monitoredStore = createStore(() => reducerCalls++, instrument()); 272 | let monitoredLiftedStore = monitoredStore.liftedStore; 273 | 274 | expect(reducerCalls).toBe(1); 275 | // actionId 0 = @@INIT 276 | monitoredStore.dispatch({ type: 'INCREMENT' }); 277 | monitoredStore.dispatch({ type: 'INCREMENT' }); 278 | monitoredStore.dispatch({ type: 'INCREMENT' }); 279 | expect(reducerCalls).toBe(4); 280 | 281 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(3)); 282 | expect(reducerCalls).toBe(4); 283 | 284 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(3)); 285 | expect(reducerCalls).toBe(5); 286 | 287 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(2)); 288 | expect(reducerCalls).toBe(6); 289 | 290 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(2)); 291 | expect(reducerCalls).toBe(8); 292 | 293 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(1)); 294 | expect(reducerCalls).toBe(10); 295 | 296 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(2)); 297 | expect(reducerCalls).toBe(11); 298 | 299 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(3)); 300 | expect(reducerCalls).toBe(11); 301 | 302 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(1)); 303 | expect(reducerCalls).toBe(12); 304 | 305 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(3)); 306 | expect(reducerCalls).toBe(13); 307 | 308 | monitoredLiftedStore.dispatch(ActionCreators.toggleAction(2)); 309 | expect(reducerCalls).toBe(15); 310 | }); 311 | 312 | it('should not recompute states when jumping to state', () => { 313 | let reducerCalls = 0; 314 | let monitoredStore = createStore(() => reducerCalls++, instrument()); 315 | let monitoredLiftedStore = monitoredStore.liftedStore; 316 | 317 | expect(reducerCalls).toBe(1); 318 | monitoredStore.dispatch({ type: 'INCREMENT' }); 319 | monitoredStore.dispatch({ type: 'INCREMENT' }); 320 | monitoredStore.dispatch({ type: 'INCREMENT' }); 321 | expect(reducerCalls).toBe(4); 322 | 323 | let savedComputedStates = monitoredLiftedStore.getState().computedStates; 324 | 325 | monitoredLiftedStore.dispatch(ActionCreators.jumpToState(0)); 326 | expect(reducerCalls).toBe(4); 327 | 328 | monitoredLiftedStore.dispatch(ActionCreators.jumpToState(1)); 329 | expect(reducerCalls).toBe(4); 330 | 331 | monitoredLiftedStore.dispatch(ActionCreators.jumpToState(3)); 332 | expect(reducerCalls).toBe(4); 333 | 334 | expect(monitoredLiftedStore.getState().computedStates).toBe(savedComputedStates); 335 | }); 336 | 337 | it('should not recompute states on monitor actions', () => { 338 | let reducerCalls = 0; 339 | let monitoredStore = createStore(() => reducerCalls++, instrument()); 340 | let monitoredLiftedStore = monitoredStore.liftedStore; 341 | 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 | let savedComputedStates = monitoredLiftedStore.getState().computedStates; 349 | 350 | monitoredLiftedStore.dispatch({ type: 'lol' }); 351 | expect(reducerCalls).toBe(4); 352 | 353 | monitoredLiftedStore.dispatch({ type: 'wat' }); 354 | expect(reducerCalls).toBe(4); 355 | 356 | expect(monitoredLiftedStore.getState().computedStates).toBe(savedComputedStates); 357 | }); 358 | 359 | describe('maxAge option', () => { 360 | let configuredStore; 361 | let configuredLiftedStore; 362 | 363 | beforeEach(() => { 364 | configuredStore = createStore(counter, instrument(undefined, { maxAge: 3 })); 365 | configuredLiftedStore = configuredStore.liftedStore; 366 | }); 367 | 368 | it('should auto-commit earliest non-@@INIT action when maxAge is reached', () => { 369 | configuredStore.dispatch({ type: 'INCREMENT' }); 370 | configuredStore.dispatch({ type: 'INCREMENT' }); 371 | let liftedStoreState = configuredLiftedStore.getState(); 372 | 373 | expect(configuredStore.getState()).toBe(2); 374 | expect(Object.keys(liftedStoreState.actionsById).length).toBe(3); 375 | expect(liftedStoreState.committedState).toBe(undefined); 376 | expect(liftedStoreState.stagedActionIds).toInclude(1); 377 | 378 | // Trigger auto-commit. 379 | configuredStore.dispatch({ type: 'INCREMENT' }); 380 | liftedStoreState = configuredLiftedStore.getState(); 381 | 382 | expect(configuredStore.getState()).toBe(3); 383 | expect(Object.keys(liftedStoreState.actionsById).length).toBe(3); 384 | expect(liftedStoreState.stagedActionIds).toExclude(1); 385 | expect(liftedStoreState.computedStates[0].state).toBe(1); 386 | expect(liftedStoreState.committedState).toBe(1); 387 | expect(liftedStoreState.currentStateIndex).toBe(2); 388 | }); 389 | 390 | it('should remove skipped actions once committed', () => { 391 | configuredStore.dispatch({ type: 'INCREMENT' }); 392 | configuredLiftedStore.dispatch(ActionCreators.toggleAction(1)); 393 | configuredStore.dispatch({ type: 'INCREMENT' }); 394 | expect(configuredLiftedStore.getState().skippedActionIds).toInclude(1); 395 | configuredStore.dispatch({ type: 'INCREMENT' }); 396 | expect(configuredLiftedStore.getState().skippedActionIds).toExclude(1); 397 | }); 398 | 399 | it('should not auto-commit errors', () => { 400 | let spy = spyOn(console, 'error'); 401 | 402 | let storeWithBug = createStore( 403 | counterWithBug, 404 | instrument(undefined, { maxAge: 3, shouldCatchErrors: true }) 405 | ); 406 | let liftedStoreWithBug = storeWithBug.liftedStore; 407 | storeWithBug.dispatch({ type: 'DECREMENT' }); 408 | storeWithBug.dispatch({ type: 'INCREMENT' }); 409 | expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(3); 410 | 411 | storeWithBug.dispatch({ type: 'INCREMENT' }); 412 | expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(4); 413 | 414 | spy.restore(); 415 | }); 416 | 417 | it('should auto-commit actions after hot reload fixes error', () => { 418 | let spy = spyOn(console, 'error'); 419 | 420 | let storeWithBug = createStore( 421 | counterWithBug, 422 | instrument(undefined, { maxAge: 3, shouldCatchErrors: true }) 423 | ); 424 | let liftedStoreWithBug = storeWithBug.liftedStore; 425 | storeWithBug.dispatch({ type: 'DECREMENT' }); 426 | storeWithBug.dispatch({ type: 'DECREMENT' }); 427 | storeWithBug.dispatch({ type: 'INCREMENT' }); 428 | storeWithBug.dispatch({ type: 'DECREMENT' }); 429 | storeWithBug.dispatch({ type: 'DECREMENT' }); 430 | storeWithBug.dispatch({ type: 'DECREMENT' }); 431 | expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(7); 432 | 433 | // Auto-commit 2 actions by "fixing" reducer bug, but introducing another. 434 | storeWithBug.replaceReducer(counterWithAnotherBug); 435 | expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(5); 436 | 437 | // Auto-commit 2 more actions by "fixing" other reducer bug. 438 | storeWithBug.replaceReducer(counter); 439 | expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(3); 440 | 441 | spy.restore(); 442 | }); 443 | 444 | it('should update currentStateIndex when auto-committing', () => { 445 | let liftedStoreState; 446 | let currentComputedState; 447 | 448 | configuredStore.dispatch({ type: 'INCREMENT' }); 449 | configuredStore.dispatch({ type: 'INCREMENT' }); 450 | liftedStoreState = configuredLiftedStore.getState(); 451 | expect(liftedStoreState.currentStateIndex).toBe(2); 452 | 453 | // currentStateIndex should stay at 2 as actions are committed. 454 | configuredStore.dispatch({ type: 'INCREMENT' }); 455 | liftedStoreState = configuredLiftedStore.getState(); 456 | currentComputedState = liftedStoreState.computedStates[liftedStoreState.currentStateIndex]; 457 | expect(liftedStoreState.currentStateIndex).toBe(2); 458 | expect(currentComputedState.state).toBe(3); 459 | }); 460 | 461 | it('should continue to increment currentStateIndex while error blocks commit', () => { 462 | let spy = spyOn(console, 'error'); 463 | 464 | let storeWithBug = createStore( 465 | counterWithBug, 466 | instrument(undefined, { maxAge: 3, shouldCatchErrors: true }) 467 | ); 468 | let liftedStoreWithBug = storeWithBug.liftedStore; 469 | 470 | storeWithBug.dispatch({ type: 'DECREMENT' }); 471 | storeWithBug.dispatch({ type: 'DECREMENT' }); 472 | storeWithBug.dispatch({ type: 'DECREMENT' }); 473 | storeWithBug.dispatch({ type: 'DECREMENT' }); 474 | 475 | let liftedStoreState = liftedStoreWithBug.getState(); 476 | let currentComputedState = liftedStoreState.computedStates[liftedStoreState.currentStateIndex]; 477 | expect(liftedStoreState.currentStateIndex).toBe(4); 478 | expect(currentComputedState.state).toBe(0); 479 | expect(currentComputedState.error).toExist(); 480 | 481 | spy.restore(); 482 | }); 483 | 484 | it('should adjust currentStateIndex correctly when multiple actions are committed', () => { 485 | let spy = spyOn(console, 'error'); 486 | 487 | let storeWithBug = createStore( 488 | counterWithBug, 489 | instrument(undefined, { maxAge: 3, shouldCatchErrors: true }) 490 | ); 491 | let liftedStoreWithBug = storeWithBug.liftedStore; 492 | 493 | storeWithBug.dispatch({ type: 'DECREMENT' }); 494 | storeWithBug.dispatch({ type: 'DECREMENT' }); 495 | storeWithBug.dispatch({ type: 'DECREMENT' }); 496 | storeWithBug.dispatch({ type: 'DECREMENT' }); 497 | 498 | // Auto-commit 2 actions by "fixing" reducer bug. 499 | storeWithBug.replaceReducer(counter); 500 | let liftedStoreState = liftedStoreWithBug.getState(); 501 | let currentComputedState = liftedStoreState.computedStates[liftedStoreState.currentStateIndex]; 502 | expect(liftedStoreState.currentStateIndex).toBe(2); 503 | expect(currentComputedState.state).toBe(-4); 504 | 505 | spy.restore(); 506 | }); 507 | 508 | it('should not allow currentStateIndex to drop below 0', () => { 509 | let spy = spyOn(console, 'error'); 510 | 511 | let storeWithBug = createStore( 512 | counterWithBug, 513 | instrument(undefined, { maxAge: 3, shouldCatchErrors: true }) 514 | ); 515 | let liftedStoreWithBug = storeWithBug.liftedStore; 516 | 517 | storeWithBug.dispatch({ type: 'DECREMENT' }); 518 | storeWithBug.dispatch({ type: 'DECREMENT' }); 519 | storeWithBug.dispatch({ type: 'DECREMENT' }); 520 | storeWithBug.dispatch({ type: 'DECREMENT' }); 521 | liftedStoreWithBug.dispatch(ActionCreators.jumpToState(1)); 522 | 523 | // Auto-commit 2 actions by "fixing" reducer bug. 524 | storeWithBug.replaceReducer(counter); 525 | let liftedStoreState = liftedStoreWithBug.getState(); 526 | let currentComputedState = liftedStoreState.computedStates[liftedStoreState.currentStateIndex]; 527 | expect(liftedStoreState.currentStateIndex).toBe(0); 528 | expect(currentComputedState.state).toBe(-2); 529 | 530 | spy.restore(); 531 | }); 532 | 533 | it('should throw error when maxAge < 2', () => { 534 | expect(() => { 535 | createStore(counter, instrument(undefined, { maxAge: 1 })); 536 | }).toThrow(/may not be less than 2/); 537 | }); 538 | }); 539 | 540 | describe('Import State', () => { 541 | let monitoredStore; 542 | let monitoredLiftedStore; 543 | let exportedState; 544 | 545 | beforeEach(() => { 546 | monitoredStore = createStore(counter, instrument()); 547 | monitoredLiftedStore = monitoredStore.liftedStore; 548 | // Set up state to export 549 | monitoredStore.dispatch({ type: 'INCREMENT' }); 550 | monitoredStore.dispatch({ type: 'INCREMENT' }); 551 | monitoredStore.dispatch({ type: 'INCREMENT' }); 552 | 553 | exportedState = monitoredLiftedStore.getState(); 554 | }); 555 | 556 | it('should replay all the steps when a state is imported', () => { 557 | let importMonitoredStore = createStore(counter, instrument()); 558 | let importMonitoredLiftedStore = importMonitoredStore.liftedStore; 559 | 560 | importMonitoredLiftedStore.dispatch(ActionCreators.importState(exportedState)); 561 | expect(importMonitoredLiftedStore.getState()).toEqual(exportedState); 562 | }); 563 | 564 | it('should replace the existing action log with the one imported', () => { 565 | let importMonitoredStore = createStore(counter, instrument()); 566 | let importMonitoredLiftedStore = importMonitoredStore.liftedStore; 567 | 568 | importMonitoredStore.dispatch({ type: 'DECREMENT' }); 569 | importMonitoredStore.dispatch({ type: 'DECREMENT' }); 570 | 571 | importMonitoredLiftedStore.dispatch(ActionCreators.importState(exportedState)); 572 | expect(importMonitoredLiftedStore.getState()).toEqual(exportedState); 573 | }); 574 | 575 | it('should allow for state to be imported without replaying actions', () => { 576 | let importMonitoredStore = createStore(counter, instrument()); 577 | let importMonitoredLiftedStore = importMonitoredStore.liftedStore; 578 | 579 | let noComputedExportedState = Object.assign({}, exportedState); 580 | delete noComputedExportedState.computedStates; 581 | 582 | importMonitoredLiftedStore.dispatch(ActionCreators.importState(noComputedExportedState, true)); 583 | 584 | let expectedImportedState = Object.assign({}, noComputedExportedState, { 585 | computedStates: undefined 586 | }); 587 | expect(importMonitoredLiftedStore.getState()).toEqual(expectedImportedState); 588 | }); 589 | }); 590 | 591 | function filterTimestamps(state) { 592 | state.actionsById = _.mapValues(state.actionsById, (action) => { 593 | delete action.timestamp; 594 | return action; 595 | }); 596 | return state; 597 | } 598 | 599 | describe('Import Actions', () => { 600 | let monitoredStore; 601 | let monitoredLiftedStore; 602 | let exportedState; 603 | let savedActions = [ 604 | { type: 'INCREMENT' }, 605 | { type: 'INCREMENT' }, 606 | { type: 'INCREMENT' } 607 | ]; 608 | 609 | beforeEach(() => { 610 | monitoredStore = createStore(counter, instrument()); 611 | monitoredLiftedStore = monitoredStore.liftedStore; 612 | // Pass actions through component 613 | savedActions.forEach(action => monitoredStore.dispatch(action)); 614 | // get the final state 615 | exportedState = filterTimestamps(monitoredLiftedStore.getState()); 616 | }); 617 | 618 | it('should replay all the steps when a state is imported', () => { 619 | let importMonitoredStore = createStore(counter, instrument()); 620 | let importMonitoredLiftedStore = importMonitoredStore.liftedStore; 621 | 622 | importMonitoredLiftedStore.dispatch(ActionCreators.importState(savedActions)); 623 | expect(filterTimestamps(importMonitoredLiftedStore.getState())).toEqual(exportedState); 624 | }); 625 | 626 | it('should replace the existing action log with the one imported', () => { 627 | let importMonitoredStore = createStore(counter, instrument()); 628 | let importMonitoredLiftedStore = importMonitoredStore.liftedStore; 629 | 630 | importMonitoredStore.dispatch({ type: 'DECREMENT' }); 631 | importMonitoredStore.dispatch({ type: 'DECREMENT' }); 632 | 633 | importMonitoredLiftedStore.dispatch(ActionCreators.importState(savedActions)); 634 | expect(filterTimestamps(importMonitoredLiftedStore.getState())).toEqual(exportedState); 635 | }); 636 | }); 637 | 638 | describe('Lock Changes', () => { 639 | it('should lock', () => { 640 | store.dispatch({ type: 'INCREMENT' }); 641 | store.liftedStore.dispatch({ type: 'LOCK_CHANGES', status: true }); 642 | expect(store.liftedStore.getState().isLocked).toBe(true); 643 | expect(store.liftedStore.getState().nextActionId).toBe(2); 644 | expect(store.getState()).toBe(1); 645 | 646 | store.dispatch({ type: 'INCREMENT' }); 647 | expect(store.liftedStore.getState().nextActionId).toBe(2); 648 | expect(store.getState()).toBe(1); 649 | 650 | liftedStore.dispatch(ActionCreators.toggleAction(1)); 651 | expect(store.getState()).toBe(0); 652 | liftedStore.dispatch(ActionCreators.toggleAction(1)); 653 | expect(store.getState()).toBe(1); 654 | 655 | store.liftedStore.dispatch({ type: 'LOCK_CHANGES', status: false }); 656 | expect(store.liftedStore.getState().isLocked).toBe(false); 657 | expect(store.liftedStore.getState().nextActionId).toBe(2); 658 | 659 | store.dispatch({ type: 'INCREMENT' }); 660 | expect(store.liftedStore.getState().nextActionId).toBe(3); 661 | expect(store.getState()).toBe(2); 662 | }); 663 | it('should start locked', () => { 664 | store = createStore(counter, instrument(undefined, { shouldStartLocked: true })); 665 | store.dispatch({ type: 'INCREMENT' }); 666 | expect(store.liftedStore.getState().isLocked).toBe(true); 667 | expect(store.liftedStore.getState().nextActionId).toBe(1); 668 | expect(store.getState()).toBe(0); 669 | 670 | const savedActions = [{ type: 'INCREMENT' }, { type: 'INCREMENT' }]; 671 | store.liftedStore.dispatch(ActionCreators.importState(savedActions)); 672 | expect(store.liftedStore.getState().nextActionId).toBe(3); 673 | expect(store.getState()).toBe(2); 674 | 675 | store.liftedStore.dispatch({ type: 'LOCK_CHANGES', status: false }); 676 | expect(store.liftedStore.getState().isLocked).toBe(false); 677 | 678 | store.dispatch({ type: 'INCREMENT' }); 679 | expect(store.liftedStore.getState().nextActionId).toBe(4); 680 | expect(store.getState()).toBe(3); 681 | }); 682 | }); 683 | 684 | describe('Pause recording', () => { 685 | it('should pause', () => { 686 | expect(store.liftedStore.getState().isPaused).toBe(false); 687 | store.dispatch({ type: 'INCREMENT' }); 688 | store.dispatch({ type: 'INCREMENT' }); 689 | expect(store.liftedStore.getState().nextActionId).toBe(3); 690 | expect(store.getState()).toBe(2); 691 | 692 | store.liftedStore.dispatch(ActionCreators.pauseRecording(true)); 693 | expect(store.liftedStore.getState().isPaused).toBe(true); 694 | expect(store.liftedStore.getState().nextActionId).toBe(1); 695 | expect(store.liftedStore.getState().actionsById[0].action).toEqual({ type: '@@INIT' }); 696 | expect(store.getState()).toBe(2); 697 | 698 | store.dispatch({ type: 'INCREMENT' }); 699 | store.dispatch({ type: 'INCREMENT' }); 700 | expect(store.liftedStore.getState().nextActionId).toBe(1); 701 | expect(store.liftedStore.getState().actionsById[0].action).toEqual({ type: '@@INIT' }); 702 | expect(store.getState()).toBe(4); 703 | 704 | store.liftedStore.dispatch(ActionCreators.pauseRecording(false)); 705 | expect(store.liftedStore.getState().isPaused).toBe(false); 706 | 707 | store.dispatch({ type: 'INCREMENT' }); 708 | store.dispatch({ type: 'INCREMENT' }); 709 | expect(store.liftedStore.getState().nextActionId).toBe(3); 710 | expect(store.liftedStore.getState().actionsById[2].action).toEqual({ type: 'INCREMENT' }); 711 | expect(store.getState()).toBe(6); 712 | }); 713 | it('should maintain the history while paused', () => { 714 | store = createStore(counter, instrument(undefined, { pauseActionType: '@@PAUSED' })); 715 | store.dispatch({ type: 'INCREMENT' }); 716 | store.dispatch({ type: 'INCREMENT' }); 717 | expect(store.getState()).toBe(2); 718 | expect(store.liftedStore.getState().nextActionId).toBe(3); 719 | expect(store.liftedStore.getState().isPaused).toBe(false); 720 | 721 | store.liftedStore.dispatch(ActionCreators.pauseRecording(true)); 722 | expect(store.liftedStore.getState().isPaused).toBe(true); 723 | expect(store.liftedStore.getState().nextActionId).toBe(4); 724 | expect(store.getState()).toBe(2); 725 | 726 | store.dispatch({ type: 'INCREMENT' }); 727 | expect(store.liftedStore.getState().nextActionId).toBe(4); 728 | store.dispatch({ type: 'INCREMENT' }); 729 | expect(store.liftedStore.getState().nextActionId).toBe(4); 730 | expect(store.getState()).toBe(4); 731 | 732 | store.liftedStore.dispatch(ActionCreators.pauseRecording(false)); 733 | expect(store.liftedStore.getState().isPaused).toBe(false); 734 | expect(store.liftedStore.getState().nextActionId).toBe(1); 735 | expect(store.getState()).toBe(4); 736 | store.dispatch({ type: 'INCREMENT' }); 737 | expect(store.liftedStore.getState().nextActionId).toBe(2); 738 | expect(store.getState()).toBe(5); 739 | 740 | store.liftedStore.dispatch(ActionCreators.commit()); 741 | store.liftedStore.dispatch(ActionCreators.pauseRecording(true)); 742 | store.dispatch({ type: 'INCREMENT' }); 743 | expect(store.liftedStore.getState().nextActionId).toBe(1); 744 | expect(store.getState()).toBe(6); 745 | }); 746 | }); 747 | 748 | it('throws if reducer is not a function', () => { 749 | expect(() => 750 | createStore(undefined, instrument()) 751 | ).toThrow('Expected the reducer to be a function.'); 752 | }); 753 | 754 | it('warns if the reducer is not a function but has a default field that is', () => { 755 | expect(() => 756 | createStore(({ 'default': () => {} }), instrument()) 757 | ).toThrow( 758 | 'Expected the reducer to be a function. ' + 759 | 'Instead got an object with a "default" field. ' + 760 | 'Did you pass a module instead of the default export? ' + 761 | 'Try passing require(...).default instead.' 762 | ); 763 | }); 764 | 765 | it('throws if there are more than one instrument enhancer included', () => { 766 | expect(() => { 767 | createStore(counter, compose(instrument(), instrument())); 768 | }).toThrow( 769 | 'DevTools instrumentation should not be applied more than once. ' + 770 | 'Check your store configuration.' 771 | ); 772 | }); 773 | }); 774 | --------------------------------------------------------------------------------