UNPKG

@zedux/machines

Version:

Simple native state machine implementation for Zedux atoms

336 lines (299 loc) 9.54 kB
import { injectEffect, injectMemo, injectRef, injectSelf, InjectStoreConfig, zeduxTypes, } from '@zedux/atoms' import { MachineStore } from './MachineStore' import { MachineHook, MachineStateShape } from './types' type ArrToUnion<S extends string[]> = S extends [infer K, ...infer Rest] ? Rest extends string[] ? K | ArrToUnion<Rest> : never : never export type InjectMachineStoreParams< States extends MachineState[], Context extends Record<string, any> | undefined = undefined > = [ statesFactory: ( state: <Name extends string>(stateName: Name) => MachineState<Context, Name> ) => [...States], initialContext?: Context, config?: { guard?: ( currentState: MachineStateShape<MapStatesToStateNames<States>, Context>, nextValue: MapStatesToStateNames<States> ) => boolean onTransition?: MachineHook< MapStatesToStateNames<States>, MapStatesToEvents<States, Context>, Context > } & InjectStoreConfig ] export interface MachineState< Context extends Record<string, any> | undefined = any, Name extends string = string, Events extends string[] = [], ChildStates extends string[] = [Name] > { on: <E extends string, S extends string>( eventName: E, nextState: S, guard?: (context: Context) => boolean ) => MachineState<Context, Name, [...Events, E], [...ChildStates, S]> onEnter: ( listener: MachineHook<ArrToUnion<ChildStates>, ArrToUnion<Events>, Context> ) => MachineState<Context, Name, Events, ChildStates> onLeave: ( listener: MachineHook<ArrToUnion<ChildStates>, ArrToUnion<Events>, Context> ) => MachineState<Context, Name, Events, ChildStates> stateName: Name } type MapStatesToStateNames< States extends MachineState[], Context extends Record<string, any> | undefined = undefined > = States extends [infer K, ...infer Rest] ? K extends MachineState ? Rest extends MachineState[] ? | StateNameType<K> | ArrToUnion<StateChildStatesType<K>> | MapStatesToStateNames<Rest, Context> : never : never : never type MapStatesToEvents< States extends MachineState[], Context extends Record<string, any> | undefined = undefined > = States extends [infer K, ...infer Rest] ? K extends MachineState ? Rest extends MachineState[] ? ArrToUnion<StateEventsType<K>> | MapStatesToEvents<Rest, Context> : ArrToUnion<StateEventsType<K>> : never : never type StateChildStatesType<S extends MachineState> = S extends MachineState< any, string, string[], infer ChildStates > ? ChildStates : never type StateEventsType<S extends MachineState> = S extends MachineState< any, string, infer Events > ? Events : never type StateNameType<S extends MachineState> = S extends MachineState< any, infer Name > ? Name : never /** * Create a MachineStore. Pass a statesFactory * * The first state in the state list returned from your statesFactory will * become the initial state (`.value`) of the store. * * Registers an effect that listens to all store changes and calls the * configured listeners appropriately. * * ```ts * const store = injectMachineStore(state => [ * state('a') * .on('next', 'b', localGuard) * .onEnter(enterListener) * .onLeave(leaveListener), * state('b').on('next', 'a') * ], initialContext, { guard, onTransition }) * ``` * * Set a universal transition guard via the 3rd `config` object param. This * guard will be called every time a valid transition is about to occur. It will * be called with the current `.context` value and should return a boolean. * Return true to allow the transition, or any falsy value to deny it. * * Set a universal `onTransition` listener via the 3rd `config` object param. * This listener will be called every time the machine transitions to a new * state (after the state is updated). It will be called with 2 params: The * current MachineStore and the storeEffect of the action that transitioned the * store. For example, use `storeEffect.oldState.value` to see what state the * machine just transitioned from. * * @param statesFactory Required. A function. Use the received state factory to * create a list of states for the machine and specify their transitions, * guards, and listeners. * @param initialContext Optional. An object or undefined. Will be set as the * initial `.context` value of the machine store's state. * @param config Optional. An object with 2 additional properties: `guard` and * `onTransition`. */ export const injectMachineStore: < States extends MachineState[], Context extends Record<string, any> | undefined = undefined >( ...[statesFactory, initialContext, config]: InjectMachineStoreParams< States, Context > ) => MachineStore< MapStatesToStateNames<States, Context>, MapStatesToEvents<States, Context>, Context > = < States extends MachineState[], Context extends Record<string, any> | undefined = undefined >( ...[statesFactory, initialContext, config]: InjectMachineStoreParams< States, Context > ) => { type EventNames = MapStatesToEvents<States, Context> type StateNames = MapStatesToStateNames<States, Context> const instance = injectSelf() const { enterHooks, leaveHooks, store } = injectMemo(() => { const enterHooks: Record< string, MachineHook<StateNames, EventNames, Context>[] > = {} const leaveHooks: Record< string, MachineHook<StateNames, EventNames, Context>[] > = {} const states = {} as Record< StateNames, Record< EventNames, { name: StateNames; guard?: (context: Context) => boolean } > > const createState = <Name extends string>(stateName: Name) => { const state = { on: ( eventName: string, nextState: string, guard?: (context: Context) => boolean ) => { if (!states[stateName as StateNames]) { states[stateName as StateNames] = {} } if (!states[nextState as StateNames]) { states[nextState as StateNames] = {} } states[stateName as StateNames][eventName as EventNames] = { name: nextState as StateNames, guard, } return state }, onEnter: (callback: any) => { if (!enterHooks[stateName]) { enterHooks[stateName] = [] } enterHooks[stateName].push(callback) return state }, onLeave: (callback: any) => { if (!leaveHooks[stateName]) { leaveHooks[stateName] = [] } leaveHooks[stateName].push(callback) return state }, stateName, } return state } const [initialState] = statesFactory(createState) const hydration = config?.hydrate && instance.ecosystem.hydration?.[instance.id] const store = new MachineStore<StateNames, EventNames, Context>( hydration?.value ?? (initialState.stateName as StateNames), states, hydration?.context ?? initialContext, config?.guard ) return { enterHooks, leaveHooks, store } }, []) const subscribeRef = injectRef<boolean | undefined>() subscribeRef.current = config?.subscribe injectEffect( () => { const subscription = store.subscribe({ effects: storeEffect => { const { action, newState, oldState } = storeEffect if (newState.value === oldState?.value) return if (oldState && leaveHooks[oldState.value]) { leaveHooks[oldState.value].forEach(callback => callback(store, storeEffect) ) } if (enterHooks[newState.value]) { enterHooks[newState.value].forEach(callback => callback(store, storeEffect) ) } if (config?.onTransition) { config.onTransition(store, storeEffect) } // Nothing to do if the state hasn't changed. Also ignore state updates // during evaluation or that are caused by `zeduxTypes.ignore` actions if ( !subscribeRef.current || newState === oldState || instance._isEvaluating || action?.meta === zeduxTypes.ignore ) { return } instance._scheduleEvaluation( { newState, oldState, operation: 'injectMachineStore', reasons: [ { action, newState, oldState, operation: 'dispatch', sourceType: 'Store', type: 'state changed', }, ], sourceType: 'Injector', type: 'state changed', }, false ) // run the scheduler synchronously after any store update if (action?.meta !== zeduxTypes.batch) { instance.ecosystem._scheduler.flush() } }, }) return () => subscription.unsubscribe() }, [], { synchronous: true } ) const currentState = store.getState() if (enterHooks[currentState.value]) { enterHooks[currentState.value].forEach(callback => callback(store, { action: { type: zeduxTypes.prime }, newState: currentState, store, }) ) } return store }