UNPKG

redux-devtools-instrument

Version:
502 lines (447 loc) 14.7 kB
import difference from 'lodash/difference'; import union from 'lodash/union'; import isPlainObject from 'lodash/isPlainObject'; import $$observable from 'symbol-observable'; 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 (!isPlainObject(action)) { throw new Error( 'Actions must be plain objects. ' + 'Use custom middleware for async actions.' ); } 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, noRecompute) { return { type: ActionTypes.IMPORT_STATE, nextLiftedState, noRecompute }; } }; export const INIT_ACTION = { type: '@@INIT' }; /** * Computes the next entry in the log by applying an action. */ function computeNextEntry(reducer, action, state, shouldCatchErrors) { if (!shouldCatchErrors) { return { state: reducer(state, action) }; } 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, shouldCatchErrors ) { // Optimization: exit early and return the same reference // if we know nothing could have changed. if ( !computedStates || minInvalidatedStateIndex === -1 || (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 shouldSkip = skippedActionIds.indexOf(actionId) > -1; let entry; if (shouldSkip) { entry = previousEntry; } else { if (shouldCatchErrors && previousEntry && previousEntry.error) { entry = { state: previousState, error: 'Interrupted by an error up the chain' }; } else { entry = computeNextEntry(reducer, action, previousState, shouldCatchErrors); } } nextComputedStates.push(entry); } return nextComputedStates; } /** * Lifts an app's action into an action on the lifted store. */ export function liftAction(action) { return ActionCreators.performAction(action); } /** * Creates a history state reducer from an app's reducer. */ export 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, liftedAction) => { let { monitorState, actionsById, nextActionId, stagedActionIds, skippedActionIds, committedState, currentStateIndex, computedStates } = liftedState || initialLiftedState; if (!liftedState) { // Prevent mutating initialLiftedState actionsById = { ...actionsById }; } 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); if (liftedAction.noRecompute) { minInvalidatedStateIndex = Infinity; } 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, options.shouldCatchErrors ); 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, options.shouldCatchErrors ); 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. */ export function unliftState(liftedState) { const { computedStates, currentStateIndex } = liftedState; const { state } = computedStates[currentStateIndex]; return state; } /** * Provides an app's view into the lifted store. */ export function unliftStore(liftedStore, liftReducer) { let lastDefinedState; function getState() { const state = unliftState(liftedStore.getState()); if (state !== undefined) { lastDefinedState = state; } return lastDefinedState; } return { ...liftedStore, liftedStore, dispatch(action) { liftedStore.dispatch(liftAction(action)); return action; }, getState, replaceReducer(nextReducer) { liftedStore.replaceReducer(liftReducer(nextReducer)); }, [$$observable]() { return { ...liftedStore[$$observable](), subscribe(observer) { if (typeof observer !== 'object') { throw new TypeError('Expected the observer to be an object.'); } function observeState() { if (observer.next) { observer.next(getState()); } } observeState(); const unsubscribe = liftedStore.subscribe(observeState); return { unsubscribe }; } }; } }; } /** * 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); }; }