Skip to content

Commit e0a9772

Browse files
Declare always and onDone targets
1 parent 49efc19 commit e0a9772

7 files changed

Lines changed: 310 additions & 58 deletions

File tree

.changeset/quiet-spiders-inspect.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@typeonce/effect-machine": minor
33
---
44

5-
Add public getters for inspecting compiled state nodes, registered transition handlers, declared transition targets, and the active state configuration of a snapshot. Event handlers may declare an upper bound of target paths that is checked against inferred and runtime results.
5+
Add public getters for inspecting compiled state nodes, registered transition handlers, declared transition targets, and the active state configuration of a snapshot. Event, eventless, and completion handlers may declare an upper bound of target paths that is checked against inferred and runtime results.

src/Machine.ts

Lines changed: 87 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2055,10 +2055,10 @@ export declare namespace Machine {
20552055
*
20562056
* **Details**
20572057
*
2058-
* Event handlers may declare an upper bound of possible target paths. A
2059-
* handler without that declaration is explicitly reported as dynamic. The
2060-
* source, trigger, and reentry behavior are available without executing the
2061-
* handler.
2058+
* Event, eventless, and completion handlers may declare an upper bound of
2059+
* possible target paths. A handler without that declaration is explicitly
2060+
* reported as dynamic. The source, trigger, and reentry behavior are
2061+
* available without executing the handler.
20622062
*
20632063
* @category models
20642064
* @since 4.0.0
@@ -3417,7 +3417,7 @@ export declare namespace Machine {
34173417
* @since 4.0.0
34183418
*/
34193419
export type AlwaysReturn<Config> = Config extends { readonly always?: infer Always }
3420-
? NonNullable<Always> extends (...args: any) => infer Ret ? Ret : never
3420+
? EventTransitionReturn<NonNullable<Always>>
34213421
: never
34223422
/**
34233423
* Extracts the return value from a state completion transition.
@@ -3426,7 +3426,7 @@ export declare namespace Machine {
34263426
* @since 4.0.0
34273427
*/
34283428
export type DoneReturn<Config> = Config extends { readonly onDone?: infer OnDone }
3429-
? NonNullable<OnDone> extends (...args: any) => infer Ret ? Ret : never
3429+
? EventTransitionReturn<NonNullable<OnDone>>
34303430
: never
34313431
/**
34323432
* Extracts the return value from a final state output function.
@@ -3596,8 +3596,24 @@ export declare namespace Machine {
35963596
readonly entry?: (context: StateActionContext<States, Events, Emits, StateId>) => StateActionResult<any, any>
35973597
readonly exit?: (context: StateActionContext<States, Events, Emits, StateId>) => StateActionResult<any, any>
35983598
readonly invoke?: InvokeDefinition<States, Events, Emits, StateId>
3599-
readonly always?: (context: AlwaysContext<States, Events, Emits, StateId>) => HandlerResult<States, any, any>
3600-
readonly onDone?: (context: DoneContext<States, Events, Emits, StateId>) => HandlerResult<States, any, any>
3599+
readonly always?:
3600+
| ((context: AlwaysContext<States, Events, Emits, StateId>) => HandlerResult<States, any, any>)
3601+
| {
3602+
/** Statically declared upper bound of possible target paths. */
3603+
readonly targets?: ReadonlyArray<StateNodeIdentifier<States>>
3604+
readonly transition: (
3605+
context: AlwaysContext<States, Events, Emits, StateId>
3606+
) => HandlerResult<States, any, any>
3607+
}
3608+
readonly onDone?:
3609+
| ((context: DoneContext<States, Events, Emits, StateId>) => HandlerResult<States, any, any>)
3610+
| {
3611+
/** Statically declared upper bound of possible target paths. */
3612+
readonly targets?: ReadonlyArray<StateNodeIdentifier<States>>
3613+
readonly transition: (
3614+
context: DoneContext<States, Events, Emits, StateId>
3615+
) => HandlerResult<States, any, any>
3616+
}
36013617
readonly on?: {
36023618
readonly [EventTag in TagOf<Events[number]>]?:
36033619
| ((
@@ -3935,6 +3951,22 @@ export declare namespace Machine {
39353951
: unknown
39363952
: unknown
39373953

3954+
type HandlerDirectTargetValidation<
3955+
StateId extends string,
3956+
Config,
3957+
Trigger extends "always" | "onDone",
3958+
Transition = Config extends { readonly [Key in Trigger]?: infer Value } ? NonNullable<Value> : never,
3959+
Undeclared extends string = UndeclaredTransitionTarget<Transition>
3960+
> = Trigger extends keyof Config ? [Undeclared] extends [never] ? unknown
3961+
: {
3962+
readonly [Key in Trigger]: HandlerValidationError<
3963+
"Transition returns a target not listed in targets",
3964+
StateId,
3965+
readonly [trigger: Trigger, target: Undeclared]
3966+
>
3967+
}
3968+
: unknown
3969+
39383970
type HandlerDepth = readonly [unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown]
39393971

39403972
type HandlerNextDepth<Depth extends ReadonlyArray<unknown>> = Depth extends
@@ -4024,6 +4056,8 @@ export declare namespace Machine {
40244056
& HandlerUnknownConfigKeyValidation<StateId, Config>
40254057
& HandlerOnKeyValidation<Events, StateId, Config>
40264058
& HandlerOnTargetValidation<StateId, Config>
4059+
& HandlerDirectTargetValidation<StateId, Config, "always">
4060+
& HandlerDirectTargetValidation<StateId, Config, "onDone">
40274061
& HandlerInvokeOutputValidation<Events, StateId, Config>
40284062
& HandlerInvokeEmitsValidation<Events, StateId, Config>
40294063
& HandlerInvokeSnapshotValidation<Events, StateId, Config>
@@ -4494,8 +4528,22 @@ export declare namespace Machine {
44944528
readonly entry?: (context: StateActionContext<States, Events, Emits, StateId>) => StateActionResult<E, R>
44954529
readonly exit?: (context: StateActionContext<States, Events, Emits, StateId>) => StateActionResult<E, R>
44964530
readonly invoke?: InvokeDefinition<States, Events, Emits, StateId>
4497-
readonly always?: (context: AlwaysContext<States, Events, Emits, StateId>) => HandlerResult<States, E, R>
4498-
readonly onDone?: (context: DoneContext<States, Events, Emits, StateId>) => HandlerResult<States, E, R>
4531+
readonly always?:
4532+
| ((context: AlwaysContext<States, Events, Emits, StateId>) => HandlerResult<States, E, R>)
4533+
| {
4534+
readonly targets?: ReadonlyArray<StateNodeIdentifier<States>>
4535+
readonly transition: (
4536+
context: AlwaysContext<States, Events, Emits, StateId>
4537+
) => HandlerResult<States, E, R>
4538+
}
4539+
readonly onDone?:
4540+
| ((context: DoneContext<States, Events, Emits, StateId>) => HandlerResult<States, E, R>)
4541+
| {
4542+
readonly targets?: ReadonlyArray<StateNodeIdentifier<States>>
4543+
readonly transition: (
4544+
context: DoneContext<States, Events, Emits, StateId>
4545+
) => HandlerResult<States, E, R>
4546+
}
44994547
readonly output?:
45004548
| ((context: FinalOutputContext<States, Events, StateId>) => unknown)
45014549
| ((context: ParallelOutputContext<States, Events, StateId>) => unknown)
@@ -4550,6 +4598,29 @@ const cloneWithHandlers = (
45504598
return machine
45514599
}
45524600

4601+
const validateTransitionTargets = (
4602+
stateNodes: Machine.StateNodes,
4603+
path: string,
4604+
trigger: PropertyKey,
4605+
transition: unknown
4606+
): void => {
4607+
if (typeof transition !== "object" || transition === null || !hasProperty(transition, "targets")) {
4608+
return
4609+
}
4610+
if (!Array.isArray(transition.targets)) {
4611+
throw new Error(
4612+
`Machine expected transition targets for state "${path}" on "${String(trigger)}" to be an array`
4613+
)
4614+
}
4615+
for (const target of transition.targets) {
4616+
if (typeof target !== "string" || !stateNodes.byPath.has(target)) {
4617+
throw new Error(
4618+
`Machine transition for state "${path}" on "${String(trigger)}" declares unknown target "${String(target)}"`
4619+
)
4620+
}
4621+
}
4622+
}
4623+
45534624
const flattenHandlers = (
45544625
handlers: Record<PropertyKey, Machine.AnyStateConfig>,
45554626
stateNodes: Machine.StateNodes,
@@ -4570,24 +4641,11 @@ const flattenHandlers = (
45704641
const on = stateConfig.on
45714642
if (typeof on === "object" && on !== null) {
45724643
for (const event of Reflect.ownKeys(on)) {
4573-
const transition = (on as Record<PropertyKey, unknown>)[event]
4574-
if (typeof transition !== "object" || transition === null || !hasProperty(transition, "targets")) {
4575-
continue
4576-
}
4577-
if (!Array.isArray(transition.targets)) {
4578-
throw new Error(
4579-
`Machine expected transition targets for state "${path}" on "${String(event)}" to be an array`
4580-
)
4581-
}
4582-
for (const target of transition.targets) {
4583-
if (typeof target !== "string" || !stateNodes.byPath.has(target)) {
4584-
throw new Error(
4585-
`Machine transition for state "${path}" on "${String(event)}" declares unknown target "${String(target)}"`
4586-
)
4587-
}
4588-
}
4644+
validateTransitionTargets(stateNodes, path, event, (on as Record<PropertyKey, unknown>)[event])
45894645
}
45904646
}
4647+
validateTransitionTargets(stateNodes, path, "always", stateConfig.always)
4648+
validateTransitionTargets(stateNodes, path, "done", stateConfig.onDone)
45914649
handlers[path] = stateConfig as Machine.AnyStateConfig
45924650
if (childConfig !== undefined) {
45934651
const node = Model.getStateNodeDefinition(path, states[key])
@@ -5978,8 +6036,9 @@ export const stateNodes = <M extends Machine.Any>(
59786036
*
59796037
* Event handlers retain their handler-key order within each source state and
59806038
* are followed by eventless and completion handlers. This function does not
5981-
* execute handlers. Event handlers with a `targets` declaration expose those
5982-
* possible paths; handlers without one remain dynamic.
6039+
* execute handlers. Object-form event, eventless, and completion handlers with
6040+
* a `targets` declaration expose those possible paths; handlers without one
6041+
* remain dynamic.
59836042
*
59846043
* @category getters
59856044
* @since 4.0.0

src/internal/machineModel.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,11 @@ export const compileStateNodes = (states: Machine.StateSchemas): Machine.StateNo
358358

359359
const dynamicTransitionTargets = { type: "dynamic" } as const
360360

361+
const transitionTargets = (handler: unknown): Machine.TransitionTargets =>
362+
typeof handler === "object" && handler !== null && "targets" in handler && handler.targets !== undefined
363+
? { type: "declared", paths: Array.from(handler.targets as ReadonlyArray<string>) }
364+
: dynamicTransitionTargets
365+
361366
export const transitionDefinitions = (
362367
machine: Machine.Any
363368
): ReadonlyArray<Machine.TransitionDefinition> => {
@@ -373,25 +378,23 @@ export const transitionDefinitions = (
373378
source: node.path,
374379
trigger: { type: "event", event },
375380
reenter: typeof handler === "object" && handler !== null && handler.reenter === true,
376-
targets: typeof handler === "object" && handler !== null && handler.targets !== undefined
377-
? { type: "declared", paths: Array.from(handler.targets) }
378-
: dynamicTransitionTargets
381+
targets: transitionTargets(handler)
379382
})
380383
}
381384
if (config.always !== undefined) {
382385
definitions.push({
383386
source: node.path,
384387
trigger: { type: "always" },
385388
reenter: false,
386-
targets: dynamicTransitionTargets
389+
targets: transitionTargets(config.always)
387390
})
388391
}
389392
if (config.onDone !== undefined) {
390393
definitions.push({
391394
source: node.path,
392395
trigger: { type: "done" },
393396
reenter: false,
394-
targets: dynamicTransitionTargets
397+
targets: transitionTargets(config.onDone)
395398
})
396399
}
397400
}

src/internal/machinePlanner.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ type MicrostepTransition<States extends Machine.StateSchemas, E, R, Context> = {
239239
readonly transition: TransitionHandler<States, E, R, Context>
240240
}
241241

242-
const normalizeEventTransition = <States extends Machine.StateSchemas, E, R, Context>(
242+
const normalizeTransition = <States extends Machine.StateSchemas, E, R, Context>(
243243
transition: EventTransition<States, E, R, Context> | undefined
244244
): MicrostepTransition<States, E, R, Context> | undefined => {
245245
if (transition === undefined) {
@@ -481,6 +481,7 @@ const resolveHistoryTarget = Effect.fnUntraced(function*(
481481
type SelectedTransition<States extends Machine.StateSchemas, E, R, Context> = {
482482
readonly sourcePath: string
483483
readonly leafPath: string
484+
readonly trigger: Machine.TransitionTrigger
484485
readonly transition: MicrostepTransition<States, E, R, Context>
485486
readonly context: Context
486487
}
@@ -703,14 +704,15 @@ const selectAlwaysTransitions = <
703704
const selectedSources = new Set<string>()
704705
for (const leaf of getActiveLeafPaths(machine, configuration)) {
705706
for (const path of getLeafCandidatePaths(machine, leaf)) {
706-
const always = machine.handlers[path]?.always
707+
const always = normalizeTransition(machine.handlers[path]?.always)
707708
if (always !== undefined) {
708709
if (!selectedSources.has(path)) {
709710
selectedSources.add(path)
710711
selected.push({
711712
sourcePath: path,
712713
leafPath: leaf,
713-
transition: { reenter: false, targets: undefined, transition: always } as MicrostepTransition<
714+
trigger: { type: "always" },
715+
transition: always as unknown as MicrostepTransition<
714716
States,
715717
E,
716718
R,
@@ -771,13 +773,14 @@ const selectDoneTransitions = <
771773
> = []
772774
const selectedSources = new Set<string>()
773775
for (const completion of completions) {
774-
const onDone = machine.handlers[completion.path]?.onDone
776+
const onDone = normalizeTransition(machine.handlers[completion.path]?.onDone)
775777
if (onDone !== undefined && !selectedSources.has(completion.path)) {
776778
selectedSources.add(completion.path)
777779
selected.push({
778780
sourcePath: completion.path,
779781
leafPath: getActiveLeafPathFrom(machine, configuration, completion.path),
780-
transition: { reenter: false, targets: undefined, transition: onDone } as MicrostepTransition<
782+
trigger: { type: "done" },
783+
transition: onDone as unknown as MicrostepTransition<
781784
States,
782785
E,
783786
R,
@@ -833,13 +836,14 @@ const selectEventTransitions = <
833836
const selectedSources = new Set<string>()
834837
for (const leaf of getActiveLeafPaths(machine, configuration)) {
835838
for (const path of getLeafCandidatePaths(machine, leaf)) {
836-
const transition = normalizeEventTransition(machine.handlers[path]?.on?.[event._tag])
839+
const transition = normalizeTransition(machine.handlers[path]?.on?.[event._tag])
837840
if (transition !== undefined) {
838841
if (!selectedSources.has(path)) {
839842
selectedSources.add(path)
840843
selected.push({
841844
sourcePath: path,
842845
leafPath: leaf,
846+
trigger: { type: "event", event: event._tag },
843847
transition: transition as unknown as MicrostepTransition<
844848
States,
845849
E,
@@ -884,7 +888,7 @@ const getTargetNodePath = <const States extends Machine.StateSchemas>(
884888

885889
const validateDeclaredTransitionTarget = (
886890
sourcePath: string,
887-
event: unknown,
891+
trigger: Machine.TransitionTrigger,
888892
declaredTargets: ReadonlyArray<string> | undefined,
889893
target: unknown
890894
): void => {
@@ -895,10 +899,9 @@ const validateDeclaredTransitionTarget = (
895899
? String(target.path)
896900
: "<unknown>"
897901
if (!declaredTargets.some((path) => actual === path || actual.startsWith(`${path}.`))) {
902+
const triggerLabel = trigger.type === "event" ? String(trigger.event) : trigger.type
898903
throw new Error(
899-
`Machine transition from "${sourcePath}" on "${
900-
String(event)
901-
}" returned target "${actual}" outside declared targets: ${
904+
`Machine transition from "${sourcePath}" on "${triggerLabel}" returned target "${actual}" outside declared targets: ${
902905
declaredTargets.length === 0 ? "none" : declaredTargets.map((path) => `"${path}"`).join(", ")
903906
}`
904907
)
@@ -1002,7 +1005,7 @@ const collectEvaluatedTransition = Effect.fnUntraced(function*<
10021005
| Machine.Target<States, Machine.StateIdentifier<States>>
10031006
validateDeclaredTransitionTarget(
10041007
selection.sourcePath,
1005-
(selection.context as { readonly event: { readonly _tag: unknown } }).event._tag,
1008+
selection.trigger,
10061009
selection.transition.targets,
10071010
unresolvedTarget
10081011
)

0 commit comments

Comments
 (0)