Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/explicit-targetless-transitions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@typeonce/effect-machine": minor
---

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()`.

Remove `Machine.retag`. To reuse compatible fields across sibling states, destructure away the source discriminator and construct the destination through its target builder:

```ts
const { _tag: _, ...fields } = state
return target.local.Saving.from({ ...fields, attempt: 1 })
```
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,18 @@ States.initial.Form.from({ draft: "" }, (form) => form.Editing.from())
The machine runs these inputs through the state schema while planning. Schema
defaults, refinements, and tagged-class identity are therefore preserved, and
decode failures remain typed machine failures. Pass a value directly only when
it is already decoded, such as a value returned by `Machine.retag`.
it is already decoded.

When sibling states share fields, remove the source discriminator and pass the
remaining fields through the target schema:

```ts
Submit: ;
;(({ state, target }) => {
const { _tag: _, ...fields } = state
return target.local.Saving.from({ ...fields, attempt: 1 })
})
```

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

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

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

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

## Statechart capabilities

Expand Down
32 changes: 25 additions & 7 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ microstep, before any selected transition is applied:
BufferReady: ({ snapshot, target }) =>
States.matches(snapshot, "Player.Network.Online")
? target.local.Playing.from()
: undefined
: target.none()
```

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

Use `Machine.retag(TargetCase, source, patch?)` when sibling state payloads
share fields. It removes the source discriminator, reuses only compatible
fields, and requires a patch for every missing or incompatible required field.
Prefer moving broadly shared data to the compound parent rather than retagging
it through every phase.
When sibling state payloads share fields, destructure away the source
discriminator and construct the destination through its target builder:

```ts
Submit: ({ state, target }) => {
const { _tag: _, ...fields } = state
return target.local.Saving.from({ ...fields, attempt: 1 })
}
```

The target schema remains responsible for defaults, transforms, refinements,
and class identity. Prefer moving broadly shared data to the compound parent
rather than copying it through every phase.

## Planning, actions, raised events, and emissions

A transition returns a target synchronously:

```ts
Submit: ({ state, target }) =>
state.valid ? target.local.Saving.from({ draft: state.draft }) : undefined
state.valid ? target.local.Saving.from({ draft: state.draft }) : target.none()
```

Every installed event, `always`, `onDone`, and invoke lifecycle handler must
return a concrete target or `target.none()`. An absent event handler means the
event is ignored. Returning `target.none()` means it was handled without a
destination, so queued commands, raised events, and emitted events are still
retained. It remains valid when a transition declares `targets`: those paths
are an upper bound on concrete destinations, not an exhaustive result set.

`reenter: true` remains meaningful with `target.none()`: the source exits and
enters again while its logical configuration is retained.

Closed statechart and actor operations use `enqueue`:

```ts
Expand Down
22 changes: 13 additions & 9 deletions examples/platformer/src/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export const CharacterMachine = definition.handle({
targets: ["Character.locomotion.Playing.Grounded.Running"],
transition: ({ event, target }) =>
event.axis === 0
? undefined
? target.none()
: target.local.Running.from({ startedAt: event.at })
},
DownPressed: {
Expand All @@ -205,7 +205,8 @@ export const CharacterMachine = definition.handle({
on: {
Move: {
targets: ["Character.locomotion.Playing.Grounded.Standing"],
transition: ({ event, target }) => event.axis === 0 ? target.local.Standing.from() : undefined
transition: ({ event, target }) =>
event.axis === 0 ? target.local.Standing.from() : target.none()
},
DownPressed: {
targets: ["Character.locomotion.Playing.Grounded.Ducking"],
Expand Down Expand Up @@ -245,8 +246,10 @@ export const CharacterMachine = definition.handle({
on: {
Move: {
targets: ["Character.locomotion.Playing.Grounded.Landing"],
transition: ({ event, state, target }) =>
target.local.Landing(Machine.retag(State.cases.Landing, state, { resumeAxis: event.axis }))
transition: ({ event, state, target }) => {
const { _tag: _, ...fields } = state
return target.local.Landing.from({ ...fields, resumeAxis: event.axis })
}
}
}
}
Expand All @@ -256,13 +259,14 @@ export const CharacterMachine = definition.handle({
on: {
JumpPressed: {
targets: [],
transition: ({ event }, enqueue) => {
transition: ({ event, target }, enqueue) => {
const push = awayFrom(event.wall)
enqueue.raise(
push === 0
? InternalEvents.TryAirJump({ at: event.at })
: InternalEvents.WallJump({ at: event.at, push })
)
return target.none()
}
},
Landed: {
Expand Down Expand Up @@ -380,23 +384,23 @@ export const CharacterMachine = definition.handle({
on: {
Move: {
targets: ["Character.facing.Right"],
transition: ({ event, target }) => event.axis === 1 ? target.local.Right.from() : undefined
transition: ({ event, target }) => event.axis === 1 ? target.local.Right.from() : target.none()
},
WallJump: {
targets: ["Character.facing.Right"],
transition: ({ event, target }) => event.push === 1 ? target.local.Right.from() : undefined
transition: ({ event, target }) => event.push === 1 ? target.local.Right.from() : target.none()
}
}
},
Right: {
on: {
Move: {
targets: ["Character.facing.Left"],
transition: ({ event, target }) => event.axis === -1 ? target.local.Left.from() : undefined
transition: ({ event, target }) => event.axis === -1 ? target.local.Left.from() : target.none()
},
WallJump: {
targets: ["Character.facing.Left"],
transition: ({ event, target }) => event.push === -1 ? target.local.Left.from() : undefined
transition: ({ event, target }) => event.push === -1 ? target.local.Left.from() : target.none()
}
}
}
Expand Down
53 changes: 34 additions & 19 deletions examples/playground/src/examples/media-player/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,14 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({
invoke: Machine.invoke({
id: "load-audio",
effect: ({ state }) => loadAudio(state.url),
onDone: (_, enqueue) => enqueue.raise(MediaPlayerInternalEvents.LoadSucceeded()),
onFailure: ({ error }, enqueue) =>
onDone: ({ target }, enqueue) => {
enqueue.raise(MediaPlayerInternalEvents.LoadSucceeded())
return target.none()
},
onFailure: ({ error, target }, enqueue) => {
enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message }))
return target.none()
}
}),
on: {
LoadSucceeded: ({ target }) => target.local.Ready.from((ready) => ready.Paused.from(initialPlaybackData))
Expand All @@ -49,9 +54,11 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({
invoke: Machine.invoke({
id: "pause-audio",
effect: pauseAudio,
onDone: () => undefined,
onFailure: ({ error }, enqueue) =>
onDone: ({ target }) => target.none(),
onFailure: ({ error, target }, enqueue) => {
enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message }))
return target.none()
}
}),
on: {
PlayRequested: ({ state, target }) =>
Expand All @@ -69,18 +76,21 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({
Machine.invoke({
id: "play-audio",
effect: playAudio,
onDone: () => undefined,
onFailure: ({ error }, enqueue) =>
onDone: ({ target }) => target.none(),
onFailure: ({ error, target }, enqueue) => {
enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message }))
return target.none()
}
}),
Machine.invoke({
id: "analyze-audio",
address: Machine.childAddress("analyze-audio"),
logic: analyzeAudio,
onDone: () => undefined,
onSnapshot: ({ snapshot }, enqueue) => {
onDone: ({ target }) => target.none(),
onSnapshot: ({ snapshot, target }, enqueue) => {
const event = loudnessEvent(snapshot.state)
if (event !== undefined) enqueue.raise(event)
return target.none()
}
})
],
Expand Down Expand Up @@ -136,9 +146,14 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({
invoke: Machine.invoke({
id: "restart-audio",
effect: restartAudio,
onDone: (_, enqueue) => enqueue.raise(MediaPlayerInternalEvents.RestartSucceeded()),
onFailure: ({ error }, enqueue) =>
onDone: ({ target }, enqueue) => {
enqueue.raise(MediaPlayerInternalEvents.RestartSucceeded())
return target.none()
},
onFailure: ({ error, target }, enqueue) => {
enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message }))
return target.none()
}
}),
on: {
RestartSucceeded: ({ target }) => target.local.Playing.from({ currentTime: 0, loudness: null }),
Expand All @@ -159,27 +174,27 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({
},

Failed: {
invoke: {
invoke: Machine.invoke({
id: "report-error",
effect: ({ state }) =>
Effect.gen(function*() {
const mediaPlayer = yield* MediaPlayer
yield* mediaPlayer.reportError(state.message)
}),
onDone: () => undefined
}
onDone: ({ target }) => target.none()
})
}
}
},

settings: {
states: {
Audible: {
invoke: {
invoke: Machine.invoke({
id: "apply-audio-settings",
effect: ({ state }) => applyAudioSettings(state, false),
onDone: () => undefined
},
onDone: ({ target }) => target.none()
}),
on: {
VolumeChanged: {
reenter: true,
Expand Down Expand Up @@ -208,11 +223,11 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({
},

Muted: {
invoke: {
invoke: Machine.invoke({
id: "apply-audio-settings",
effect: ({ state }) => applyAudioSettings(state, true),
onDone: () => undefined
},
onDone: ({ target }) => target.none()
}),
on: {
VolumeChanged: {
reenter: true,
Expand Down
2 changes: 1 addition & 1 deletion examples/playground/src/examples/microwave/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const MicrowaveMachine = definition.handle({
PowerPressed: ({ snapshot, target }) =>
MicrowaveStates.matches(snapshot, "Oven.door.Closed")
? target.local.Cooking.from({ elapsedSeconds: 0 })
: undefined
: target.none()
}
},
Cooking: {
Expand Down
2 changes: 1 addition & 1 deletion examples/pokemon/src/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const machine = Machine.make({
invoke: [
Machine.invoke({
child: SelectionChild,
onDone: () => undefined,
onDone: ({ target }) => target.none(),
onFailure: ({ target }) => target.full.Failed.from()
}),
Machine.invoke({ child: ReplaceChild, onFailure: ({ target }) => target.full.Failed.from() })
Expand Down
5 changes: 4 additions & 1 deletion examples/pokemon/src/machines/replace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ export const ReplaceMachine = Machine.make({
invoke: Machine.invoke({
id: "replaceWithRandom",
effect: replaceWithRandom,
onDone: ({ output }, enqueue) => enqueue.raise(ReplaceInternalEvents.Replaced({ pokemon: output.pokemon })),
onDone: ({ output, target }, enqueue) => {
enqueue.raise(ReplaceInternalEvents.Replaced({ pokemon: output.pokemon }))
return target.none()
},
onFailure: ({ target }) => target.full.Idle.from()
}),
on: {
Expand Down
12 changes: 6 additions & 6 deletions perf/runtime/counter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ const counterParentMachine = Machine.make({
initial: () => ParentStates.initial.Active.from()
}).handle({
Active: {
invoke: benchmarkApi.invokeChild({ child: CounterChild, onDone: () => undefined })
invoke: benchmarkApi.invokeChild({ child: CounterChild, onDone: benchmarkApi.targetless })
}
})

Expand All @@ -87,8 +87,8 @@ const counterSnapshotParentMachine = Machine.make({
Active: {
invoke: benchmarkApi.invokeChild({
child: CounterChild,
onDone: () => undefined,
onSnapshot: () => undefined
onDone: benchmarkApi.targetless,
onSnapshot: benchmarkApi.targetless
})
}
})
Expand Down Expand Up @@ -308,7 +308,7 @@ const waitForCounterChild = (parent) =>
}
yield* Effect.yieldNow
}
return yield* Effect.dieMessage("Effect Machine child did not become ready")
return yield* Effect.die(new Error("Effect Machine child did not become ready"))
})

export const startChildCounter = () =>
Expand All @@ -328,13 +328,13 @@ export const runChildCounterBurst = (parent, size) =>
for (let index = 0; index < size; index += 1) {
const child = yield* parent.child(CounterChild)
if (Option.isNone(child)) {
return yield* Effect.dieMessage("Effect Machine child disappeared during the benchmark")
return yield* Effect.die(new Error("Effect Machine child disappeared during the benchmark"))
}
yield* child.value.send(incrementEvent)
}
const child = yield* parent.child(CounterChild)
if (Option.isNone(child)) {
return yield* Effect.dieMessage("Effect Machine child disappeared before the terminal fence")
return yield* Effect.die(new Error("Effect Machine child disappeared before the terminal fence"))
}
yield* child.value.send(finishEvent)
return yield* child.value.join
Expand Down
1 change: 1 addition & 0 deletions perf/runtime/effect-machine-compatibility.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const makeEffectMachineBenchmarkApi = (Machine) => ({
events: typeof Machine.event === "function"
? (...schemas) => schemas
: (...schemas) => Machine.events(...schemas),
targetless: ({ target }) => typeof target.none === "function" ? target.none() : undefined,
invokeChild: typeof Machine.invokeMachine === "function"
? ({ onSnapshot, onFailure, ...config }) => {
if (onFailure !== undefined) {
Expand Down
Loading