Skip to content

Commit cb137a7

Browse files
Add explicit targetless transitions (#113)
* Add explicit targetless transitions * Fix runtime benchmark API compatibility * Integrate explicit targets with actor events
1 parent 62e2281 commit cb137a7

47 files changed

Lines changed: 426 additions & 261 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Add `target.none()` for explicit targetless transitions. Every installed transition handler now returns a concrete target or `target.none()`; declared `targets` remain an upper bound on concrete destinations and never exclude `target.none()`.
6+
7+
Remove `Machine.retag`. To reuse compatible fields across sibling states, destructure away the source discriminator and construct the destination through its target builder:
8+
9+
```ts
10+
const { _tag: _, ...fields } = state
11+
return target.local.Saving.from({ ...fields, attempt: 1 })
12+
```

README.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,18 @@ States.initial.Form.from({ draft: "" }, (form) => form.Editing.from())
9797
The machine runs these inputs through the state schema while planning. Schema
9898
defaults, refinements, and tagged-class identity are therefore preserved, and
9999
decode failures remain typed machine failures. Pass a value directly only when
100-
it is already decoded, such as a value returned by `Machine.retag`.
100+
it is already decoded.
101+
102+
When sibling states share fields, remove the source discriminator and pass the
103+
remaining fields through the target schema:
104+
105+
```ts
106+
Submit: ;
107+
;(({ state, target }) => {
108+
const { _tag: _, ...fields } = state
109+
return target.local.Saving.from({ ...fields, attempt: 1 })
110+
})
111+
```
101112

102113
Omit `schema` when a state represents control flow but owns no data:
103114

@@ -237,14 +248,20 @@ paths. `parent` always means the owning actor reference.
237248

238249
| Builder | Use when | Preserves |
239250
| ---------------- | ---------------------------------------- | ------------------------------------------------- |
251+
| `target.none()` | Handling without selecting a destination | The complete current configuration |
240252
| `target.local` | Moving inside the nearest compound scope | Ancestors and unrelated parallel regions |
241253
| `target.branch` | Moving elsewhere under the active root | Omitted active ancestors and parallel regions |
242254
| `target.full` | Replacing or selecting a complete root | Nothing implicit for a newly selected root |
243255
| `target.history` | Restoring a declared history node | The remembered configuration or its typed default |
244256

245-
Builders describe the next logical configuration. Shared states exit and enter
246-
only when paths change; use `{ reenter: true, transition }` when the source must
247-
restart even if its path is unchanged.
257+
Every installed transition handler returns either a concrete target or
258+
`target.none()`. An absent handler ignores the trigger; `target.none()` handles
259+
it and retains queued commands, raised events, and emitted events without
260+
selecting a destination. Declared `targets` constrain only concrete
261+
destinations, so `target.none()` is always permitted. Builders describe the
262+
next logical configuration. Shared states exit and enter only when paths
263+
change; use `{ reenter: true, transition }` when the source must restart. With
264+
`target.none()`, reentry restarts the source while retaining its configuration.
248265

249266
## Statechart capabilities
250267

docs/agent-guide.md

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,7 @@ microstep, before any selected transition is applied:
426426
BufferReady: ({ snapshot, target }) =>
427427
States.matches(snapshot, "Player.Network.Online")
428428
? target.local.Playing.from()
429-
: undefined
429+
: target.none()
430430
```
431431

432432
Use the existing `States.matches`, `States.get`, `States.getWithParents`, and
@@ -457,21 +457,39 @@ history definitions may declare an `annotations` object containing only
457457
they cannot change behavior, identity, or targeting. Visualization may show a
458458
title, while the structural path remains authoritative.
459459

460-
Use `Machine.retag(TargetCase, source, patch?)` when sibling state payloads
461-
share fields. It removes the source discriminator, reuses only compatible
462-
fields, and requires a patch for every missing or incompatible required field.
463-
Prefer moving broadly shared data to the compound parent rather than retagging
464-
it through every phase.
460+
When sibling state payloads share fields, destructure away the source
461+
discriminator and construct the destination through its target builder:
462+
463+
```ts
464+
Submit: ({ state, target }) => {
465+
const { _tag: _, ...fields } = state
466+
return target.local.Saving.from({ ...fields, attempt: 1 })
467+
}
468+
```
469+
470+
The target schema remains responsible for defaults, transforms, refinements,
471+
and class identity. Prefer moving broadly shared data to the compound parent
472+
rather than copying it through every phase.
465473

466474
## Planning, actions, raised events, and emissions
467475

468476
A transition returns a target synchronously:
469477

470478
```ts
471479
Submit: ({ state, target }) =>
472-
state.valid ? target.local.Saving.from({ draft: state.draft }) : undefined
480+
state.valid ? target.local.Saving.from({ draft: state.draft }) : target.none()
473481
```
474482

483+
Every installed event, `always`, `onDone`, and invoke lifecycle handler must
484+
return a concrete target or `target.none()`. An absent event handler means the
485+
event is ignored. Returning `target.none()` means it was handled without a
486+
destination, so queued commands, raised events, and emitted events are still
487+
retained. It remains valid when a transition declares `targets`: those paths
488+
are an upper bound on concrete destinations, not an exhaustive result set.
489+
490+
`reenter: true` remains meaningful with `target.none()`: the source exits and
491+
enters again while its logical configuration is retained.
492+
475493
Closed statechart and actor operations use `enqueue`:
476494

477495
```ts

examples/platformer/src/machine.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ export const CharacterMachine = definition.handle({
192192
targets: ["Character.locomotion.Playing.Grounded.Running"],
193193
transition: ({ event, target }) =>
194194
event.axis === 0
195-
? undefined
195+
? target.none()
196196
: target.local.Running.from({ startedAt: event.at })
197197
},
198198
DownPressed: {
@@ -205,7 +205,8 @@ export const CharacterMachine = definition.handle({
205205
on: {
206206
Move: {
207207
targets: ["Character.locomotion.Playing.Grounded.Standing"],
208-
transition: ({ event, target }) => event.axis === 0 ? target.local.Standing.from() : undefined
208+
transition: ({ event, target }) =>
209+
event.axis === 0 ? target.local.Standing.from() : target.none()
209210
},
210211
DownPressed: {
211212
targets: ["Character.locomotion.Playing.Grounded.Ducking"],
@@ -245,8 +246,10 @@ export const CharacterMachine = definition.handle({
245246
on: {
246247
Move: {
247248
targets: ["Character.locomotion.Playing.Grounded.Landing"],
248-
transition: ({ event, state, target }) =>
249-
target.local.Landing(Machine.retag(State.cases.Landing, state, { resumeAxis: event.axis }))
249+
transition: ({ event, state, target }) => {
250+
const { _tag: _, ...fields } = state
251+
return target.local.Landing.from({ ...fields, resumeAxis: event.axis })
252+
}
250253
}
251254
}
252255
}
@@ -256,13 +259,14 @@ export const CharacterMachine = definition.handle({
256259
on: {
257260
JumpPressed: {
258261
targets: [],
259-
transition: ({ event }, enqueue) => {
262+
transition: ({ event, target }, enqueue) => {
260263
const push = awayFrom(event.wall)
261264
enqueue.raise(
262265
push === 0
263266
? InternalEvents.TryAirJump({ at: event.at })
264267
: InternalEvents.WallJump({ at: event.at, push })
265268
)
269+
return target.none()
266270
}
267271
},
268272
Landed: {
@@ -380,23 +384,23 @@ export const CharacterMachine = definition.handle({
380384
on: {
381385
Move: {
382386
targets: ["Character.facing.Right"],
383-
transition: ({ event, target }) => event.axis === 1 ? target.local.Right.from() : undefined
387+
transition: ({ event, target }) => event.axis === 1 ? target.local.Right.from() : target.none()
384388
},
385389
WallJump: {
386390
targets: ["Character.facing.Right"],
387-
transition: ({ event, target }) => event.push === 1 ? target.local.Right.from() : undefined
391+
transition: ({ event, target }) => event.push === 1 ? target.local.Right.from() : target.none()
388392
}
389393
}
390394
},
391395
Right: {
392396
on: {
393397
Move: {
394398
targets: ["Character.facing.Left"],
395-
transition: ({ event, target }) => event.axis === -1 ? target.local.Left.from() : undefined
399+
transition: ({ event, target }) => event.axis === -1 ? target.local.Left.from() : target.none()
396400
},
397401
WallJump: {
398402
targets: ["Character.facing.Left"],
399-
transition: ({ event, target }) => event.push === -1 ? target.local.Left.from() : undefined
403+
transition: ({ event, target }) => event.push === -1 ? target.local.Left.from() : target.none()
400404
}
401405
}
402406
}

examples/playground/src/examples/media-player/machine.ts

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,14 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({
3434
invoke: Machine.invoke({
3535
id: "load-audio",
3636
effect: ({ state }) => loadAudio(state.url),
37-
onDone: (_, enqueue) => enqueue.raise(MediaPlayerInternalEvents.LoadSucceeded()),
38-
onFailure: ({ error }, enqueue) =>
37+
onDone: ({ target }, enqueue) => {
38+
enqueue.raise(MediaPlayerInternalEvents.LoadSucceeded())
39+
return target.none()
40+
},
41+
onFailure: ({ error, target }, enqueue) => {
3942
enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message }))
43+
return target.none()
44+
}
4045
}),
4146
on: {
4247
LoadSucceeded: ({ target }) => target.local.Ready.from((ready) => ready.Paused.from(initialPlaybackData))
@@ -49,9 +54,11 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({
4954
invoke: Machine.invoke({
5055
id: "pause-audio",
5156
effect: pauseAudio,
52-
onDone: () => undefined,
53-
onFailure: ({ error }, enqueue) =>
57+
onDone: ({ target }) => target.none(),
58+
onFailure: ({ error, target }, enqueue) => {
5459
enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message }))
60+
return target.none()
61+
}
5562
}),
5663
on: {
5764
PlayRequested: ({ state, target }) =>
@@ -69,18 +76,21 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({
6976
Machine.invoke({
7077
id: "play-audio",
7178
effect: playAudio,
72-
onDone: () => undefined,
73-
onFailure: ({ error }, enqueue) =>
79+
onDone: ({ target }) => target.none(),
80+
onFailure: ({ error, target }, enqueue) => {
7481
enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message }))
82+
return target.none()
83+
}
7584
}),
7685
Machine.invoke({
7786
id: "analyze-audio",
7887
address: Machine.childAddress("analyze-audio"),
7988
logic: analyzeAudio,
80-
onDone: () => undefined,
81-
onSnapshot: ({ snapshot }, enqueue) => {
89+
onDone: ({ target }) => target.none(),
90+
onSnapshot: ({ snapshot, target }, enqueue) => {
8291
const event = loudnessEvent(snapshot.state)
8392
if (event !== undefined) enqueue.raise(event)
93+
return target.none()
8494
}
8595
})
8696
],
@@ -136,9 +146,14 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({
136146
invoke: Machine.invoke({
137147
id: "restart-audio",
138148
effect: restartAudio,
139-
onDone: (_, enqueue) => enqueue.raise(MediaPlayerInternalEvents.RestartSucceeded()),
140-
onFailure: ({ error }, enqueue) =>
149+
onDone: ({ target }, enqueue) => {
150+
enqueue.raise(MediaPlayerInternalEvents.RestartSucceeded())
151+
return target.none()
152+
},
153+
onFailure: ({ error, target }, enqueue) => {
141154
enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message }))
155+
return target.none()
156+
}
142157
}),
143158
on: {
144159
RestartSucceeded: ({ target }) => target.local.Playing.from({ currentTime: 0, loudness: null }),
@@ -159,27 +174,27 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({
159174
},
160175

161176
Failed: {
162-
invoke: {
177+
invoke: Machine.invoke({
163178
id: "report-error",
164179
effect: ({ state }) =>
165180
Effect.gen(function*() {
166181
const mediaPlayer = yield* MediaPlayer
167182
yield* mediaPlayer.reportError(state.message)
168183
}),
169-
onDone: () => undefined
170-
}
184+
onDone: ({ target }) => target.none()
185+
})
171186
}
172187
}
173188
},
174189

175190
settings: {
176191
states: {
177192
Audible: {
178-
invoke: {
193+
invoke: Machine.invoke({
179194
id: "apply-audio-settings",
180195
effect: ({ state }) => applyAudioSettings(state, false),
181-
onDone: () => undefined
182-
},
196+
onDone: ({ target }) => target.none()
197+
}),
183198
on: {
184199
VolumeChanged: {
185200
reenter: true,
@@ -208,11 +223,11 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({
208223
},
209224

210225
Muted: {
211-
invoke: {
226+
invoke: Machine.invoke({
212227
id: "apply-audio-settings",
213228
effect: ({ state }) => applyAudioSettings(state, true),
214-
onDone: () => undefined
215-
},
229+
onDone: ({ target }) => target.none()
230+
}),
216231
on: {
217232
VolumeChanged: {
218233
reenter: true,

examples/playground/src/examples/microwave/machine.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export const MicrowaveMachine = definition.handle({
5757
PowerPressed: ({ snapshot, target }) =>
5858
MicrowaveStates.matches(snapshot, "Oven.door.Closed")
5959
? target.local.Cooking.from({ elapsedSeconds: 0 })
60-
: undefined
60+
: target.none()
6161
}
6262
},
6363
Cooking: {

examples/pokemon/src/machine.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const machine = Machine.make({
3535
invoke: [
3636
Machine.invoke({
3737
child: SelectionChild,
38-
onDone: () => undefined,
38+
onDone: ({ target }) => target.none(),
3939
onFailure: ({ target }) => target.full.Failed.from()
4040
}),
4141
Machine.invoke({ child: ReplaceChild, onFailure: ({ target }) => target.full.Failed.from() })

examples/pokemon/src/machines/replace.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ export const ReplaceMachine = Machine.make({
4747
invoke: Machine.invoke({
4848
id: "replaceWithRandom",
4949
effect: replaceWithRandom,
50-
onDone: ({ output }, enqueue) => enqueue.raise(ReplaceInternalEvents.Replaced({ pokemon: output.pokemon })),
50+
onDone: ({ output, target }, enqueue) => {
51+
enqueue.raise(ReplaceInternalEvents.Replaced({ pokemon: output.pokemon }))
52+
return target.none()
53+
},
5154
onFailure: ({ target }) => target.full.Idle.from()
5255
}),
5356
on: {

perf/runtime/counter.mjs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ const counterParentMachine = Machine.make({
7474
initial: () => ParentStates.initial.Active.from()
7575
}).handle({
7676
Active: {
77-
invoke: benchmarkApi.invokeChild({ child: CounterChild, onDone: () => undefined })
77+
invoke: benchmarkApi.invokeChild({ child: CounterChild, onDone: benchmarkApi.targetless })
7878
}
7979
})
8080

@@ -87,8 +87,8 @@ const counterSnapshotParentMachine = Machine.make({
8787
Active: {
8888
invoke: benchmarkApi.invokeChild({
8989
child: CounterChild,
90-
onDone: () => undefined,
91-
onSnapshot: () => undefined
90+
onDone: benchmarkApi.targetless,
91+
onSnapshot: benchmarkApi.targetless
9292
})
9393
}
9494
})
@@ -308,7 +308,7 @@ const waitForCounterChild = (parent) =>
308308
}
309309
yield* Effect.yieldNow
310310
}
311-
return yield* Effect.dieMessage("Effect Machine child did not become ready")
311+
return yield* Effect.die(new Error("Effect Machine child did not become ready"))
312312
})
313313

314314
export const startChildCounter = () =>
@@ -328,13 +328,13 @@ export const runChildCounterBurst = (parent, size) =>
328328
for (let index = 0; index < size; index += 1) {
329329
const child = yield* parent.child(CounterChild)
330330
if (Option.isNone(child)) {
331-
return yield* Effect.dieMessage("Effect Machine child disappeared during the benchmark")
331+
return yield* Effect.die(new Error("Effect Machine child disappeared during the benchmark"))
332332
}
333333
yield* child.value.send(incrementEvent)
334334
}
335335
const child = yield* parent.child(CounterChild)
336336
if (Option.isNone(child)) {
337-
return yield* Effect.dieMessage("Effect Machine child disappeared before the terminal fence")
337+
return yield* Effect.die(new Error("Effect Machine child disappeared before the terminal fence"))
338338
}
339339
yield* child.value.send(finishEvent)
340340
return yield* child.value.join

perf/runtime/effect-machine-compatibility.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const makeEffectMachineBenchmarkApi = (Machine) => ({
99
events: typeof Machine.event === "function"
1010
? (...schemas) => schemas
1111
: (...schemas) => Machine.events(...schemas),
12+
targetless: ({ target }) => typeof target.none === "function" ? target.none() : undefined,
1213
invokeChild: typeof Machine.invokeMachine === "function"
1314
? ({ onSnapshot, onFailure, ...config }) => {
1415
if (onFailure !== undefined) {

0 commit comments

Comments
 (0)