diff --git a/modules/store-devtools/spec/store.spec.ts b/modules/store-devtools/spec/store.spec.ts index c2136aea91..3108dd9af3 100644 --- a/modules/store-devtools/spec/store.spec.ts +++ b/modules/store-devtools/spec/store.spec.ts @@ -6,6 +6,7 @@ import { StateObservable, Store, StoreModule, + UPDATE, } from '@ngrx/store'; import { @@ -15,6 +16,7 @@ import { StoreDevtoolsOptions, } from '../'; import { IS_EXTENSION_OR_MONITOR_PRESENT } from '../src/instrument'; +import { PerformAction } from '../src/actions'; const counter = jasmine .createSpy('counter') @@ -615,15 +617,18 @@ describe('Store Devtools', () => { fixture.store.dispatch({ type: 'INCREMENT' }); fixture.store.dispatch({ type: 'INCREMENT' }); fixture.devtools.lockChanges(true); - expect(fixture.getLiftedState().isLocked).toBe(true); - expect(fixture.getLiftedState().nextActionId).toBe(3); - expect(fixture.getState()).toBe(2); }); afterEach(() => { fixture.cleanup(); }); + it('should update state correctly', () => { + expect(fixture.getLiftedState().isLocked).toBe(true); + expect(fixture.getLiftedState().nextActionId).toBe(3); + expect(fixture.getState()).toBe(2); + }); + it('should not accept changes during lock', () => { fixture.store.dispatch({ type: 'INCREMENT' }); expect(fixture.getLiftedState().nextActionId).toBe(3); @@ -655,4 +660,69 @@ describe('Store Devtools', () => { expect(fixture.getState()).toBe(3); }); }); + + describe('pause recording', () => { + let fixture: Fixture; + beforeEach(() => { + fixture = createStore(counter); + fixture.store.dispatch({ type: 'INCREMENT' }); + fixture.store.dispatch({ type: 'INCREMENT' }); + fixture.devtools.pauseRecording(true); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + it('should update pause correctly', () => { + expect(fixture.getLiftedState().isPaused).toBe(true); + fixture.devtools.pauseRecording(false); + expect(fixture.getLiftedState().isPaused).toBe(false); + }); + + it('should create a copy of the last state before pausing', () => { + const computedStates = fixture.getLiftedState().computedStates; + expect(computedStates.length).toBe(4); + expect(computedStates[3]).toEqual(computedStates[2]); + expect(fixture.getLiftedState().currentStateIndex).toBe(3); + expect(fixture.getState()).toBe(2); + }); + + it('should add pause action', () => { + const liftedState = fixture.getLiftedState(); + expect(liftedState.nextActionId).toBe(4); + expect(liftedState.actionsById[3].action.type).toEqual('@ngrx/devtools/pause'); + }); + + it('should overwrite last state during pause but keep action', () => { + fixture.store.dispatch({ type: 'DECREMENT' }); + const liftedState = fixture.getLiftedState(); + expect(liftedState.currentStateIndex).toBe(3); + expect(liftedState.computedStates.length).toBe(4); + expect(fixture.getState()).toEqual(1); + expect(liftedState.nextActionId).toBe(4); + expect(liftedState.actionsById[3].action.type).toEqual('@ngrx/devtools/pause'); + }); + + it('recomputation of states should preserve last state', () => { + fixture.devtools.jumpToState(1); + expect(fixture.getState()).toBe(1); + fixture.devtools.jumpToState(3); + expect(fixture.getState()).toBe(2); + fixture.devtools.toggleAction(1); + expect(fixture.getState()).toBe(2); + fixture.devtools.jumpToState(2); + expect(fixture.getState()).toBe(1); + }); + + it('reducer update should not be recorded but should still be applied', () => { + const oldComputedStates = fixture.getLiftedState().computedStates; + fixture.store.dispatch({ type: UPDATE }); + expect(fixture.getState()).toBe(2); + const liftedState = fixture.getLiftedState(); + expect(liftedState.nextActionId).toBe(4); + expect(liftedState.actionsById[3].action.type).toEqual('@ngrx/devtools/pause'); + expect(oldComputedStates).not.toBe(liftedState.computedStates); + }); + }); }); diff --git a/modules/store-devtools/src/actions.ts b/modules/store-devtools/src/actions.ts index 11d79f22dd..e1ff7754c5 100644 --- a/modules/store-devtools/src/actions.ts +++ b/modules/store-devtools/src/actions.ts @@ -11,6 +11,7 @@ export const JUMP_TO_STATE = 'JUMP_TO_STATE'; export const JUMP_TO_ACTION = 'JUMP_TO_ACTION'; export const IMPORT_STATE = 'IMPORT_STATE'; export const LOCK_CHANGES = 'LOCK_CHANGES'; +export const PAUSE_RECORDING = 'PAUSE_RECORDING'; export class PerformAction implements Action { readonly type = PERFORM_ACTION; @@ -87,6 +88,12 @@ export class LockChanges implements Action { constructor(public status: boolean) {} } +export class PauseRecording implements Action { + readonly type = PAUSE_RECORDING; + + constructor(public status: boolean) {} +} + export type All = | PerformAction | Reset @@ -98,4 +105,5 @@ export type All = | JumpToState | JumpToAction | ImportState - | LockChanges; + | LockChanges + | PauseRecording; diff --git a/modules/store-devtools/src/devtools.ts b/modules/store-devtools/src/devtools.ts index 1d6669c40f..68439ffd82 100644 --- a/modules/store-devtools/src/devtools.ts +++ b/modules/store-devtools/src/devtools.ts @@ -157,4 +157,8 @@ export class StoreDevtools implements Observer { lockChanges(status: boolean) { this.dispatch(new Actions.LockChanges(status)); } + + pauseRecording(status: boolean) { + this.dispatch(new Actions.PauseRecording(status)); + } } diff --git a/modules/store-devtools/src/extension.ts b/modules/store-devtools/src/extension.ts index 90adf00fe7..afaf71f3dc 100644 --- a/modules/store-devtools/src/extension.ts +++ b/modules/store-devtools/src/extension.ts @@ -69,7 +69,7 @@ export class DevtoolsExtension { // Check to see if the action requires a full update of the liftedState. // If it is a simple action generated by the user's app and the recording - // is not locked, only send the action and the current state (fast). + // is not locked/paused, only send the action and the current state (fast). // // A full liftedState update (slow: serializes the entire liftedState) is // only required when: @@ -80,7 +80,11 @@ export class DevtoolsExtension { // c) the state has been recomputed due to time-traveling // d) any action that is not a PerformAction to err on the side of // caution. - if (action.type === PERFORM_ACTION && !state.isLocked) { + if (action.type === PERFORM_ACTION) { + if (state.isLocked || state.isPaused) { + return; + } + const currentState = unliftState(state); const sanitizedState = this.config.stateSanitizer ? sanitizeState( diff --git a/modules/store-devtools/src/reducer.ts b/modules/store-devtools/src/reducer.ts index 46d63921c0..efb4de5e21 100644 --- a/modules/store-devtools/src/reducer.ts +++ b/modules/store-devtools/src/reducer.ts @@ -49,6 +49,7 @@ export interface LiftedState { currentStateIndex: number; computedStates: ComputedState[]; isLocked: boolean; + isPaused: boolean; } /** @@ -94,7 +95,8 @@ function recomputeStates( actionsById: LiftedActions, stagedActionIds: number[], skippedActionIds: number[], - errorHandler: ErrorHandler + errorHandler: ErrorHandler, + isPaused: boolean ) { // Optimization: exit early and return the same reference // if we know nothing could have changed. @@ -106,7 +108,10 @@ function recomputeStates( } const nextComputedStates = computedStates.slice(0, minInvalidatedStateIndex); - for (let i = minInvalidatedStateIndex; i < stagedActionIds.length; i++) { + // If the recording is paused, recompute all states up until the pause state, + // else recompute all states. + const lastIncludedActionId = stagedActionIds.length - (isPaused ? 1 : 0); + for (let i = minInvalidatedStateIndex; i < lastIncludedActionId; i++) { const actionId = stagedActionIds[i]; const action = actionsById[actionId].action; @@ -118,15 +123,20 @@ function recomputeStates( const entry: ComputedState = shouldSkip ? previousEntry : computeNextEntry( - reducer, - action, - previousState, - previousError, - errorHandler - ); + reducer, + action, + previousState, + previousError, + errorHandler + ); nextComputedStates.push(entry); } + // If the recording is paused, the last state will not be recomputed, + // because it's essentially not part of the state history. + if (isPaused) { + nextComputedStates.push(computedStates[computedStates.length - 1]); + } return nextComputedStates; } @@ -145,6 +155,7 @@ export function liftInitialState( currentStateIndex: 0, computedStates: [], isLocked: false, + isPaused: false, }; } @@ -174,6 +185,7 @@ export function liftReducerWith( currentStateIndex, computedStates, isLocked, + isPaused, } = liftedState || initialLiftedState; @@ -208,6 +220,18 @@ export function liftReducerWith( currentStateIndex > excess ? currentStateIndex - excess : 0; } + function commitChanges() { + // 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 = []; + } + // By default, aggressively 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. @@ -219,6 +243,31 @@ export function liftReducerWith( minInvalidatedStateIndex = Infinity; break; } + case Actions.PAUSE_RECORDING: { + isPaused = liftedAction.status; + if (isPaused) { + // Add a pause action to signal the devtools-user the recording is paused. + // The corresponding state will be overwritten on each update to always contain + // the latest state (see Actions.PERFORM_ACTION). + stagedActionIds = [...stagedActionIds, nextActionId]; + actionsById[nextActionId] = new PerformAction({ + type: '@ngrx/devtools/pause', + }, +Date.now()); + nextActionId++; + minInvalidatedStateIndex = stagedActionIds.length - 1; + computedStates = computedStates.concat( + computedStates[computedStates.length - 1] + ); + + if (currentStateIndex === stagedActionIds.length - 2) { + currentStateIndex++; + } + minInvalidatedStateIndex = Infinity; + } else { + commitChanges(); + } + break; + } case Actions.RESET: { // Get back to the state the store was created with. actionsById = { 0: liftAction(INIT_ACTION) }; @@ -231,15 +280,7 @@ export function liftReducerWith( break; } case Actions.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 = []; + commitChanges(); break; } case Actions.ROLLBACK: { @@ -315,6 +356,26 @@ export function liftReducerWith( return liftedState || initialLiftedState; } + if (isPaused) { + // If recording is paused, overwrite the last state + // (corresponds to the pause action) and keep everything else as is. + // This way, the app gets the new current state while the devtools + // do not record another action. + const lastState = computedStates[computedStates.length - 1]; + computedStates = [ + ...computedStates.slice(0, -1), + computeNextEntry( + reducer, + liftedAction.action, + lastState.state, + lastState.error, + errorHandler + ), + ]; + minInvalidatedStateIndex = Infinity; + break; + } + // Auto-commit as new actions come in. if (options.maxAge && stagedActionIds.length === options.maxAge) { commitExcessActions(1); @@ -345,6 +406,7 @@ export function liftReducerWith( currentStateIndex, computedStates, isLocked, + isPaused, } = liftedAction.nextLiftedState); break; } @@ -362,7 +424,8 @@ export function liftReducerWith( actionsById, stagedActionIds, skippedActionIds, - errorHandler + errorHandler, + isPaused ); commitExcessActions(stagedActionIds.length - options.maxAge); @@ -391,7 +454,8 @@ export function liftReducerWith( actionsById, stagedActionIds, skippedActionIds, - errorHandler + errorHandler, + isPaused ); commitExcessActions(stagedActionIds.length - options.maxAge); @@ -400,28 +464,32 @@ export function liftReducerWith( minInvalidatedStateIndex = Infinity; } } else { - if (currentStateIndex === stagedActionIds.length - 1) { - currentStateIndex++; - } + // If not paused/locked, add a new action to signal devtools-user + // that there was a reducer update. + if (!isPaused && !isLocked) { + if (currentStateIndex === stagedActionIds.length - 1) { + currentStateIndex++; + } - // Add a new action to only recompute state - const actionId = nextActionId++; - actionsById[actionId] = new PerformAction(liftedAction, +Date.now()); - stagedActionIds = [...stagedActionIds, actionId]; + // Add a new action to only recompute state + const actionId = nextActionId++; + actionsById[actionId] = new PerformAction(liftedAction, +Date.now()); + stagedActionIds = [...stagedActionIds, actionId]; - minInvalidatedStateIndex = stagedActionIds.length - 1; + minInvalidatedStateIndex = stagedActionIds.length - 1; - // States must be recomputed before committing excess. - computedStates = recomputeStates( - computedStates, - minInvalidatedStateIndex, - reducer, - committedState, - actionsById, - stagedActionIds, - skippedActionIds, - errorHandler - ); + computedStates = recomputeStates( + computedStates, + minInvalidatedStateIndex, + reducer, + committedState, + actionsById, + stagedActionIds, + skippedActionIds, + errorHandler, + isPaused + ); + } // Recompute state history with latest reducer and update action computedStates = computedStates.map(cmp => ({ @@ -429,7 +497,7 @@ export function liftReducerWith( state: reducer(cmp.state, liftedAction), })); - currentStateIndex = minInvalidatedStateIndex; + currentStateIndex = stagedActionIds.length - 1; if (options.maxAge && stagedActionIds.length > options.maxAge) { commitExcessActions(stagedActionIds.length - options.maxAge); @@ -457,7 +525,8 @@ export function liftReducerWith( actionsById, stagedActionIds, skippedActionIds, - errorHandler + errorHandler, + isPaused ); monitorState = monitorReducer(monitorState, liftedAction); @@ -471,6 +540,7 @@ export function liftReducerWith( currentStateIndex, computedStates, isLocked, + isPaused, }; }; }