diff --git a/docs/Walkthrough.md b/docs/Walkthrough.md index 11aac84d..0a1a6168 100644 --- a/docs/Walkthrough.md +++ b/docs/Walkthrough.md @@ -370,6 +370,8 @@ Note that there are no useful props you can pass to the `DevTools` component oth * **It is important that `DevTools.instrument()` store enhancer should be added to your middleware stack *after* `applyMiddleware` in the `compose`d functions, as `applyMiddleware` is potentially asynchronous.** Otherwise, DevTools won’t see the raw actions emitted by asynchronous middleware such as [redux-promise](https://github.com/acdlite/redux-promise) or [redux-thunk](https://github.com/gaearon/redux-thunk). +* **Are you a library author**? If you're building a Store Enhancer you might find handy to know which actions have been dispatched regularly and which have been replayed. `redux-devtools` is calling reducer with third argument. The argument is a boolean identifying that Action has been replayed. + ### What Next? Now that you see the DevTools, you might want to learn what those buttons mean and how to use them. This usually depends on the monitor. You can begin by exploring the [LogMonitor](https://github.com/gaearon/redux-devtools-log-monitor) and [DockMonitor](https://github.com/gaearon/redux-devtools-dock-monitor) documentation, as they are the default monitors we suggest to use together. When you’re comfortable using them, you may want to create your own monitor for more exotic purposes, such as a [chart](https://github.com/romseguy/redux-devtools-chart-monitor) or a [diff](https://github.com/whetstone/redux-devtools-diff-monitor) monitor. Don’t forget to send a PR to feature your monitor at the front page! diff --git a/src/instrument.js b/src/instrument.js index 60d88762..56e1fe33 100644 --- a/src/instrument.js +++ b/src/instrument.js @@ -65,7 +65,7 @@ const INIT_ACTION = { type: '@@INIT' }; /** * Computes the next entry in the log by applying an action. */ -function computeNextEntry(reducer, action, state, error) { +function computeNextEntry(reducer, action, state, error, replaying) { if (error) { return { state, @@ -76,7 +76,7 @@ function computeNextEntry(reducer, action, state, error) { let nextState = state; let nextError; try { - nextState = reducer(state, action); + nextState = reducer(state, { ...action, replaying }); } catch (err) { nextError = err.toString(); if (typeof window === 'object' && typeof window.chrome !== 'undefined') { @@ -103,7 +103,8 @@ function recomputeStates( committedState, actionsById, stagedActionIds, - skippedActionIds + skippedActionIds, + replaying ) { // Optimization: exit early and return the same reference // if we know nothing could have changed. @@ -126,7 +127,7 @@ function recomputeStates( const shouldSkip = skippedActionIds.indexOf(actionId) > -1; const entry = shouldSkip ? previousEntry : - computeNextEntry(reducer, action, previousState, previousError); + computeNextEntry(reducer, action, previousState, previousError, replaying); nextComputedStates.push(entry); } @@ -201,6 +202,10 @@ function liftReducerWith(reducer, initialCommittedState, monitorReducer, options // value whenever we feel like we don't have to recompute the states. let minInvalidatedStateIndex = 0; + // For now, potentially any action except PERFORM_ACTION is considered + // as replay + let replaying = true; + switch (liftedAction.type) { case ActionTypes.RESET: { // Get back to the state the store was created with. @@ -297,6 +302,10 @@ function liftReducerWith(reducer, initialCommittedState, monitorReducer, options stagedActionIds = [...stagedActionIds, actionId]; // Optimization: we know that only the new action needs computing. minInvalidatedStateIndex = stagedActionIds.length - 1; + + // This is the first time Action is actually performed, therefore + // we don't consider this replay + replaying = false; break; } case ActionTypes.IMPORT_STATE: { @@ -314,6 +323,8 @@ function liftReducerWith(reducer, initialCommittedState, monitorReducer, options break; } case '@@redux/INIT': { + replaying = false; + // Always recompute states on hot reload and init. minInvalidatedStateIndex = 0; @@ -352,7 +363,8 @@ function liftReducerWith(reducer, initialCommittedState, monitorReducer, options committedState, actionsById, stagedActionIds, - skippedActionIds + skippedActionIds, + replaying ); monitorState = monitorReducer(monitorState, liftedAction); return { diff --git a/test/instrument.spec.js b/test/instrument.spec.js index 919b5b28..ffe806b1 100644 --- a/test/instrument.spec.js +++ b/test/instrument.spec.js @@ -1,4 +1,4 @@ -import expect, { spyOn } from 'expect'; +import expect, { createSpy, spyOn } from 'expect'; import { createStore, compose } from 'redux'; import instrument, { ActionCreators } from '../src/instrument'; @@ -542,4 +542,87 @@ describe('instrument', () => { 'Check your store configuration.' ); }); + + describe('replaying flag', () => { + const TESTING_ACTION = { type: 'TESTING_ACTION' }; + const INIT_ACTION = { type: '@@INIT' }; + const TESTING_APP_STATE = 42; + + const buildTestingAction = replaying => ({ ...TESTING_ACTION, replaying }); + const buildInitAction = replaying => ({ ...INIT_ACTION, replaying }); + + let spiedEmptyReducer; + let replayingStore; + let liftedReplayingStore; + + beforeEach(() => { + spiedEmptyReducer = createSpy(function emptyReducer(appState = TESTING_APP_STATE) { + return appState; + }).andCallThrough(); + replayingStore = createStore(spiedEmptyReducer, instrument()); + liftedReplayingStore = replayingStore.liftedStore; + }); + + it('should provide falsy replaying flag when plain action is dispatched', () => { + replayingStore.dispatch(TESTING_ACTION); + expect(spiedEmptyReducer).toHaveBeenCalled(); + expect(spiedEmptyReducer.calls[1].arguments).toEqual([TESTING_APP_STATE, buildTestingAction(false)]); + }); + + it('should provide falsy replaying flag when PERFORM_ACTION is dispatched', () => { + replayingStore.dispatch(TESTING_ACTION); + liftedReplayingStore.dispatch(ActionCreators.performAction(TESTING_ACTION)); + expect(spiedEmptyReducer.calls[1].arguments).toEqual([TESTING_APP_STATE, buildTestingAction(false)]); + }); + + it('should provide truthy replaying flag for init action which follows rollback', () => { + replayingStore.dispatch(TESTING_ACTION); + liftedReplayingStore.dispatch(ActionCreators.rollback()); + expect(spiedEmptyReducer.calls[2].arguments).toEqual([undefined, buildInitAction(true)]); + }); + + it('should provide truthy replaying flag for init action which follows reset', () => { + replayingStore.dispatch(TESTING_ACTION); + liftedReplayingStore.dispatch(ActionCreators.reset()); + expect(spiedEmptyReducer.calls[2].arguments).toEqual([undefined, buildInitAction(true)]); + }); + + it('should provide truthy replaying flag for init action which follows commit', () => { + replayingStore.dispatch(TESTING_ACTION); + liftedReplayingStore.dispatch(ActionCreators.commit()); + expect(spiedEmptyReducer.calls[2].arguments).toEqual([42, buildInitAction(true)]); + }); + + it('should provide truthy replaying flag for all the actions after sweeping', () => { + replayingStore.dispatch(TESTING_ACTION); + liftedReplayingStore.dispatch(ActionCreators.sweep()); + expect(spiedEmptyReducer.calls[2].arguments).toEqual([undefined, buildInitAction(true)]); + expect(spiedEmptyReducer.calls[3].arguments).toEqual([TESTING_APP_STATE, buildTestingAction(true)]); + }); + + it('after toggling, should provide truthy replaying flag for action which has not been toggled', () => { + const NEXT_TESTING_ACTION = { type: 'NEXT_TESTING_ACTION' }; + + replayingStore.dispatch(TESTING_ACTION); + replayingStore.dispatch(NEXT_TESTING_ACTION); + liftedReplayingStore.dispatch(ActionCreators.toggleAction(1)); + expect(spiedEmptyReducer.calls[3].arguments).toEqual([TESTING_APP_STATE, { ...NEXT_TESTING_ACTION, replaying: true }]); + }); + + it('should provide truthy replaying flag for all the actions after importing state', () => { + replayingStore.dispatch(TESTING_ACTION); + const exportedState = liftedReplayingStore.getState(); + + const spiedImportStoreReducer = createSpy(function importReducer(appState = TESTING_APP_STATE) { + return appState; + }).andCallThrough(); + + const importStore = createStore(spiedImportStoreReducer, instrument()); + importStore.liftedStore.dispatch(ActionCreators.importState(exportedState)); + + expect(spiedImportStoreReducer.calls[0].arguments).toEqual([undefined, buildInitAction(false)]); + expect(spiedImportStoreReducer.calls[1].arguments).toEqual([undefined, buildInitAction(true)]); + expect(spiedImportStoreReducer.calls[2].arguments).toEqual([TESTING_APP_STATE, buildTestingAction(true)]); + }); + }); });