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

Add fully typed shallow and deep history states. History targets restore schema-validated state values, support typed defaults before the first capture, require only the initializers needed by shallow restoration, preserve parallel configurations, and round-trip through snapshot encoding and decoding.
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,25 @@ jobs:
- run: pnpm --dir ../.. build
- run: pnpm install --frozen-lockfile
- run: pnpm check

platformer-example:
runs-on: ubuntu-latest
defaults:
run:
working-directory: examples/platformer
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
package_json_file: examples/platformer/package.json
- uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
examples/platformer/pnpm-lock.yaml
- run: pnpm --dir ../.. install --frozen-lockfile
- run: pnpm --dir ../.. build
- run: pnpm install --frozen-lockfile
- run: pnpm check
91 changes: 79 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ masquerade as an internal result.

## Statechart structure

`Machine.defineStates` accepts atomic, compound, parallel, and final state
`Machine.defineStates` accepts atomic, compound, parallel, final, and history
nodes:

```ts
Expand Down Expand Up @@ -205,6 +205,71 @@ Put data on the narrowest state where it is valid. If several sibling phases
share data, prefer storing it on their compound parent instead of copying it
into every child state.

### History states

A history pseudo-state remembers the last active configuration of its parent.
It has no value schema and never appears in an active snapshot. History is
shallow by default; use `history: "deep"` to retain the complete descendant
configuration and its validated values:

```ts
const States = Machine.defineStates({
checkout: {
schema: Checkout,
initial: "shipping",
states: {
shipping: Shipping,
payment: {
schema: Payment,
initial: "cardEntry",
states: {
cardEntry: CardEntry,
verifying: Verifying
}
},
resume: { type: "history", history: "deep" }
}
},
support: Support
})
```

Implement a typed default for the first transition before any configuration
has been remembered, then target history without supplying a state value:

```ts
machine.handle({
checkout: {
history: {
resume: {
default: () => initialCheckoutSnapshot
}
}
},
support: {
on: {
Resume: ({ target }) => target.history.checkout.resume()
}
}
})
```

Deep history restores every remembered descendant value. Shallow history
restores the parent and direct-child values, then follows normal initial paths.
Only compound or parallel states that shallow restoration can enter implicitly
need an `initial` handler to construct those new child values:

```ts
payment: {
initial: ;
;(({ state }) => new CardEntry({ attempt: state.attempt, cardNumber: "" }))
}
```

Execution APIs remain unavailable until required history defaults and shallow
initializers have been implemented. History records are part of logical
snapshots and are schema-validated by `encodeSnapshot` and `decodeSnapshot`.

Transition between structurally related tagged states with `Machine.retag`.
The source `_tag` is discarded, compatible fields are reused, and missing or
incompatible required fields must be supplied:
Expand All @@ -215,13 +280,14 @@ const saving = Machine.retag(State.cases.Saving, editing)

## Choosing a target builder

Transition contexts expose three typed target builders:
Transition contexts expose four typed target builders:

| Builder | Destination | Configuration behavior |
| --------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `target.local` | Inside the source's nearest compound scope | Keeps the compound value, active ancestors, and unrelated parallel regions |
| `target.branch` | Anywhere under the source's active top-level root | Replaces the selected branch while keeping omitted active ancestor values and parallel regions |
| `target.full` | Any top-level root | Builds a complete active snapshot for the selected root |
| Builder | Destination | Configuration behavior |
| ---------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `target.local` | Inside the source's nearest compound scope | Keeps the compound value, active ancestors, and unrelated parallel regions |
| `target.branch` | Anywhere under the source's active top-level root | Replaces the selected branch while keeping omitted active ancestor values and parallel regions |
| `target.full` | Any top-level root | Builds a complete active snapshot for the selected root |
| `target.history` | A declared history pseudo-state | Restores its parent's remembered configuration or runs its typed default |

When `target.local` or `target.branch` enters an inactive nested parallel
state, its callback must select every region, just like `initial` and
Expand Down Expand Up @@ -404,9 +470,9 @@ restrictions and delivery guarantees are documented on that API.

## Current limits

History states and declarative first-class guards are not part of the current
API. Ordinary TypeScript conditions implement guards. Use `Machine.after` for a
cancellable state-scoped delayed event.
Declarative first-class guards are not part of the current API. Ordinary
TypeScript conditions implement guards. Use `Machine.after` for a cancellable
state-scoped delayed event.

## Guidance for agents and contributors

Expand Down Expand Up @@ -434,8 +500,9 @@ TypeScript consumer with `skipLibCheck: false`.
The [platformer statechart example](./examples/platformer) is a playable SVG
demo centered on a schema-first character machine. It demonstrates nested
compound locomotion, parallel airborne motion and air-jump regions, independent
facing and wall-contact regions, typed protocol events, state-scoped timers,
and state-driven SVG transforms.
facing and wall-contact regions, a pause/resume flow backed by typed deep
history, typed protocol events, state-scoped timers, and state-driven SVG
transforms.

The [Pokémon statechart example](./examples/pokemon) is a standalone React and
Vite project demonstrating compound and parallel states, state-scoped invokes,
Expand Down
76 changes: 68 additions & 8 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ its extra control is required:
the same operation with a returned transition value, not a separate action
API.

## Atomic, compound, and parallel states
## Atomic, compound, parallel, and history states

Use an atomic state when no child phase can be active beneath it.

Expand Down Expand Up @@ -201,13 +201,74 @@ const machine = Machine.make({
Do not repeat `type: "final"` in `handle`. Execution APIs reject a machine
until every declared output schema has an implementation.

Declare a history pseudo-state below the active parent whose configuration it
should remember. It has no schema, is excluded from active state identifiers,
and is addressed only through `target.history`:

```ts
const States = Machine.defineStates({
checkout: {
schema: Checkout,
initial: "shipping",
states: {
shipping: Shipping,
payment: {
schema: Payment,
initial: "cardEntry",
states: {
cardEntry: CardEntry,
verifying: Verifying
}
},
recent: { type: "history" },
exact: { type: "history", history: "deep" }
}
},
support: Support
})
```

Every history node needs a default parent snapshot for the first use:

```ts
checkout: {
history: {
recent: { default: () => initialCheckoutSnapshot },
exact: { default: () => initialCheckoutSnapshot }
}
}
```

Target it without a value:

```ts
Resume: ({ target }) => target.history.checkout.exact()
```

Deep history restores the complete remembered subtree and its decoded values.
Shallow history restores only parent and direct-child values. If the remembered
child is compound, its configured initial child needs a freshly constructed
value, so implement `initial` only on paths required by shallow history:

```ts
payment: {
initial: ({ state }) => new CardEntry({ attempt: state.attempt, cardNumber: "" })
}
```

The machine's readiness type tracks missing defaults and shallow initializers.
History is an overwriteable register, not a stack: restoration does not consume
it, and the next parent exit replaces it. Entry actions and invokes run again;
prior effects, actors, and timers are not rewound.

## Choosing a target

| Builder | Use it when | What it preserves |
| --------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `target.local` | The destination is inside the nearest compound scope containing the source | The compound value, active ancestors, and unrelated parallel regions |
| `target.branch` | The destination is elsewhere under the active top-level root | Omitted current ancestor values and parallel regions |
| `target.full` | The destination may be under any top-level root | Nothing is inferred for a newly selected root; build its complete active snapshot |
| Builder | Use it when | What it preserves |
| ---------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `target.local` | The destination is inside the nearest compound scope containing the source | The compound value, active ancestors, and unrelated parallel regions |
| `target.branch` | The destination is elsewhere under the active top-level root | Omitted current ancestor values and parallel regions |
| `target.full` | The destination may be under any top-level root | Nothing is inferred for a newly selected root; build its complete active snapshot |
| `target.history` | The destination is a declared history pseudo-state | Its parent's remembered configuration, or its default before the first capture |

Entering an inactive parallel state through `target.local` or `target.branch`
requires a complete callback with one selection per region. A parallel state
Expand Down Expand Up @@ -611,10 +672,9 @@ a deeper statechart instead of casting away the diagnostic.

The current API does not include:

- history states;
- declarative first-class guards;
- a complete inspectable graph for arbitrary transition Effects.

Use ordinary TypeScript conditions for guards and `Machine.after` for
state-scoped timers. Do not invent undocumented state-node properties such as
`guard` or `history`.
`guard`.
31 changes: 25 additions & 6 deletions examples/platformer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,44 @@ pnpm check
- **W**, **up**, or **Space** — jump; press again once in the air for a double jump
- Touch either wall and jump — turn and kick away; repeat after returning to a wall
- **S** or **down** — duck while grounded; dive while airborne
- **P** — pause and resume the exact playable configuration through deep history
- **R** — reset

## Statechart

`Character` is parallel: `locomotion`, `facing`, and `contact` update
independently. The locomotion region is compound and makes `Grounded` and
`Airborne` mutually exclusive. Each branch is compound again:
independently. The locomotion region switches between `Playing` and `Paused`.
Inside `Playing`, `Grounded` and `Airborne` are mutually exclusive. Each branch
is compound again:

```text
Character (parallel)
├─ locomotion
│ ├─ Grounded: Standing | Running | Ducking | Landing
│ └─ Airborne (parallel)
│ ├─ motion: Jumping | Falling | Diving
│ └─ airJump: GroundLock | WallLock | Ready | Spent
│ ├─ Playing
│ │ ├─ Grounded: Standing | Running | Ducking | Landing
│ │ ├─ Airborne (parallel)
│ │ │ ├─ motion: Jumping | Falling | Diving
│ │ │ └─ airJump: GroundLock | WallLock | Ready | Spent
│ │ └─ resume (deep history)
│ └─ Paused
├─ facing: Left | Right
└─ contact: NoWall | LeftWall | RightWall
```

`Pause` exits `Playing`, which records its current deep configuration. Physics
stops while `Paused`. `Resume` targets `Playing.resume`, restoring both the
active descendants and their typed values: for example, an airborne wall jump
returns with its `originY`, `startedAt`, `push`, jump kind, and air-jump lock.
This is one saved configuration, not an undo stack; pausing again replaces the
previous history. The history implementation also supplies a typed default
`Playing` snapshot for the case where the history node is targeted before the
region has ever been exited.

State-scoped invocations follow normal statechart entry/exit semantics. Pausing
cancels an active landing or air-jump timer, and restoring that state starts its
invocation again. History restores state configuration and values, not elapsed
wall-clock time or the adapter's past events.

State payloads live only where they are valid: `Landing` owns impact and resume
direction, while `Airborne` owns only the jump origin. Air-jump availability is
modeled entirely as state: lock states own cancellable readiness timers,
Expand Down
Loading