@zedux/machines
Version:
Simple native state machine implementation for Zedux atoms
336 lines (299 loc) • 9.54 kB
text/typescript
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
}