UNPKG

redux-sigma

Version:

A state machine library for redux and redux-saga.

425 lines (416 loc) 14 kB
import produce from 'immer'; import { putResolve, actionChannel, take, put, call, fork, race, cancel, takeEvery } from 'redux-saga/effects'; import { buffers } from 'redux-saga'; const startStmActionType = '@@redux-sigma/start-stm'; const stmStartedActionType = '@@redux-sigma/stm-started'; const stopStmActionType = '@@redux-sigma/stop-stm'; const stmStoppedActionType = '@@redux-sigma/stm-stopped'; const storeStmStateActionType = '@@redux-sigma/store-state'; const storeStmContextActionType = '@@redux-sigma/store-context'; const REACTION_POLICY_FIRST = 'REACTION_POLICY_FIRST'; const REACTION_POLICY_LAST = 'REACTION_POLICY_LAST'; const REACTION_POLICY_ALL = 'REACTION_POLICY_ALL'; function isStateTransition(value) { return typeof value === 'string'; } function isGuardedTransition(value) { return 'guard' in value; } function isGuardedTransitionArray(value) { return value instanceof Array; } function isSimpleTransition(value) { return (!isStateTransition(value) && !isGuardedTransition(value) && !isGuardedTransitionArray(value)); } function isReactionSpec(value) { return 'policy' in value; } function isFunction(value) { return typeof value === 'function'; } function isStarted(storage) { return storage.state !== null; } class StateMachine { constructor() { this.runningTasks = []; this.start = (context) => { const initialContext = produce(null, () => context); return { type: startStmActionType, payload: { name: this.name, context: initialContext, }, }; }; this.stop = () => { return { type: stopStmActionType, payload: { name: this.name, }, }; }; this.started = (context) => { return { type: stmStartedActionType, payload: { name: this.name, context, }, }; }; this.stopped = () => { return { type: stmStoppedActionType, payload: { name: this.name, }, }; }; this.storeState = (state) => { return { type: storeStmStateActionType, payload: { name: this.name, state, }, }; }; this.storeContext = (context) => { return { type: storeStmContextActionType, payload: { name: this.name, context, }, }; }; this.stateReducer = (state = { state: null, context: undefined }, action) => { var _a; if (((_a = action.payload) === null || _a === void 0 ? void 0 : _a.name) !== this.name) { return state; } switch (action.type) { case stmStartedActionType: return { state: this.initialState, context: action.payload.context, }; case stopStmActionType: return { state: null, context: undefined, }; case storeStmContextActionType: if (!isStarted(state)) { return state; } else { return { state: state.state, context: action.payload.context, }; } case storeStmStateActionType: if (isStarted(state)) { return { state: action.payload.state, context: state.context, }; } else { return state; } default: return state; } }; } *setContext(newContext) { if (isFunction(newContext)) { this._context = produce(this._context, newContext); } else { this._context = produce(null, () => newContext); } yield putResolve(this.storeContext(this._context)); } get context() { return this._context; } *starterSaga() { const startChannel = (yield actionChannel((action) => action.type == startStmActionType && action.payload.name == this.name, buffers.sliding(1))); while (true) { const action = (yield take(startChannel)); yield put(this.started(action.payload.context)); yield call([this, this.run], action.payload.context); yield put(this.stopped()); } } *run(context) { this._context = context; this.currentState = this.initialState; const stopChannel = (yield actionChannel((action) => action.type == stopStmActionType && action.payload.name == this.name)); while (true) { const nextState = (yield call([this, this.stateLoop], stopChannel)); if (!nextState) { return; } if (nextState.command) { if (Array.isArray(nextState.command)) { for (const saga of nextState.command) { yield call([this, saga], nextState.event); } } else { yield call([this, nextState.command], nextState.event); } } this.currentState = nextState.nextState; yield put(this.storeState(this.currentState)); } } *stateLoop(stopChannel) { try { const { transitions } = this.spec[this.currentState]; const transitionEvents = transitions ? Object.keys(transitions) : []; this.transitionChannel = (yield actionChannel(transitionEvents)); this.runningTasks.push((yield fork([this, this.startOnEntryActivities]))); this.runningTasks.push((yield fork([this, this.registerToReactions]))); yield call([this, this.startSubMachines]); const { nextState } = (yield race({ nextState: call([this, this.getNextState]), stop: take(stopChannel), })); return nextState; } finally { yield call([this, this.cancelRunningTasks]); yield call([this, this.stopSubMachines]); yield call([this, this.runOnExitActivities]); } } *getNextState() { while (true) { const event = (yield take(this.transitionChannel)); const transitionSpec = this.spec[this.currentState].transitions[event.type]; if (isStateTransition(transitionSpec)) { return { event, nextState: transitionSpec, }; } else if (isSimpleTransition(transitionSpec)) { return { event, nextState: transitionSpec.target, command: transitionSpec.command, }; } else if (isGuardedTransition(transitionSpec)) { if (yield call(transitionSpec.guard, event, this.context)) { return { event, nextState: transitionSpec.target, command: transitionSpec.command, }; } } else { for (const transitionOption of transitionSpec) { if (yield call(transitionOption.guard, event, this.context)) return { event, nextState: transitionOption.target, command: transitionOption.command, }; } } } } *startOnEntryActivities() { const { onEntry } = this.spec[this.currentState]; if (onEntry) { if (Array.isArray(onEntry)) { for (const saga of onEntry) { this.runningTasks.push((yield fork([this, saga]))); } } else { this.runningTasks.push((yield fork([this, onEntry]))); } } } *registerToReactions() { const { reactions } = this.spec[this.currentState]; if (reactions) { const eventTypes = Object.keys(reactions); for (const eventType of eventTypes) { const reaction = reactions[eventType]; const [activity, policy] = isReactionSpec(reaction) ? [reaction.activity, reaction.policy] : [reaction, REACTION_POLICY_ALL]; let task; switch (policy) { case REACTION_POLICY_LAST: { task = (yield fork([this, this.takeLast], eventType, activity)); break; } case REACTION_POLICY_FIRST: { task = (yield fork([this, this.takeFirst], eventType, activity)); break; } case REACTION_POLICY_ALL: { task = (yield fork([this, this.takeAll], eventType, activity)); break; } } this.runningTasks.push(task); } } } *takeFirst(eventType, activity) { while (true) { const event = (yield take(eventType)); yield call([this, activity], event); } } *takeLast(eventType, activity) { const channel = (yield actionChannel(eventType)); let task = null; while (true) { const event = (yield take(channel)); if (task !== null) { yield cancel(task); } task = (yield fork([this, activity], event)); } } *takeAll(eventType, activity) { const channel = (yield actionChannel(eventType)); while (true) { const event = (yield take(channel)); yield call([this, activity], event); } } *cancelRunningTasks() { yield cancel(this.runningTasks); this.runningTasks = []; } *startSubMachines() { let { subMachines } = this.spec[this.currentState]; if (!subMachines) return; if (!Array.isArray(subMachines)) { subMachines = [subMachines]; } for (const subMachine of subMachines) { if ('stm' in subMachine) { const ctx = yield call([this, subMachine.contextBuilder]); yield put(subMachine.stm.start(ctx)); } else { yield put(subMachine.start({})); } } } *stopSubMachines() { let { subMachines } = this.spec[this.currentState]; if (!subMachines) return; if (!Array.isArray(subMachines)) { subMachines = [subMachines]; } for (const subMachine of subMachines) { if ('stm' in subMachine) { yield put(subMachine.stm.stop()); } else { yield put(subMachine.stop()); } } } *runOnExitActivities() { const { onExit } = this.spec[this.currentState]; if (onExit) { if (Array.isArray(onExit)) { for (const saga of onExit) { yield call([this, saga]); } } else { yield call([this, onExit]); } } } } function not(f) { return (...args) => !f(...args); } function and(...fs) { return function (...args) { return fs.every(f => f(...args)); }; } function or(...fs) { return function (...args) { return fs.some(f => f(...args)); }; } function all(activity) { return { activity, policy: REACTION_POLICY_ALL, }; } function last(activity) { return { activity, policy: REACTION_POLICY_LAST, }; } function first(activity) { return { activity, policy: REACTION_POLICY_FIRST, }; } function bindStm(stm, contextBuilder) { return { stm, contextBuilder, }; } function* reportUnknownStateMachine(action) { yield call(console.warn, `Unkwnown state machine ${action.payload.name}`); } function* stateMachineStarterSaga(...stms) { const duplicateStm = stms .map(stm => stm.name) .find((name, idx, arr) => arr.lastIndexOf(name) !== idx); if (duplicateStm) { throw new Error(`Duplicate STM detected with name ${duplicateStm}`); } for (const stm of stms) { yield fork([stm, stm.starterSaga]); } if (process.env.NODE_ENV !== 'production') { const stmNames = stms.map(stm => stm.name); yield takeEvery((action) => action.payload && action.payload.name && [ startStmActionType, stopStmActionType, storeStmContextActionType, storeStmStateActionType, ].includes(action.type) && !stmNames.includes(action.payload.name), reportUnknownStateMachine); } } export { StateMachine, all, and, bindStm, first, last, not, or, stateMachineStarterSaga };