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
11 changes: 11 additions & 0 deletions .changeset/soft-hounds-decline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@typeonce/effect-machine": minor
---

Add opt-in declinable transitions for conditional statechart dispatch.

Set `declinable: true` on `Machine.transition` to expose a typed `decline()` resolver capability. Declining selects no transition, discards operations enqueued by that resolver, and lets hierarchical event or eventless dispatch continue with the next eligible ancestor. `target.none()` remains handled and continues to consume the trigger.

Declining a completion or invocation outcome ignores that lifecycle occurrence because those triggers do not dispatch to ancestor handlers.

Static transition definitions now expose `acceptance: "required" | "declinable"` alongside their exact target branches. Choices and initial routing remain total and reject declinable transitions.
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ paths. `parent` always means the owning machine target.
| `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 |

Every installed transition handler returns either a concrete target or
Every required 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
Expand All @@ -392,6 +392,28 @@ 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.

Use `declinable: true` when a resolver may decide that its transition is not
enabled. Only that resolver receives `decline()`, and its return type expands to
accept the opaque declined result:

```ts
Submit: Machine.transition({
declinable: true,
target: (to) => to.local.Saving(),
resolve: ({ event, target, decline }) => accepts(event) ? target.from({ draft: event.draft }) : decline()
})
```

Declining discards work enqueued by that resolver. Event and eventless dispatch
continues with the next eligible ancestor; if no candidate accepts, no
transition is selected. This differs from `target.none()`, which consumes the
trigger and prevents an ancestor from handling it. `transitionDefinitions`
reports each handler's `acceptance` as `"required"` or `"declinable"` while
preserving the exact declared target branches. Choices and initial routing must
remain total and cannot use declinable transitions. Completion and invocation
outcomes have no ancestor candidate: declining one ignores that lifecycle
occurrence and leaves the current configuration active.

## Statechart capabilities

`Machine.states` supports:
Expand Down
35 changes: 32 additions & 3 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,34 @@ an optional `title` controls presentation and otherwise defaults to the key.
Selecting a branch whose target is `to.none()` handles the transition without a
destination while retaining queued commands, raised events, and emitted events.

Set `declinable: true` only when the resolver may decide that its transition is
not enabled. The flag adds a typed `decline()` capability to that resolver and
permits its opaque result:

```ts
Submit: Machine.transition({
declinable: true,
branches: (to) => ({
accepted: { target: to.local.Saving() },
consumed: { target: to.none() }
}),
resolve: ({ event, select, decline }) => {
if (!belongsToThisState(event)) return decline()
return event.consume ? select.consumed() : select.accepted.from()
}
})
```

Declining discards that resolver's enqueue buffer and resumes hierarchical
event or eventless selection at the next eligible ancestor. If no candidate
accepts, the trigger is unhandled. This is deliberately different from
`to.none()`, which consumes the trigger. `decline()` is absent and its result is
rejected unless the literal flag is present. Choice and initial routing remain
total and cannot decline. Static inspection exposes the distinction through
`TransitionDefinition.acceptance` without executing resolver code. Completion
and invocation outcomes have no ancestor candidate; declining one ignores that
lifecycle occurrence and leaves the current configuration active.

The `branches` callback runs once when handlers are installed. Its record uses
the deterministic ECMAScript property order for presentation and `branchIndex`;
array-index and symbol keys are rejected. Treat the string key as semantic:
Expand Down Expand Up @@ -1313,9 +1341,10 @@ const step = yield * probe.sendAndAwait(event)
```

Inspect `step.before`, `step.after`, `step.plan`, `step.handled`, and
`step.configurationChanged`. An ignored event has `handled: false` and an
empty microstep list, but still completes its acknowledgement. A targetless
handler has `handled: true` even if its before and after snapshots are equal.
`step.configurationChanged`. An ignored event, including one for which every
eligible candidate declines, has `handled: false` and an empty microstep list,
but still completes its acknowledgement. A targetless handler has
`handled: true` even if its before and after snapshots are equal.

Do not use a probe as a substitute for a domain completion event. The
acknowledgement covers the submitted event's synchronous macrostep, state
Expand Down
1 change: 1 addition & 0 deletions examples/platformer/src/machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ describe("platformer history integration", () => {
source: "Character.locomotion.Playing.Airborne.airJump",
trigger: { type: "event", event: "WallJump" },
reenter: true,
acceptance: "required",
branches: [{
type: "direct",
target: "Character.locomotion.Playing.Airborne.airJump.AirJumpWallLock",
Expand Down
Loading