UNPKG

react-machine

Version:

A lightweight state machine for React applications

451 lines (378 loc) 11.7 kB
let nextEffectId const hookKeys = { guard: 'guards', reduce: 'reducers', effect: 'effects', } const transitionHooks = ['assign', 'reduce', 'invoke', 'effect', 'guard'] const enterHooks = ['assign', 'reduce', 'invoke', 'effect'] const exitHooks = ['assign', 'reduce', 'effect'] const mappedHooks = { assign: ['reduce', assignToReduce], invoke: ['effect', invokeToEffect], } const env = (process && process.env && process.env.NODE_ENV) || 'development' function warn(msg) { if (env !== 'production') { console.warn(msg) } } function arg(argument, type, error) { if (type === 'string') { if (typeof argument !== 'string') { throw new Error(error) } } } /** * Parse the machine DSL into a machine object. */ export function createMachine(create) { const machine = { initial: defaultInitial, nodes: {} } if (create) { // restart the auto incrementing id nextEffectId = 1 create({ state: (name, ...opts) => { machine.nodes[name] = createStateNode(name, ...opts) }, initial: (...opts) => { machine.initial = createInitial(...opts) }, enter: createEnter, exit: createExit, transition: createTransition, immediate: createImmediate, internal: createInternal, }) } validate(machine) return machine } function validate(machine) { for (const [, node] of Object.entries(machine.nodes)) { for (const transition of node.immediates) { if (!machine.nodes[transition.target]) { throw new Error(`Invalid transition target '${transition.target}'`) } } for (const transitions of Object.values(node.transitions)) { for (const transition of transitions) { if (!transition.internal && !machine.nodes[transition.target]) { throw new Error(`Invalid transition target '${transition.target}'`) } } } } } /** * Create a state node. */ function createStateNode(name, ...opts) { const enter = [] const exit = [] const transitions = {} const immediates = [] for (const opt of opts) { const { type, event } = opt if (type === 'transition') { if (!transitions[event]) transitions[event] = [] transitions[event].push(opt) } else if (type === 'immediate') { immediates.push(opt) } else if (type === 'enter') { enter.push(opt) } else if (type === 'exit') { exit.push(opt) } else { throw new Error( `State '${name}' should be passed one of enter(), exit(), transition(), immediate() or internal()`, ) } } return { name, enter, exit, transitions, immediates, } } function defaultInitial(opts) { return { data: {} } } function createInitial(name, initialData) { return (context) => { const initial = {} if (typeof name === 'string') { initial.name = name } else { initialData = name } if (typeof initialData === 'function') { initial.data = initialData(context) } else { initial.data = initialData } return initial } } function createEnter(opts) { return { type: 'enter', ...merge(opts, enterHooks) } } function createExit(opts) { return { type: 'exit', ...merge(opts, exitHooks) } } function createTransition(event, target, opts) { arg(event, 'string', 'First argument of the transition must be the name of the event') arg(target, 'string', 'Second argument of the transition must be the name of the target state') return { type: 'transition', event, target, ...merge(opts, transitionHooks) } } function createInternal(event, opts) { arg(event, 'string', 'First argument of the internal transition must be the name of the event') return { type: 'transition', event, internal: true, ...merge(opts, transitionHooks), } } function createImmediate(target, opts) { arg( target, 'string', 'First argument of the immediate transition must be the name of the target state', ) return { type: 'immediate', target, ...merge(opts, transitionHooks) } } /** * Transition the given machine from the provided state * to the next state based on the event. Returns the tuple * of the next state and any events to execute. In case effects * did not change, return null for effects, to indicate that the * active effects should continue running. */ export function transition(machine = {}, context = {}, state = {}, event, { assign } = {}) { event = typeof event === 'string' ? { type: event } : event // initial transition if (!state.name && event && event.type === null) { let { name, data } = machine.initial(context) const curr = { ...state, data } if (!name) { const nodeNames = Object.keys(machine.nodes) if (nodeNames.length > 0) { name = nodeNames[0] } } if (name) { const initialTransition = createImmediate(name) return applyTransition(machine, context, curr, event, initialTransition) } return [curr, []] } const currNode = machine.nodes[state.name] || {} const transitions = currNode.transitions || {} const candidates = transitions[event.type] || [] for (const candidate of candidates) { if (checkGuards(context, state, event, candidate)) { return applyTransition(machine, context, state, event, candidate) } } // did not find any explicit assign transition, construct a dynamic transition // so that we re-trigger all of the immediate transitions every time the context // changes if (event.type === assign) { return applyTransition(machine, context, state, event, createInternal(assign)) } return [state, []] } /** * The logic of applying a transition to the machine. Exit active state nodes, * apply transition hooks, enter target state nodes and collect any effects. Do this * recursively until all immediate transitions settle. */ function applyTransition(machine, context, curr, event, transition, effects = []) { const next = { ...curr } const target = transition.internal ? curr.name : transition.target const currNode = machine.nodes[curr.name] const nextNode = machine.nodes[target] if (currNode && !transition.internal) { effects.push({ op: 'exit', name: currNode.name }) for (const exit of currNode.exit) { applyReducers(exit, context, next, event) queueEffects(exit, effects, next, event, target) } } next.name = target applyReducers(transition, context, next, event) queueEffects(transition, effects, next, event, target) if (!transition.internal) { for (const enter of nextNode.enter) { applyReducers(enter, context, next, event) queueEffects(enter, effects, next, event, target) } } for (const candidate of nextNode.immediates) { if (checkGuards(context, next, event, candidate)) { return applyTransition(machine, context, next, event, candidate, effects) } } if (Object.keys(nextNode.transitions).length === 0 && nextNode.immediates.length === 0) { next.final = true } return [next, effects] } function checkGuards(context, state, event, transition) { return !transition.guards.length || transition.guards.every((g) => g(context, state.data, event)) } function applyReducers({ reducers }, context, next, event) { for (const reduce of reducers) { next.data = reduce(context, next.data, event) } } function queueEffects({ effects }, effectQueue, state, event, target) { for (const effect of effects) { effectQueue.push({ ...effect, op: 'effect', data: state.data, event, target }) } } /** * A common operation is to assign event payload * to the context, this allows to do in several ways: * true - assign the full event payload to context * fn - assign the result of the fn(context, data) to context * val - assign the constant provided value to context */ function assignToReduce(assign) { return (context, data, event) => { const { type, ...payload } = event if (assign === true) { return { ...data, ...payload } } if (typeof assign === 'function') { return { ...data, ...assign(context, data, payload) } } return { ...data, ...assign } } } /** * Allow to pass each hook as a function, or a list of functions * transition(..., { reduce: fn }) * transition(..., { reduce: [fn1, fn2] }) * Convert both of those into arrays, and also remap some of the * hooks to different hooks (i.e. assign -> reduce) */ function merge(opts = {}, allowedHooks) { const merged = {} for (const hook of allowedHooks) { add(hook) } function add(hook) { let t = opts[hook] || [] t = Array.isArray(t) ? t : [t] if (mappedHooks[hook]) { const [newName, transform] = mappedHooks[hook] hook = newName t = t.map(transform) } if (hook === 'effect') { t = t.map((run) => ({ id: nextEffectId++, run })) } const key = hookKeys[hook] merged[key] = merged[key] || [] merged[key] = merged[key].concat(t) } return merged } /** * Convert an async function into an effect * that sends 'done' and 'error' events */ function invokeToEffect(fn) { return (context, data, event, send) => { let disposed = false Promise.resolve(fn(context, data, event)) .then((result) => { if (!disposed) { send({ type: 'done', result }) } }) .catch((error) => { if (!disposed) { send({ type: 'error', error }) } }) return () => { disposed = true } } } /** * createMachine and transition are pure, stateless functions. After * transitioning the machine to the next state node, the caller must apply * the new set of effects atop of the currently running ones. This stops * any effects as necessary and starts any new ones. */ export function applyEffects(runningEffects = [], effectQueue, context, send) { let nextRunningEffects = runningEffects for (const effect of effectQueue) { if (effect.op === 'exit') { for (let i = runningEffects.length - 1; i >= 0; i--) { const eff = runningEffects[i] if (eff.disposed) { continue } if (effect.name === eff.target) { eff.dispose() } } continue } for (let i = runningEffects.length - 1; i >= 0; i--) { const eff = runningEffects[i] if (eff.disposed) { continue } if (effect.id === eff.id) { eff.dispose() } } const safeSend = (...args) => { if (effect.disposed) { warn( [ "Can't send events in an effect after it has been cleaned up.", 'This is a no-op, but indicates a memory leak in your application.', "To fix, cancel all subscriptions and asynchronous tasks in the effect's cleanup function.", ].join(' '), ) } else { return send(...args) } } effect.executed = true const dispose = effect.run(context, effect.data, effect.event, safeSend) if (dispose && dispose.then) { warn( [ 'Effect function must return a cleanup function or nothing.', 'Use invoke instead of effect for async functions, or call the async function inside the synchronous effect function.', ].join(' '), ) } effect.dispose = () => { effect.disposed = true if (dispose) { return dispose() } } nextRunningEffects.push(effect) } nextRunningEffects = nextRunningEffects.filter((eff) => !eff.disposed) return nextRunningEffects } export function cleanEffects(runningEffects = []) { for (const effect of runningEffects) { effect.dispose() } return [] }