Skip to content

Commit 1d1f35f

Browse files
Add typed history states
1 parent e690c05 commit 1d1f35f

9 files changed

Lines changed: 2563 additions & 116 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
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.

README.md

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ masquerade as an internal result.
147147
148148
## Statechart structure
149149
150-
`Machine.defineStates` accepts atomic, compound, parallel, and final state
150+
`Machine.defineStates` accepts atomic, compound, parallel, final, and history
151151
nodes:
152152
153153
```ts
@@ -205,6 +205,70 @@ Put data on the narrowest state where it is valid. If several sibling phases
205205
share data, prefer storing it on their compound parent instead of copying it
206206
into every child state.
207207

208+
### History states
209+
210+
A history pseudo-state remembers the last active configuration of its parent.
211+
It has no value schema and never appears in an active snapshot. History is
212+
shallow by default; use `history: "deep"` to retain the complete descendant
213+
configuration and its validated values:
214+
215+
```ts
216+
const States = Machine.defineStates({
217+
checkout: {
218+
schema: Checkout,
219+
initial: "shipping",
220+
states: {
221+
shipping: Shipping,
222+
payment: {
223+
schema: Payment,
224+
initial: "cardEntry",
225+
states: {
226+
cardEntry: CardEntry,
227+
verifying: Verifying
228+
}
229+
},
230+
resume: { type: "history", history: "deep" }
231+
}
232+
},
233+
support: Support
234+
})
235+
```
236+
237+
Implement a typed default for the first transition before any configuration
238+
has been remembered, then target history without supplying a state value:
239+
240+
```ts
241+
machine.handle({
242+
checkout: {
243+
history: {
244+
resume: {
245+
default: () => initialCheckoutSnapshot
246+
}
247+
}
248+
},
249+
support: {
250+
on: {
251+
Resume: ({ target }) => target.history.checkout.resume()
252+
}
253+
}
254+
})
255+
```
256+
257+
Deep history restores every remembered descendant value. Shallow history
258+
restores the parent and direct-child values, then follows normal initial paths.
259+
Only compound or parallel states that shallow restoration can enter implicitly
260+
need an `initial` handler to construct those new child values:
261+
262+
```ts
263+
payment: {
264+
initial: ({ state }) => new CardEntry({ attempt: state.attempt, cardNumber: "" })
265+
}
266+
```
267+
268+
Execution APIs remain unavailable until required history defaults and shallow
269+
initializers have been implemented. History records are part of logical
270+
snapshots and are schema-validated by `encodeSnapshot` and `decodeSnapshot`.
271+
208272
Transition between structurally related tagged states with `Machine.retag`.
209273
The source `_tag` is discarded, compatible fields are reused, and missing or
210274
incompatible required fields must be supplied:
@@ -215,13 +279,14 @@ const saving = Machine.retag(State.cases.Saving, editing)
215279

216280
## Choosing a target builder
217281

218-
Transition contexts expose three typed target builders:
282+
Transition contexts expose four typed target builders:
219283

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

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

405470
## Current limits
406471

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

411476
## Guidance for agents and contributors
412477

docs/agent-guide.md

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ its extra control is required:
104104
the same operation with a returned transition value, not a separate action
105105
API.
106106

107-
## Atomic, compound, and parallel states
107+
## Atomic, compound, parallel, and history states
108108

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

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

204+
Declare a history pseudo-state below the active parent whose configuration it
205+
should remember. It has no schema, is excluded from active state identifiers,
206+
and is addressed only through `target.history`:
207+
208+
```ts
209+
const States = Machine.defineStates({
210+
checkout: {
211+
schema: Checkout,
212+
initial: "shipping",
213+
states: {
214+
shipping: Shipping,
215+
payment: {
216+
schema: Payment,
217+
initial: "cardEntry",
218+
states: {
219+
cardEntry: CardEntry,
220+
verifying: Verifying
221+
}
222+
},
223+
recent: { type: "history" },
224+
exact: { type: "history", history: "deep" }
225+
}
226+
},
227+
support: Support
228+
})
229+
```
230+
231+
Every history node needs a default parent snapshot for the first use:
232+
233+
```ts
234+
checkout: {
235+
history: {
236+
recent: { default: () => initialCheckoutSnapshot },
237+
exact: { default: () => initialCheckoutSnapshot }
238+
}
239+
}
240+
```
241+
242+
Target it without a value:
243+
244+
```ts
245+
Resume: ({ target }) => target.history.checkout.exact()
246+
```
247+
248+
Deep history restores the complete remembered subtree and its decoded values.
249+
Shallow history restores only parent and direct-child values. If the remembered
250+
child is compound, its configured initial child needs a freshly constructed
251+
value, so implement `initial` only on paths required by shallow history:
252+
253+
```ts
254+
payment: {
255+
initial: ({ state }) => new CardEntry({ attempt: state.attempt, cardNumber: "" })
256+
}
257+
```
258+
259+
The machine's readiness type tracks missing defaults and shallow initializers.
260+
History is an overwriteable register, not a stack: restoration does not consume
261+
it, and the next parent exit replaces it. Entry actions and invokes run again;
262+
prior effects, actors, and timers are not rewound.
263+
204264
## Choosing a target
205265

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

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

612673
The current API does not include:
613674

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

618678
Use ordinary TypeScript conditions for guards and `Machine.after` for
619679
state-scoped timers. Do not invent undocumented state-node properties such as
620-
`guard` or `history`.
680+
`guard`.

0 commit comments

Comments
 (0)