Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide information that action is being replayed #263

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/Walkthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,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!
22 changes: 17 additions & 5 deletions src/instrument.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,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,
Expand All @@ -70,7 +70,7 @@ function computeNextEntry(reducer, action, state, error) {
let nextState = state;
let nextError;
try {
nextState = reducer(state, action);
nextState = reducer(state, { ...action, replaying });
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am in favour of keeping the original reference untouched, of course it is a performance penalty.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the user already has a meaning for replaying field? Can this be a Symbol instead?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been thinking about serializability. However, it does make sense to ignore that field when actions are serialized anyway.

Agreed that the naming clash can potentially be an issue, will re-work this.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gaearon Only issue I am seeing here is that IsReplaying Symbol would have to be exposed by redux-devtools. However, that would mean that any library which relies on the flag, would need to define redux-devtools as peerDependency.

Not sure if that's acceptable.

} catch (err) {
nextError = err.toString();
if (typeof window === 'object' && typeof window.chrome !== 'undefined') {
Expand All @@ -97,7 +97,8 @@ function recomputeStates(
committedState,
actionsById,
stagedActionIds,
skippedActionIds
skippedActionIds,
replaying
) {
// Optimization: exit early and return the same reference
// if we know nothing could have changed.
Expand All @@ -120,7 +121,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);
}
Expand Down Expand Up @@ -170,6 +171,10 @@ function liftReducerWith(reducer, initialCommittedState, monitorReducer) {
// 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.
Expand Down Expand Up @@ -245,6 +250,10 @@ function liftReducerWith(reducer, initialCommittedState, monitorReducer) {
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: {
Expand All @@ -262,6 +271,8 @@ function liftReducerWith(reducer, initialCommittedState, monitorReducer) {
break;
}
case '@@redux/INIT': {
replaying = false;

// Always recompute states on hot reload and init.
minInvalidatedStateIndex = 0;
break;
Expand All @@ -281,7 +292,8 @@ function liftReducerWith(reducer, initialCommittedState, monitorReducer) {
committedState,
actionsById,
stagedActionIds,
skippedActionIds
skippedActionIds,
replaying
);
monitorState = monitorReducer(monitorState, liftedAction);
return {
Expand Down
85 changes: 84 additions & 1 deletion test/instrument.spec.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -356,4 +356,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)]);
});
});
});