diff --git a/package.json b/package.json index 89c33953..9a03502c 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "dependencies": { "lodash": "^4.2.0", "react-redux": "^4.0.0", - "redux": "^3.2.1" + "redux": "^3.2.1", + "redux-devtools-instrument": "^1.0.0" } } diff --git a/src/createDevTools.js b/src/createDevTools.js index 41ad6f54..f9662e77 100644 --- a/src/createDevTools.js +++ b/src/createDevTools.js @@ -1,6 +1,6 @@ import React, { Children, Component, PropTypes } from 'react'; import { connect } from 'react-redux'; -import instrument from './instrument'; +import instrument from 'redux-devtools-instrument'; export default function createDevTools(children) { const monitorElement = Children.only(children); diff --git a/src/index.js b/src/index.js index f302dc55..dd8692bf 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,3 @@ -export { default as instrument, ActionCreators, ActionTypes } from './instrument'; +export { default as instrument, ActionCreators, ActionTypes } from 'redux-devtools-instrument'; export { default as persistState } from './persistState'; export { default as createDevTools } from './createDevTools'; diff --git a/src/instrument.js b/src/instrument.js deleted file mode 100644 index 7ff63a2f..00000000 --- a/src/instrument.js +++ /dev/null @@ -1,450 +0,0 @@ -import difference from 'lodash/difference'; -import union from 'lodash/union'; - -export const ActionTypes = { - PERFORM_ACTION: 'PERFORM_ACTION', - RESET: 'RESET', - ROLLBACK: 'ROLLBACK', - COMMIT: 'COMMIT', - SWEEP: 'SWEEP', - TOGGLE_ACTION: 'TOGGLE_ACTION', - SET_ACTIONS_ACTIVE: 'SET_ACTIONS_ACTIVE', - JUMP_TO_STATE: 'JUMP_TO_STATE', - IMPORT_STATE: 'IMPORT_STATE' -}; - -/** - * Action creators to change the History state. - */ -export const ActionCreators = { - performAction(action) { - if (typeof action.type === 'undefined') { - throw new Error( - 'Actions may not have an undefined "type" property. ' + - 'Have you misspelled a constant?' - ); - } - return { type: ActionTypes.PERFORM_ACTION, action, timestamp: Date.now() }; - }, - - reset() { - return { type: ActionTypes.RESET, timestamp: Date.now() }; - }, - - rollback() { - return { type: ActionTypes.ROLLBACK, timestamp: Date.now() }; - }, - - commit() { - return { type: ActionTypes.COMMIT, timestamp: Date.now() }; - }, - - sweep() { - return { type: ActionTypes.SWEEP }; - }, - - toggleAction(id) { - return { type: ActionTypes.TOGGLE_ACTION, id }; - }, - - setActionsActive(start, end, active=true) { - return { type: ActionTypes.SET_ACTIONS_ACTIVE, start, end, active }; - }, - - jumpToState(index) { - return { type: ActionTypes.JUMP_TO_STATE, index }; - }, - - importState(nextLiftedState) { - return { type: ActionTypes.IMPORT_STATE, nextLiftedState }; - } -}; - -const INIT_ACTION = { type: '@@INIT' }; - -/** - * Computes the next entry in the log by applying an action. - */ -function computeNextEntry(reducer, action, state, error) { - if (error) { - return { - state, - error: 'Interrupted by an error up the chain' - }; - } - - let nextState = state; - let nextError; - try { - nextState = reducer(state, action); - } catch (err) { - nextError = err.toString(); - if (typeof window === 'object' && typeof window.chrome !== 'undefined') { - // In Chrome, rethrowing provides better source map support - setTimeout(() => { throw err; }); - } else { - console.error(err); - } - } - - return { - state: nextState, - error: nextError - }; -} - -/** - * Runs the reducer on invalidated actions to get a fresh computation log. - */ -function recomputeStates( - computedStates, - minInvalidatedStateIndex, - reducer, - committedState, - actionsById, - stagedActionIds, - skippedActionIds -) { - // Optimization: exit early and return the same reference - // if we know nothing could have changed. - if ( - minInvalidatedStateIndex >= computedStates.length && - computedStates.length === stagedActionIds.length - ) { - return computedStates; - } - - const nextComputedStates = computedStates.slice(0, minInvalidatedStateIndex); - for (let i = minInvalidatedStateIndex; i < stagedActionIds.length; i++) { - const actionId = stagedActionIds[i]; - const action = actionsById[actionId].action; - - const previousEntry = nextComputedStates[i - 1]; - const previousState = previousEntry ? previousEntry.state : committedState; - const previousError = previousEntry ? previousEntry.error : undefined; - - const shouldSkip = skippedActionIds.indexOf(actionId) > -1; - const entry = shouldSkip ? - previousEntry : - computeNextEntry(reducer, action, previousState, previousError); - - nextComputedStates.push(entry); - } - - return nextComputedStates; -} - -/** - * Lifts an app's action into an action on the lifted store. - */ -function liftAction(action) { - return ActionCreators.performAction(action); -} - -/** - * Creates a history state reducer from an app's reducer. - */ -function liftReducerWith(reducer, initialCommittedState, monitorReducer, options) { - const initialLiftedState = { - monitorState: monitorReducer(undefined, {}), - nextActionId: 1, - actionsById: { 0: liftAction(INIT_ACTION) }, - stagedActionIds: [0], - skippedActionIds: [], - committedState: initialCommittedState, - currentStateIndex: 0, - computedStates: [] - }; - - /** - * Manages how the history actions modify the history state. - */ - return (liftedState = initialLiftedState, liftedAction) => { - let { - monitorState, - actionsById, - nextActionId, - stagedActionIds, - skippedActionIds, - committedState, - currentStateIndex, - computedStates - } = liftedState; - - function commitExcessActions(n) { - // Auto-commits n-number of excess actions. - let excess = n; - let idsToDelete = stagedActionIds.slice(1, excess + 1); - - for (let i = 0; i < idsToDelete.length; i++) { - if (computedStates[i + 1].error) { - // Stop if error is found. Commit actions up to error. - excess = i; - idsToDelete = stagedActionIds.slice(1, excess + 1); - break; - } else { - delete actionsById[idsToDelete[i]]; - } - } - - skippedActionIds = skippedActionIds.filter(id => idsToDelete.indexOf(id) === -1); - stagedActionIds = [0, ...stagedActionIds.slice(excess + 1)]; - committedState = computedStates[excess].state; - computedStates = computedStates.slice(excess); - currentStateIndex = currentStateIndex > excess - ? currentStateIndex - excess - : 0; - } - - // By default, agressively recompute every state whatever happens. - // This has O(n) performance, so we'll override this to a sensible - // value whenever we feel like we don't have to recompute the states. - let minInvalidatedStateIndex = 0; - - switch (liftedAction.type) { - case ActionTypes.RESET: { - // Get back to the state the store was created with. - actionsById = { 0: liftAction(INIT_ACTION) }; - nextActionId = 1; - stagedActionIds = [0]; - skippedActionIds = []; - committedState = initialCommittedState; - currentStateIndex = 0; - computedStates = []; - break; - } - case ActionTypes.COMMIT: { - // Consider the last committed state the new starting point. - // Squash any staged actions into a single committed state. - actionsById = { 0: liftAction(INIT_ACTION) }; - nextActionId = 1; - stagedActionIds = [0]; - skippedActionIds = []; - committedState = computedStates[currentStateIndex].state; - currentStateIndex = 0; - computedStates = []; - break; - } - case ActionTypes.ROLLBACK: { - // Forget about any staged actions. - // Start again from the last committed state. - actionsById = { 0: liftAction(INIT_ACTION) }; - nextActionId = 1; - stagedActionIds = [0]; - skippedActionIds = []; - currentStateIndex = 0; - computedStates = []; - break; - } - case ActionTypes.TOGGLE_ACTION: { - // Toggle whether an action with given ID is skipped. - // Being skipped means it is a no-op during the computation. - const { id: actionId } = liftedAction; - const index = skippedActionIds.indexOf(actionId); - if (index === -1) { - skippedActionIds = [actionId, ...skippedActionIds]; - } else { - skippedActionIds = skippedActionIds.filter(id => id !== actionId); - } - // Optimization: we know history before this action hasn't changed - minInvalidatedStateIndex = stagedActionIds.indexOf(actionId); - break; - } - case ActionTypes.SET_ACTIONS_ACTIVE: { - // Toggle whether an action with given ID is skipped. - // Being skipped means it is a no-op during the computation. - const { start, end, active } = liftedAction; - const actionIds = []; - for (let i = start; i < end; i++) actionIds.push(i); - if (active) { - skippedActionIds = difference(skippedActionIds, actionIds); - } else { - skippedActionIds = union(skippedActionIds, actionIds); - } - - // Optimization: we know history before this action hasn't changed - minInvalidatedStateIndex = stagedActionIds.indexOf(start); - break; - } - case ActionTypes.JUMP_TO_STATE: { - // Without recomputing anything, move the pointer that tell us - // which state is considered the current one. Useful for sliders. - currentStateIndex = liftedAction.index; - // Optimization: we know the history has not changed. - minInvalidatedStateIndex = Infinity; - break; - } - case ActionTypes.SWEEP: { - // Forget any actions that are currently being skipped. - stagedActionIds = difference(stagedActionIds, skippedActionIds); - skippedActionIds = []; - currentStateIndex = Math.min(currentStateIndex, stagedActionIds.length - 1); - break; - } - case ActionTypes.PERFORM_ACTION: { - // Auto-commit as new actions come in. - if (options.maxAge && stagedActionIds.length === options.maxAge) { - commitExcessActions(1); - } - - if (currentStateIndex === stagedActionIds.length - 1) { - currentStateIndex++; - } - const actionId = nextActionId++; - // Mutation! This is the hottest path, and we optimize on purpose. - // It is safe because we set a new key in a cache dictionary. - actionsById[actionId] = liftedAction; - stagedActionIds = [...stagedActionIds, actionId]; - // Optimization: we know that only the new action needs computing. - minInvalidatedStateIndex = stagedActionIds.length - 1; - break; - } - case ActionTypes.IMPORT_STATE: { - // Completely replace everything. - ({ - monitorState, - actionsById, - nextActionId, - stagedActionIds, - skippedActionIds, - committedState, - currentStateIndex, - computedStates - } = liftedAction.nextLiftedState); - break; - } - case '@@redux/INIT': { - // Always recompute states on hot reload and init. - minInvalidatedStateIndex = 0; - - if (options.maxAge && stagedActionIds.length > options.maxAge) { - // States must be recomputed before committing excess. - computedStates = recomputeStates( - computedStates, - minInvalidatedStateIndex, - reducer, - committedState, - actionsById, - stagedActionIds, - skippedActionIds - ); - - commitExcessActions(stagedActionIds.length - options.maxAge); - - // Avoid double computation. - minInvalidatedStateIndex = Infinity; - } - - break; - } - default: { - // If the action is not recognized, it's a monitor action. - // Optimization: a monitor action can't change history. - minInvalidatedStateIndex = Infinity; - break; - } - } - - computedStates = recomputeStates( - computedStates, - minInvalidatedStateIndex, - reducer, - committedState, - actionsById, - stagedActionIds, - skippedActionIds - ); - monitorState = monitorReducer(monitorState, liftedAction); - return { - monitorState, - actionsById, - nextActionId, - stagedActionIds, - skippedActionIds, - committedState, - currentStateIndex, - computedStates - }; - }; -} - -/** - * Provides an app's view into the state of the lifted store. - */ -function unliftState(liftedState) { - const { computedStates, currentStateIndex } = liftedState; - const { state } = computedStates[currentStateIndex]; - return state; -} - -/** - * Provides an app's view into the lifted store. - */ -function unliftStore(liftedStore, liftReducer) { - let lastDefinedState; - - return { - ...liftedStore, - - liftedStore, - - dispatch(action) { - liftedStore.dispatch(liftAction(action)); - return action; - }, - - getState() { - const state = unliftState(liftedStore.getState()); - if (state !== undefined) { - lastDefinedState = state; - } - return lastDefinedState; - }, - - replaceReducer(nextReducer) { - liftedStore.replaceReducer(liftReducer(nextReducer)); - } - }; -} - -/** - * Redux instrumentation store enhancer. - */ -export default function instrument(monitorReducer = () => null, options = {}) { - /* eslint-disable no-eq-null */ - if (options.maxAge != null && options.maxAge < 2) { - /* eslint-enable */ - throw new Error( - 'DevTools.instrument({ maxAge }) option, if specified, ' + - 'may not be less than 2.' - ); - } - - return createStore => (reducer, initialState, enhancer) => { - - function liftReducer(r) { - if (typeof r !== 'function') { - if (r && typeof r.default === 'function') { - throw new Error( - 'Expected the reducer to be a function. ' + - 'Instead got an object with a "default" field. ' + - 'Did you pass a module instead of the default export? ' + - 'Try passing require(...).default instead.' - ); - } - throw new Error('Expected the reducer to be a function.'); - } - return liftReducerWith(r, initialState, monitorReducer, options); - } - - const liftedStore = createStore(liftReducer(reducer), enhancer); - if (liftedStore.liftedStore) { - throw new Error( - 'DevTools instrumentation should not be applied more than once. ' + - 'Check your store configuration.' - ); - } - - return unliftStore(liftedStore, liftReducer); - }; -} diff --git a/test/instrument.spec.js b/test/instrument.spec.js deleted file mode 100644 index bc8bfd24..00000000 --- a/test/instrument.spec.js +++ /dev/null @@ -1,551 +0,0 @@ -import expect, { spyOn } from 'expect'; -import { createStore, compose } from 'redux'; -import instrument, { ActionCreators } from '../src/instrument'; - -function counter(state = 0, action) { - switch (action.type) { - case 'INCREMENT': return state + 1; - case 'DECREMENT': return state - 1; - default: return state; - } -} - -function counterWithBug(state = 0, action) { - switch (action.type) { - case 'INCREMENT': return state + 1; - case 'DECREMENT': return mistake - 1; // eslint-disable-line no-undef - case 'SET_UNDEFINED': return undefined; - default: return state; - } -} - -function counterWithAnotherBug(state = 0, action) { - switch (action.type) { - case 'INCREMENT': return mistake + 1; // eslint-disable-line no-undef - case 'DECREMENT': return state - 1; - case 'SET_UNDEFINED': return undefined; - default: return state; - } -} - -function doubleCounter(state = 0, action) { - switch (action.type) { - case 'INCREMENT': return state + 2; - case 'DECREMENT': return state - 2; - default: return state; - } -} - -describe('instrument', () => { - let store; - let liftedStore; - - beforeEach(() => { - store = createStore(counter, instrument()); - liftedStore = store.liftedStore; - }); - - it('should perform actions', () => { - expect(store.getState()).toBe(0); - store.dispatch({ type: 'INCREMENT' }); - expect(store.getState()).toBe(1); - store.dispatch({ type: 'INCREMENT' }); - expect(store.getState()).toBe(2); - }); - - it('should rollback state to the last committed state', () => { - store.dispatch({ type: 'INCREMENT' }); - store.dispatch({ type: 'INCREMENT' }); - expect(store.getState()).toBe(2); - - liftedStore.dispatch(ActionCreators.commit()); - expect(store.getState()).toBe(2); - - store.dispatch({ type: 'INCREMENT' }); - store.dispatch({ type: 'INCREMENT' }); - expect(store.getState()).toBe(4); - - liftedStore.dispatch(ActionCreators.rollback()); - expect(store.getState()).toBe(2); - - store.dispatch({ type: 'DECREMENT' }); - expect(store.getState()).toBe(1); - - liftedStore.dispatch(ActionCreators.rollback()); - expect(store.getState()).toBe(2); - }); - - it('should reset to initial state', () => { - store.dispatch({ type: 'INCREMENT' }); - expect(store.getState()).toBe(1); - - liftedStore.dispatch(ActionCreators.commit()); - expect(store.getState()).toBe(1); - - store.dispatch({ type: 'INCREMENT' }); - expect(store.getState()).toBe(2); - - liftedStore.dispatch(ActionCreators.rollback()); - expect(store.getState()).toBe(1); - - store.dispatch({ type: 'INCREMENT' }); - expect(store.getState()).toBe(2); - - liftedStore.dispatch(ActionCreators.reset()); - expect(store.getState()).toBe(0); - }); - - it('should toggle an action', () => { - // actionId 0 = @@INIT - store.dispatch({ type: 'INCREMENT' }); - store.dispatch({ type: 'DECREMENT' }); - store.dispatch({ type: 'INCREMENT' }); - expect(store.getState()).toBe(1); - - liftedStore.dispatch(ActionCreators.toggleAction(2)); - expect(store.getState()).toBe(2); - - liftedStore.dispatch(ActionCreators.toggleAction(2)); - expect(store.getState()).toBe(1); - }); - - it('should set multiple action skip', () => { - // actionId 0 = @@INIT - store.dispatch({ type: 'INCREMENT' }); - store.dispatch({ type: 'INCREMENT' }); - store.dispatch({ type: 'INCREMENT' }); - expect(store.getState()).toBe(3); - - liftedStore.dispatch(ActionCreators.setActionsActive(1, 3, false)); - expect(store.getState()).toBe(1); - - liftedStore.dispatch(ActionCreators.setActionsActive(0, 2, true)); - expect(store.getState()).toBe(2); - - liftedStore.dispatch(ActionCreators.setActionsActive(0, 1, true)); - expect(store.getState()).toBe(2); - }); - - it('should sweep disabled actions', () => { - // actionId 0 = @@INIT - store.dispatch({ type: 'INCREMENT' }); - store.dispatch({ type: 'DECREMENT' }); - store.dispatch({ type: 'INCREMENT' }); - store.dispatch({ type: 'INCREMENT' }); - - expect(store.getState()).toBe(2); - expect(liftedStore.getState().stagedActionIds).toEqual([0, 1, 2, 3, 4]); - expect(liftedStore.getState().skippedActionIds).toEqual([]); - - liftedStore.dispatch(ActionCreators.toggleAction(2)); - expect(store.getState()).toBe(3); - expect(liftedStore.getState().stagedActionIds).toEqual([0, 1, 2, 3, 4]); - expect(liftedStore.getState().skippedActionIds).toEqual([2]); - - liftedStore.dispatch(ActionCreators.sweep()); - expect(store.getState()).toBe(3); - expect(liftedStore.getState().stagedActionIds).toEqual([0, 1, 3, 4]); - expect(liftedStore.getState().skippedActionIds).toEqual([]); - }); - - it('should jump to state', () => { - store.dispatch({ type: 'INCREMENT' }); - store.dispatch({ type: 'DECREMENT' }); - store.dispatch({ type: 'INCREMENT' }); - expect(store.getState()).toBe(1); - - liftedStore.dispatch(ActionCreators.jumpToState(0)); - expect(store.getState()).toBe(0); - - liftedStore.dispatch(ActionCreators.jumpToState(1)); - expect(store.getState()).toBe(1); - - liftedStore.dispatch(ActionCreators.jumpToState(2)); - expect(store.getState()).toBe(0); - - store.dispatch({ type: 'INCREMENT' }); - expect(store.getState()).toBe(0); - - liftedStore.dispatch(ActionCreators.jumpToState(4)); - expect(store.getState()).toBe(2); - }); - - it('should replace the reducer', () => { - store.dispatch({ type: 'INCREMENT' }); - store.dispatch({ type: 'DECREMENT' }); - store.dispatch({ type: 'INCREMENT' }); - expect(store.getState()).toBe(1); - - store.replaceReducer(doubleCounter); - expect(store.getState()).toBe(2); - }); - - it('should catch and record errors', () => { - let spy = spyOn(console, 'error'); - let storeWithBug = createStore(counterWithBug, instrument()); - - storeWithBug.dispatch({ type: 'INCREMENT' }); - storeWithBug.dispatch({ type: 'DECREMENT' }); - storeWithBug.dispatch({ type: 'INCREMENT' }); - - let { computedStates } = storeWithBug.liftedStore.getState(); - expect(computedStates[2].error).toMatch( - /ReferenceError/ - ); - expect(computedStates[3].error).toMatch( - /Interrupted by an error up the chain/ - ); - expect(spy.calls[0].arguments[0].toString()).toMatch( - /ReferenceError/ - ); - - spy.restore(); - }); - - it('should catch invalid action type', () => { - expect(() => { - store.dispatch({ type: undefined }); - }).toThrow( - 'Actions may not have an undefined "type" property. ' + - 'Have you misspelled a constant?' - ); - }); - - it('should return the last non-undefined state from getState', () => { - let storeWithBug = createStore(counterWithBug, instrument()); - storeWithBug.dispatch({ type: 'INCREMENT' }); - storeWithBug.dispatch({ type: 'INCREMENT' }); - expect(storeWithBug.getState()).toBe(2); - - storeWithBug.dispatch({ type: 'SET_UNDEFINED' }); - expect(storeWithBug.getState()).toBe(2); - }); - - it('should not recompute states on every action', () => { - let reducerCalls = 0; - let monitoredStore = createStore(() => reducerCalls++, instrument()); - expect(reducerCalls).toBe(1); - monitoredStore.dispatch({ type: 'INCREMENT' }); - monitoredStore.dispatch({ type: 'INCREMENT' }); - monitoredStore.dispatch({ type: 'INCREMENT' }); - expect(reducerCalls).toBe(4); - }); - - it('should not recompute old states when toggling an action', () => { - let reducerCalls = 0; - let monitoredStore = createStore(() => reducerCalls++, instrument()); - let monitoredLiftedStore = monitoredStore.liftedStore; - - expect(reducerCalls).toBe(1); - // actionId 0 = @@INIT - monitoredStore.dispatch({ type: 'INCREMENT' }); - monitoredStore.dispatch({ type: 'INCREMENT' }); - monitoredStore.dispatch({ type: 'INCREMENT' }); - expect(reducerCalls).toBe(4); - - monitoredLiftedStore.dispatch(ActionCreators.toggleAction(3)); - expect(reducerCalls).toBe(4); - - monitoredLiftedStore.dispatch(ActionCreators.toggleAction(3)); - expect(reducerCalls).toBe(5); - - monitoredLiftedStore.dispatch(ActionCreators.toggleAction(2)); - expect(reducerCalls).toBe(6); - - monitoredLiftedStore.dispatch(ActionCreators.toggleAction(2)); - expect(reducerCalls).toBe(8); - - monitoredLiftedStore.dispatch(ActionCreators.toggleAction(1)); - expect(reducerCalls).toBe(10); - - monitoredLiftedStore.dispatch(ActionCreators.toggleAction(2)); - expect(reducerCalls).toBe(11); - - monitoredLiftedStore.dispatch(ActionCreators.toggleAction(3)); - expect(reducerCalls).toBe(11); - - monitoredLiftedStore.dispatch(ActionCreators.toggleAction(1)); - expect(reducerCalls).toBe(12); - - monitoredLiftedStore.dispatch(ActionCreators.toggleAction(3)); - expect(reducerCalls).toBe(13); - - monitoredLiftedStore.dispatch(ActionCreators.toggleAction(2)); - expect(reducerCalls).toBe(15); - }); - - it('should not recompute states when jumping to state', () => { - let reducerCalls = 0; - let monitoredStore = createStore(() => reducerCalls++, instrument()); - let monitoredLiftedStore = monitoredStore.liftedStore; - - expect(reducerCalls).toBe(1); - monitoredStore.dispatch({ type: 'INCREMENT' }); - monitoredStore.dispatch({ type: 'INCREMENT' }); - monitoredStore.dispatch({ type: 'INCREMENT' }); - expect(reducerCalls).toBe(4); - - let savedComputedStates = monitoredLiftedStore.getState().computedStates; - - monitoredLiftedStore.dispatch(ActionCreators.jumpToState(0)); - expect(reducerCalls).toBe(4); - - monitoredLiftedStore.dispatch(ActionCreators.jumpToState(1)); - expect(reducerCalls).toBe(4); - - monitoredLiftedStore.dispatch(ActionCreators.jumpToState(3)); - expect(reducerCalls).toBe(4); - - expect(monitoredLiftedStore.getState().computedStates).toBe(savedComputedStates); - }); - - it('should not recompute states on monitor actions', () => { - let reducerCalls = 0; - let monitoredStore = createStore(() => reducerCalls++, instrument()); - let monitoredLiftedStore = monitoredStore.liftedStore; - - expect(reducerCalls).toBe(1); - monitoredStore.dispatch({ type: 'INCREMENT' }); - monitoredStore.dispatch({ type: 'INCREMENT' }); - monitoredStore.dispatch({ type: 'INCREMENT' }); - expect(reducerCalls).toBe(4); - - let savedComputedStates = monitoredLiftedStore.getState().computedStates; - - monitoredLiftedStore.dispatch({ type: 'lol' }); - expect(reducerCalls).toBe(4); - - monitoredLiftedStore.dispatch({ type: 'wat' }); - expect(reducerCalls).toBe(4); - - expect(monitoredLiftedStore.getState().computedStates).toBe(savedComputedStates); - }); - - describe('maxAge option', () => { - let configuredStore; - let configuredLiftedStore; - - beforeEach(() => { - configuredStore = createStore(counter, instrument(undefined, { maxAge: 3 })); - configuredLiftedStore = configuredStore.liftedStore; - }); - - it('should auto-commit earliest non-@@INIT action when maxAge is reached', () => { - configuredStore.dispatch({ type: 'INCREMENT' }); - configuredStore.dispatch({ type: 'INCREMENT' }); - let liftedStoreState = configuredLiftedStore.getState(); - - expect(configuredStore.getState()).toBe(2); - expect(Object.keys(liftedStoreState.actionsById).length).toBe(3); - expect(liftedStoreState.committedState).toBe(undefined); - expect(liftedStoreState.stagedActionIds).toInclude(1); - - // Trigger auto-commit. - configuredStore.dispatch({ type: 'INCREMENT' }); - liftedStoreState = configuredLiftedStore.getState(); - - expect(configuredStore.getState()).toBe(3); - expect(Object.keys(liftedStoreState.actionsById).length).toBe(3); - expect(liftedStoreState.stagedActionIds).toExclude(1); - expect(liftedStoreState.computedStates[0].state).toBe(1); - expect(liftedStoreState.committedState).toBe(1); - expect(liftedStoreState.currentStateIndex).toBe(2); - }); - - it('should remove skipped actions once committed', () => { - configuredStore.dispatch({ type: 'INCREMENT' }); - configuredLiftedStore.dispatch(ActionCreators.toggleAction(1)); - configuredStore.dispatch({ type: 'INCREMENT' }); - expect(configuredLiftedStore.getState().skippedActionIds).toInclude(1); - configuredStore.dispatch({ type: 'INCREMENT' }); - expect(configuredLiftedStore.getState().skippedActionIds).toExclude(1); - }); - - it('should not auto-commit errors', () => { - let spy = spyOn(console, 'error'); - - let storeWithBug = createStore(counterWithBug, instrument(undefined, { maxAge: 3 })); - let liftedStoreWithBug = storeWithBug.liftedStore; - storeWithBug.dispatch({ type: 'DECREMENT' }); - storeWithBug.dispatch({ type: 'INCREMENT' }); - expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(3); - - storeWithBug.dispatch({ type: 'INCREMENT' }); - expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(4); - - spy.restore(); - }); - - it('should auto-commit actions after hot reload fixes error', () => { - let spy = spyOn(console, 'error'); - - let storeWithBug = createStore(counterWithBug, instrument(undefined, { maxAge: 3 })); - let liftedStoreWithBug = storeWithBug.liftedStore; - storeWithBug.dispatch({ type: 'DECREMENT' }); - storeWithBug.dispatch({ type: 'DECREMENT' }); - storeWithBug.dispatch({ type: 'INCREMENT' }); - storeWithBug.dispatch({ type: 'DECREMENT' }); - storeWithBug.dispatch({ type: 'DECREMENT' }); - storeWithBug.dispatch({ type: 'DECREMENT' }); - expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(7); - - // Auto-commit 2 actions by "fixing" reducer bug, but introducing another. - storeWithBug.replaceReducer(counterWithAnotherBug); - expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(5); - - // Auto-commit 2 more actions by "fixing" other reducer bug. - storeWithBug.replaceReducer(counter); - expect(liftedStoreWithBug.getState().stagedActionIds.length).toBe(3); - - spy.restore(); - }); - - it('should update currentStateIndex when auto-committing', () => { - let liftedStoreState; - let currentComputedState; - - configuredStore.dispatch({ type: 'INCREMENT' }); - configuredStore.dispatch({ type: 'INCREMENT' }); - liftedStoreState = configuredLiftedStore.getState(); - expect(liftedStoreState.currentStateIndex).toBe(2); - - // currentStateIndex should stay at 2 as actions are committed. - configuredStore.dispatch({ type: 'INCREMENT' }); - liftedStoreState = configuredLiftedStore.getState(); - currentComputedState = liftedStoreState.computedStates[liftedStoreState.currentStateIndex]; - expect(liftedStoreState.currentStateIndex).toBe(2); - expect(currentComputedState.state).toBe(3); - }); - - it('should continue to increment currentStateIndex while error blocks commit', () => { - let spy = spyOn(console, 'error'); - - let storeWithBug = createStore(counterWithBug, instrument(undefined, { maxAge: 3 })); - let liftedStoreWithBug = storeWithBug.liftedStore; - - storeWithBug.dispatch({ type: 'DECREMENT' }); - storeWithBug.dispatch({ type: 'DECREMENT' }); - storeWithBug.dispatch({ type: 'DECREMENT' }); - storeWithBug.dispatch({ type: 'DECREMENT' }); - - let liftedStoreState = liftedStoreWithBug.getState(); - let currentComputedState = liftedStoreState.computedStates[liftedStoreState.currentStateIndex]; - expect(liftedStoreState.currentStateIndex).toBe(4); - expect(currentComputedState.state).toBe(0); - expect(currentComputedState.error).toExist(); - - spy.restore(); - }); - - it('should adjust currentStateIndex correctly when multiple actions are committed', () => { - let spy = spyOn(console, 'error'); - - let storeWithBug = createStore(counterWithBug, instrument(undefined, { maxAge: 3 })); - let liftedStoreWithBug = storeWithBug.liftedStore; - - storeWithBug.dispatch({ type: 'DECREMENT' }); - storeWithBug.dispatch({ type: 'DECREMENT' }); - storeWithBug.dispatch({ type: 'DECREMENT' }); - storeWithBug.dispatch({ type: 'DECREMENT' }); - - // Auto-commit 2 actions by "fixing" reducer bug. - storeWithBug.replaceReducer(counter); - let liftedStoreState = liftedStoreWithBug.getState(); - let currentComputedState = liftedStoreState.computedStates[liftedStoreState.currentStateIndex]; - expect(liftedStoreState.currentStateIndex).toBe(2); - expect(currentComputedState.state).toBe(-4); - - spy.restore(); - }); - - it('should not allow currentStateIndex to drop below 0', () => { - let spy = spyOn(console, 'error'); - - let storeWithBug = createStore(counterWithBug, instrument(undefined, { maxAge: 3 })); - let liftedStoreWithBug = storeWithBug.liftedStore; - - storeWithBug.dispatch({ type: 'DECREMENT' }); - storeWithBug.dispatch({ type: 'DECREMENT' }); - storeWithBug.dispatch({ type: 'DECREMENT' }); - storeWithBug.dispatch({ type: 'DECREMENT' }); - liftedStoreWithBug.dispatch(ActionCreators.jumpToState(1)); - - // Auto-commit 2 actions by "fixing" reducer bug. - storeWithBug.replaceReducer(counter); - let liftedStoreState = liftedStoreWithBug.getState(); - let currentComputedState = liftedStoreState.computedStates[liftedStoreState.currentStateIndex]; - expect(liftedStoreState.currentStateIndex).toBe(0); - expect(currentComputedState.state).toBe(-2); - - spy.restore(); - }); - - it('should throw error when maxAge < 2', () => { - expect(() => { - createStore(counter, instrument(undefined, { maxAge: 1 })); - }).toThrow(/may not be less than 2/); - }); - }); - - describe('Import State', () => { - let monitoredStore; - let monitoredLiftedStore; - let exportedState; - - beforeEach(() => { - monitoredStore = createStore(counter, instrument()); - monitoredLiftedStore = monitoredStore.liftedStore; - // Set up state to export - monitoredStore.dispatch({ type: 'INCREMENT' }); - monitoredStore.dispatch({ type: 'INCREMENT' }); - monitoredStore.dispatch({ type: 'INCREMENT' }); - - exportedState = monitoredLiftedStore.getState(); - }); - - it('should replay all the steps when a state is imported', () => { - let importMonitoredStore = createStore(counter, instrument()); - let importMonitoredLiftedStore = importMonitoredStore.liftedStore; - - importMonitoredLiftedStore.dispatch(ActionCreators.importState(exportedState)); - expect(importMonitoredLiftedStore.getState()).toEqual(exportedState); - }); - - it('should replace the existing action log with the one imported', () => { - let importMonitoredStore = createStore(counter, instrument()); - let importMonitoredLiftedStore = importMonitoredStore.liftedStore; - - importMonitoredStore.dispatch({ type: 'DECREMENT' }); - importMonitoredStore.dispatch({ type: 'DECREMENT' }); - - importMonitoredLiftedStore.dispatch(ActionCreators.importState(exportedState)); - expect(importMonitoredLiftedStore.getState()).toEqual(exportedState); - }); - }); - - it('throws if reducer is not a function', () => { - expect(() => - createStore(undefined, instrument()) - ).toThrow('Expected the reducer to be a function.'); - }); - - it('warns if the reducer is not a function but has a default field that is', () => { - expect(() => - createStore(({ 'default': () => {} }), instrument()) - ).toThrow( - 'Expected the reducer to be a function. ' + - 'Instead got an object with a "default" field. ' + - 'Did you pass a module instead of the default export? ' + - 'Try passing require(...).default instead.' - ); - }); - - it('throws if there are more than one instrument enhancer included', () => { - expect(() => { - createStore(counter, compose(instrument(), instrument())); - }).toThrow( - 'DevTools instrumentation should not be applied more than once. ' + - 'Check your store configuration.' - ); - }); -});