aifsmjs 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,9 +8,9 @@
8
8
 
9
9
  > A small, strict FSM library for any TypeScript/JS app that needs deterministic, replayable state transitions. Lifecycle is a pure `step()` function. Chain-of-Responsibility intuition is reserved for cross-cutting concerns (observe / persist / replay), never for the transition core.
10
10
 
11
- **Primary audience**: developers building stateful flows — multi-step forms, checkout funnels, auth flows, tutorial sequences, document-status workflows, scene flow in interactive apps, and the same patterns in browser-based games (PixiJS / Svelte 5 / plain Canvas / WebGL). The core is **environment-neutral** (pure function + adapter boundary): browser, Node, Bun, Deno, Flutter WebView, and Web Workers all work. The Roadmap section keeps gaming-specific niceties (tick hook, ECS bridge) as opt-in subpaths, not core surface.
11
+ Part of the [ai\*js micro-runtime ecosystem](https://github.com/yshengliao) — see also [aibridgejs](https://github.com/yshengliao/aibridgejs) (cross-context RPC) and [aiecsjs](https://github.com/yshengliao/aiecsjs) (ECS).
12
12
 
13
- > Traditional Chinese version: [README_ZHTW.md](README_ZHTW.md).
13
+ **Primary audience**: developers building stateful flows — multi-step forms, checkout funnels, auth flows, tutorial sequences, document-status workflows, scene flow in interactive apps, and the same patterns in browser-based games (PixiJS / Svelte 5 / plain Canvas / WebGL). The core is **environment-neutral** (pure function + adapter boundary): browser, Node, Bun, Deno, Flutter WebView, and Web Workers all work. The Roadmap section keeps gaming-specific niceties (tick hook, ECS bridge) as opt-in subpaths, not core surface.
14
14
 
15
15
  ---
16
16
 
@@ -106,15 +106,16 @@ The three layers are fully decoupled: take `step()` alone for replay, take `Mach
106
106
 
107
107
  | Will do (v1) | Won't do |
108
108
  | --------------------------------------------------- | ------------------------------------------------- |
109
- | Flat states + transitions | Hierarchical / compound states |
109
+ | Flat states + transitions | Parallel state regions |
110
+ | Hierarchical sugar via `state.sub` (experimental, since 0.3.0) | Closures embedded in definition (breaks serialize) |
110
111
  | Guards (sync only; inline async throws `InvalidDefinitionError` at `defineMachine`; runtime throws `AsyncGuardError` on thenable return) | Async guards |
111
112
  | Actions (assign + enqueue effects) | Async API inside an action (use an effect) |
112
113
  | Fire-and-forget effects | Actor invocation / spawn |
113
114
  | Read-only inspect middleware | Cancellable transition middleware |
114
115
  | `replay(initial, log, def, impl)` pure function | Time-travel debugger (v2 candidate) |
115
116
  | `fast-check` `fc.commands` adapter | Custom PBT framework |
116
- | String ref + runtime injection | Closures embedded in definition (breaks serialize) |
117
- | Tree-shake friendly subpath exports | Single root import for everything |
117
+ | String ref + runtime injection | Single root import for everything |
118
+ | Tree-shake friendly subpath exports | ECS / Pixi bridges (opt-in subpath, not core) |
118
119
 
119
120
  ---
120
121
 
@@ -356,6 +357,27 @@ Non-goals:
356
357
  - No async lifecycle hook
357
358
  - Inspect middleware cannot alter the transition outcome
358
359
 
360
+ ### Sub-machine lifecycle (since 0.3.0, experimental)
361
+
362
+ When a state declares `sub`, the per-transition ordering is:
363
+
364
+ 1. Parent `step()` runs: `exit actions → transition.actions → entry actions`.
365
+ 2. Old child (if any) `dispose()` — synchronous; exceptions become
366
+ `SubMachineError(phase: "dispose")`.
367
+ 3. New child (if next state has `sub`) instantiation — exceptions become
368
+ `SubMachineError(phase: "init")`.
369
+ 4. Parent snapshot commits.
370
+ 5. Middleware pipeline runs.
371
+ 6. Effects dispatch.
372
+ 7. `'transition'` event emits to `on()` / `onTransition()` subscribers.
373
+
374
+ If step 2 or 3 throws, the parent snapshot is **not** committed (rollback
375
+ to `prev`); no middleware / effects / `'transition'` runs.
376
+
377
+ `runtime.dispose()` cascades to the child via `controller.signal`'s abort
378
+ listener and an explicit `child.dispose()` call. Cascade swallows child
379
+ exceptions to honour the never-throws dispose contract.
380
+
359
381
  ---
360
382
 
361
383
  ## Lifecycle Protocol
@@ -438,7 +460,7 @@ Example-first, PBT-augmented. Lesson from jssm: "3000+ tests / 100% coverage" tu
438
460
  - **Example tests** (vitest): for every src module, write happy path + edge + error-message triplets.
439
461
  - **PBT smoke**: each generic property runs 50 iterations as an invariant guard, not as a coverage source.
440
462
  - **CI-enforced thresholds**: `@vitest/coverage-v8` is wired to **100% statements / 100% lines / 100% functions / ≥90% branches**. The few defensive invariant-guard branches (e.g. runtime determinism mismatch) carry `/* v8 ignore */` annotations with rationale.
441
- - **Size budget**: `scripts/check-size.mjs` enforces per-subpath gzip caps in CI — core ≤3 KB, replay ≤1.6 KB, pbt ≤4.5 KB, others ≤1 KB. Exceeding any cap fails the build.
463
+ - **Size budget**: `scripts/check-size.mjs` enforces per-subpath gzip caps in CI — core ≤4.7 KB (raised in 0.3.0 for sub-machine sugar), replay ≤1.8 KB, pbt ≤5.5 KB (raised in 0.3.0 because `pbt` transitively imports `createRuntime`), others ≤1 KB. Exceeding any cap fails the build.
442
464
 
443
465
  ### The 6 built-in generic properties
444
466
 
@@ -458,7 +480,7 @@ Example-first, PBT-augmented. Lesson from jssm: "3000+ tests / 100% coverage" tu
458
480
  | | aifsmjs | XState v5 | Robot3 | @xstate/store | Zag.js |
459
481
  | -------------------------- | -------------- | ----------------- | ----------------- | ----------------- | ----------------- |
460
482
  | Core size (gzip) | ~2.8KB | ~15KB | ~1KB | < 1KB | per-component |
461
- | Hierarchical states | No (v1) | Yes | No | N/A | Yes |
483
+ | Hierarchical states | Sugar (0.3.0) | Yes | No | N/A | Yes |
462
484
  | Async invoke / actor | No | Yes | No | N/A | No |
463
485
  | Guard combinators | and/or/not | and/or/not | No | N/A | No |
464
486
  | Effects dual-track | enqueue | enqueueActions | reduce/action | enq.effect() | array of names |
@@ -474,12 +496,23 @@ Example-first, PBT-augmented. Lesson from jssm: "3000+ tests / 100% coverage" tu
474
496
  | Version | Scope |
475
497
  | ------- | ------------------------------------------------------------------ |
476
498
  | v0.1 | core + guards + effects + inspect + replay + pbt (this release) |
477
- | v0.2 | Hierarchical / compound states, including entry/exit ordering |
478
- | v0.3 | Parallel state regions |
479
- | v0.4 | Actor invocation (async) and spawn |
499
+ | v0.2 | Async-guard detection, coverage tuning, llms-full.txt verify gate |
500
+ | v0.3 | Hierarchical sugar via `state.sub` (experimental, this release) |
501
+ | v0.4 | `historyState` — remember last active sub-state on re-entry |
480
502
  | v0.5 | `aifsmjs-bridge-bitecs` / `aifsmjs-bridge-pixi` (separate sub-packages) |
481
503
  | v1.0 | API freeze and stability guarantee |
482
504
 
505
+ **Out of scope (v1)**:
506
+
507
+ - **Parallel state regions** (out of scope for v1)
508
+ - **Actor invocation / spawn** (out of scope for v1)
509
+ - **Tick / game-loop hook** (out of scope for v1)
510
+ - **ECS / Pixi bridges** (out of scope for v1)
511
+
512
+ **v0.4 candidate**: `historyState` — remember last active sub-state on
513
+ re-entry. Workaround today: snapshot via `onTransition` and restore
514
+ manually.
515
+
483
516
  ---
484
517
 
485
518
  ## License
package/README_ZHTW.md CHANGED
@@ -8,6 +8,8 @@
8
8
 
9
9
  > 一個小而嚴格的 FSM library,為任何需要可重現、可重播狀態流轉的 TypeScript/JS app 而生:把 lifecycle 寫成 pure `step()`,把 Chain-of-Responsibility 直覺收斂到 cross-cutting concerns(observe / persist / replay)── 而非 transition 主流程。
10
10
 
11
+ 隸屬 [ai\*js micro-runtime 生態系](https://github.com/yshengliao) ─ 另見 [aibridgejs](https://github.com/yshengliao/aibridgejs)(cross-context RPC)與 [aiecsjs](https://github.com/yshengliao/aiecsjs)(ECS)。
12
+
11
13
  **主要受眾**:所有處理 stateful flow 的工程師 ── 多步驟表單、checkout 流程、auth flow、教學引導步驟、文件審批狀態機、互動 app 的 scene flow,以及瀏覽器遊戲的相同模式(PixiJS / Svelte 5 / 純 Canvas / WebGL)。Library 本身**環境中立**(pure core + adapter 邊界):browser、Node、Bun、Deno、Flutter WebView、Web Worker 全部都跑。Roadmap 段把遊戲特有的便利功能(tick hook、ECS bridge)保留為 opt-in subpath,不進 core surface。
12
14
 
13
15
  ---
@@ -103,15 +105,16 @@ console.log(runtime.getSnapshot().context); // { ticks: 1 }
103
105
 
104
106
  | 會做(v1) | 不會做 |
105
107
  | --------------------------------------------------- | ------------------------------------------------- |
106
- | Flat states + transitions | Hierarchical / compound states |
108
+ | Flat states + transitions | Parallel state regions |
109
+ | 透過 `state.sub` 的階層式 sugar(experimental,since 0.3.0) | Definition 內直接綁 closure(會無法序列化) |
107
110
  | Guards(sync only;inline async 在 `defineMachine` 時丟 `InvalidDefinitionError`;runtime 偵測到 thenable 回傳則丟 `AsyncGuardError`) | Async guards |
108
111
  | Actions(assign + enqueue effects) | 在 action 內呼叫 async API(請放到 effect) |
109
112
  | Fire-and-forget effects | Actor invocation / spawn |
110
113
  | Read-only inspect middleware | 可中止 transition 的 middleware |
111
114
  | `replay(initial, log, def, impl)` 純函式 | Time travel debugger(v2 再評估) |
112
115
  | `fast-check` `fc.commands` adapter | 自家 PBT framework |
113
- | String ref + runtime injection | Definition 內直接綁 closure(會無法序列化) |
114
- | Tree-shake friendly subpath exports | 從 root 一次 import 全部 |
116
+ | String ref + runtime injection | 從 root 一次 import 全部 |
117
+ | Tree-shake friendly subpath exports | ECS / Pixi bridges(opt-in subpath,不進 core) |
115
118
 
116
119
  ---
117
120
 
@@ -353,6 +356,22 @@ sched.cancelAll();
353
356
  - async lifecycle hook
354
357
  - Inspect middleware 影響 transition 結果
355
358
 
359
+ ### Sub-machine lifecycle(since 0.3.0,experimental)
360
+
361
+ 當一個 state 宣告 `sub` 時,每次 transition 的執行順序為:
362
+
363
+ 1. Parent `step()` 執行:`exit actions → transition.actions → entry actions`。
364
+ 2. 舊 child(若有)`dispose()` — 同步執行;例外會包成 `SubMachineError(phase: "dispose")`。
365
+ 3. 新 child(若 next state 有 `sub`)實例化 — 例外會包成 `SubMachineError(phase: "init")`。
366
+ 4. Parent snapshot 確認提交。
367
+ 5. Middleware pipeline 執行。
368
+ 6. Effects 派發。
369
+ 7. `'transition'` 事件發給 `on()` / `onTransition()` 的訂閱者。
370
+
371
+ 若步驟 2 或 3 丟例外,parent snapshot **不會**提交(回滾至 `prev`);middleware、effects、`'transition'` 都不會執行。
372
+
373
+ `runtime.dispose()` 透過 `controller.signal` 的 abort listener 以及顯式的 `child.dispose()` 呼叫,將 dispose 行為串聯到 child。Cascade 會吞掉 child 的例外,以遵守永不丟錯的 dispose 契約。
374
+
356
375
  ---
357
376
 
358
377
  ## Lifecycle Protocol
@@ -435,7 +454,7 @@ aifsmjs 在幾個常見議題上做了刻意取捨,跟主流 FSM library 寫
435
454
  - **Example tests**(vitest):對每個 src module 寫 happy path + 邊界 + error message 三類。
436
455
  - **PBT smoke**:每條 generic property 跑 50 runs,作為 invariant guard,不追求 coverage。
437
456
  - **CI 強制門檻**:`@vitest/coverage-v8` 設 **100% statements / 100% lines / 100% functions / ≥90% branches**。少數 defensive invariant-guard 分支(例如 runtime determinism mismatch)標 `/* v8 ignore */` 並寫明原因。
438
- - **Size budget**:`scripts/check-size.mjs` 在 CI 檢查每個 subpath gzip 大小,超過預算(core ≤3 KB、replay ≤1.6 KB、pbt ≤4.5 KB、其他 ≤1 KB)即 fail。
457
+ - **Size budget**:`scripts/check-size.mjs` 在 CI 檢查每個 subpath gzip 大小,超過預算(core ≤4.7 KB、replay ≤1.8 KB、pbt ≤5.5 KB、其他 ≤1 KB;0.3.0 為了 sub-machine sugar 提高 core / pbt 上限)即 fail。
439
458
 
440
459
  ### 內建的 6 條 generic properties
441
460
 
@@ -455,7 +474,7 @@ aifsmjs 在幾個常見議題上做了刻意取捨,跟主流 FSM library 寫
455
474
  | | aifsmjs | XState v5 | Robot3 | @xstate/store | Zag.js |
456
475
  | -------------------------- | -------------- | ----------------- | ----------------- | ----------------- | ----------------- |
457
476
  | Core size (gzip) | ~2.8KB | ~15KB | ~1KB | < 1KB | per-component |
458
- | Hierarchical states | No (v1) | Yes | No | N/A | Yes |
477
+ | Hierarchical states | Sugar (0.3.0) | Yes | No | N/A | Yes |
459
478
  | Async invoke / actor | No | Yes | No | N/A | No |
460
479
  | Guard combinators | and/or/not | and/or/not | No | N/A | No |
461
480
  | Effects 雙軌 | enqueue | enqueueActions | reduce/action | enq.effect() | array of names |
@@ -471,12 +490,21 @@ aifsmjs 在幾個常見議題上做了刻意取捨,跟主流 FSM library 寫
471
490
  | 版本 | 範圍 |
472
491
  | ---- | ----------------------------------------------------------------- |
473
492
  | v0.1 | core + guards + effects + inspect + replay + pbt(本次發佈) |
474
- | v0.2 | Hierarchical / compound states,含 entry/exit 階層順序 |
475
- | v0.3 | Parallel state regions |
476
- | v0.4 | Actor invocation(async)與 spawn |
493
+ | v0.2 | Async-guard 偵測、coverage 調整、llms-full.txt verify gate |
494
+ | v0.3 | 透過 `state.sub` 的階層式 sugar(experimental,本次發佈) |
495
+ | v0.4 | `historyState` — re-entry 時恢復上一次的 sub-state |
477
496
  | v0.5 | `aifsmjs-bridge-bitecs` / `aifsmjs-bridge-pixi`(獨立 sub-package) |
478
497
  | v1.0 | API freeze 與 stability guarantee |
479
498
 
499
+ **不在 v1 範圍內**:
500
+
501
+ - **Parallel state regions**(v1 不做)
502
+ - **Actor invocation / spawn**(v1 不做)
503
+ - **Tick / game-loop hook**(v1 不做)
504
+ - **ECS / Pixi bridges**(v1 不做)
505
+
506
+ **v0.4 候選**:`historyState` — re-entry 時自動恢復上一次的 sub-state。0.3.0 的暫代方案:透過 `onTransition` snapshot sub-runtime 的 value,之後手動還原。
507
+
480
508
  ---
481
509
 
482
510
  ## License
package/STABILITY.md ADDED
@@ -0,0 +1,77 @@
1
+ # Stability
2
+
3
+ This document defines the stability tier of every public symbol exported by
4
+ `aifsmjs`. Tiers govern what breaks may occur in future minor / major bumps.
5
+
6
+ ## Stable (since 0.1.0)
7
+
8
+ Fully stable. Breaking changes only at a major version bump (1.0+).
9
+
10
+ - `createMachine`, `defineMachine`, `setup`, `createRuntime`, `initialSnapshot`
11
+ - `step`, `resolveTransitions`, `evalGuard`, `resolveGuard`, `isAsyncGuardFn`
12
+ - `assign`, `mergeContext`, `createSnapshot`, `deepFreeze`, `freezeSnapshot`
13
+ - `Runtime.send`, `Runtime.reset`, `Runtime.can`, `Runtime.getSnapshot`,
14
+ `Runtime.snapshot`, `Runtime.subscribe`, `Runtime.on`, `Runtime.dispose`,
15
+ `Runtime.signal`, `Runtime.disposed`
16
+ - All error classes from 0.1.0–0.2.1: `RuntimeDisposedError`,
17
+ `InvalidDefinitionError`, `UnknownActionError`, `UnknownGuardError`,
18
+ `AsyncGuardError`
19
+ - Types: `MachineDef`, `StateDef` (fields `on`, `entry`, `exit`, `final`),
20
+ `TransitionDef`, `Snapshot`, `Implementations`, `Guard`, `Action`,
21
+ `EffectHandler`, `Effect`, `Enqueuer`, `Middleware`, `MiddlewareContext`,
22
+ `RuntimeOptions`, `StepResult`, `ResetEvent`, `RESET_EVENT_TYPE`,
23
+ `RuntimeTransitionEvent`, `RuntimeErrorEvent`, `RuntimeEventMap`
24
+ - All subpath exports: `aifsmjs/guards`, `aifsmjs/effects`, `aifsmjs/inspect`,
25
+ `aifsmjs/replay`, `aifsmjs/pbt`, `aifsmjs/timer`
26
+ - `Runtime.onTransition` (added in 0.3.0) — pure sugar over the stable
27
+ `on('transition', ...)` API; listed under Stable because the underlying
28
+ contract is unchanged.
29
+
30
+ ## Experimental (since 0.3.0)
31
+
32
+ Shape and behaviour may change in any minor bump until 1.0. Production use
33
+ is OK; expect a one-line patch on minor upgrades.
34
+
35
+ - `StateDef.sub` (optional `SubMachineDef`) — when present, a child runtime
36
+ is lazily initialised on entry and disposed on exit. Per-transition
37
+ ordering is parent `step()` (exit / actions / entry) → child dispose →
38
+ child init → snapshot commit.
39
+ - `StateDef.subImpl` (optional `Implementations`) — paired with `sub`;
40
+ passed to the child `createRuntime`. Defaults to `{}`.
41
+ - `Runtime.subRuntime()` — returns the live child handle, or `undefined`.
42
+ Returned generic is `Runtime<unknown, { type: string }, string>`; caller
43
+ narrows via cast if necessary.
44
+ - `SubMachineError` — thrown by `send()` / `reset()` on child init/dispose
45
+ failure. Fields: `parentState`, `phase ("init" | "dispose")`, `cause`.
46
+ - `SubMachineDef` type alias.
47
+
48
+ ### Known boundaries (0.3.0)
49
+
50
+ - **Replay / PBT do not see child state.** `replay()` and
51
+ `commandsFromMachine` only inspect parent snapshots. If your business
52
+ logic lives in the parent layer, replay is still deterministic.
53
+ - **`subRuntime()` may return a disposed handle** if an external caller
54
+ disposed it. The handle is not reinitialised until the parent leaves and
55
+ re-enters the sub-bearing state. Detect with `child.disposed`.
56
+ - **Self-targeting external (`A → A`) is treated as full exit/entry**:
57
+ child is disposed and reinitialised. The 0.3.0 dispatcher re-resolves
58
+ guards to identify the chosen transition before deciding external vs
59
+ internal, so guarded internal transitions on the same event no longer
60
+ trigger a reinit.
61
+ - **Init-failure mid-transition leaves the parent without a live child.**
62
+ If `applySubLifecycle` successfully disposes the old child and then the
63
+ new child's `createRuntime` throws, the parent's snapshot is rolled back
64
+ to `prev` but `subRuntime()` returns `undefined`. Callers catching
65
+ `SubMachineError(phase: "init")` should treat the runtime as quarantined
66
+ — call `runtime.dispose()` (idempotent) or `runtime.reset()` (which
67
+ attempts re-init) before sending further events. A future major may
68
+ switch to a two-phase "init before dispose" commit strategy; for 0.3.x
69
+ this remains opt-in only when child failures are expected.
70
+
71
+ ## Draft (planned, not implemented in 0.3.0)
72
+
73
+ API sketched, not shipped. May change before release.
74
+
75
+ - `historyState` (v0.4 candidate) — opt-in pseudo-state that remembers
76
+ the last active sub-state on re-entry. Workaround in 0.3.0: snapshot
77
+ the sub-runtime's value on exit via `onTransition`, restore manually.
@@ -1,4 +1,4 @@
1
- import { c as Enqueuer, E as Effect, b as EffectHandler } from '../types-BLwKAHnW.cjs';
1
+ import { c as Enqueuer, E as Effect, b as EffectHandler } from '../types-CGKk6Rur.cjs';
2
2
 
3
3
  /**
4
4
  * Build a closure-based Enqueuer that pushes effects into the supplied sink.
@@ -1,4 +1,4 @@
1
- import { c as Enqueuer, E as Effect, b as EffectHandler } from '../types-BLwKAHnW.js';
1
+ import { c as Enqueuer, E as Effect, b as EffectHandler } from '../types-CGKk6Rur.js';
2
2
 
3
3
  /**
4
4
  * Build a closure-based Enqueuer that pushes effects into the supplied sink.
@@ -1,4 +1,4 @@
1
- import { e as GuardRef, G as Guard } from '../types-BLwKAHnW.cjs';
1
+ import { e as GuardRef, G as Guard } from '../types-CGKk6Rur.cjs';
2
2
 
3
3
  /** Logical AND over guards. Short-circuits on the first `false`. */
4
4
  declare function and<Ctx, Evt>(items: readonly GuardRef<Ctx, Evt>[]): Guard<Ctx, Evt>;
@@ -1,4 +1,4 @@
1
- import { e as GuardRef, G as Guard } from '../types-BLwKAHnW.js';
1
+ import { e as GuardRef, G as Guard } from '../types-CGKk6Rur.js';
2
2
 
3
3
  /** Logical AND over guards. Short-circuits on the first `false`. */
4
4
  declare function and<Ctx, Evt>(items: readonly GuardRef<Ctx, Evt>[]): Guard<Ctx, Evt>;
package/dist/index.cjs CHANGED
@@ -192,6 +192,18 @@ var RuntimeDisposedError = class extends Error {
192
192
  this.name = "RuntimeDisposedError";
193
193
  }
194
194
  };
195
+ var SubMachineError = class extends Error {
196
+ parentState;
197
+ phase;
198
+ cause;
199
+ constructor(parentState, phase, cause) {
200
+ super(`aifsmjs: sub-machine ${phase} failed at parent state "${parentState}"`, { cause });
201
+ this.name = "SubMachineError";
202
+ this.parentState = parentState;
203
+ this.phase = phase;
204
+ this.cause = cause;
205
+ }
206
+ };
195
207
  var RESET_EVENT = Object.freeze({ type: RESET_EVENT_TYPE });
196
208
  function composeMiddleware(middleware) {
197
209
  return (ctx, finalNext) => {
@@ -216,6 +228,8 @@ function createRuntime(def, impl, opts = {}) {
216
228
  const shouldDispatch = opts.dispatchEffects !== false;
217
229
  const controller = new AbortController();
218
230
  let disposed = false;
231
+ let childRuntime;
232
+ let childAbortCleanup;
219
233
  const eventListeners = {
220
234
  transition: /* @__PURE__ */ new Set(),
221
235
  error: /* @__PURE__ */ new Set(),
@@ -231,14 +245,7 @@ function createRuntime(def, impl, opts = {}) {
231
245
  }
232
246
  function runMiddleware(prev, event, effects, changed) {
233
247
  if (!middlewareChain) return;
234
- const mwCtx = deepFreeze({
235
- prev,
236
- next: snapshot,
237
- event,
238
- effects,
239
- changed
240
- });
241
- middlewareChain(mwCtx, () => {
248
+ middlewareChain(deepFreeze({ prev, next: snapshot, event, effects, changed }), () => {
242
249
  });
243
250
  }
244
251
  function dispatchEffects(effects, context, event) {
@@ -249,52 +256,128 @@ function createRuntime(def, impl, opts = {}) {
249
256
  const r = handler(eff, { context, event, signal: controller.signal });
250
257
  if (r instanceof Promise) {
251
258
  r.catch((err) => {
252
- const payload = { error: err, event };
253
- emit("error", payload);
259
+ emit("error", { error: err, event });
254
260
  });
255
261
  }
256
262
  }
257
263
  }
264
+ function wireChildAbort(child) {
265
+ if (controller.signal.aborted) {
266
+ try {
267
+ child.dispose();
268
+ } catch {
269
+ }
270
+ return () => {
271
+ };
272
+ }
273
+ const onAbort = () => {
274
+ try {
275
+ child.dispose();
276
+ } catch {
277
+ }
278
+ };
279
+ controller.signal.addEventListener("abort", onAbort, { once: true });
280
+ return () => controller.signal.removeEventListener("abort", onAbort);
281
+ }
282
+ function findChosenIsExternal(value, event, context) {
283
+ const state = def.states[value];
284
+ if (!state?.on) return false;
285
+ const candidates = state.on[event.type];
286
+ if (!candidates) return false;
287
+ const list = Array.isArray(candidates) ? candidates : [candidates];
288
+ for (const t of list) {
289
+ if (!t.guard || evalGuard(t.guard, context, event, impl, value)) {
290
+ return t.target !== void 0;
291
+ }
292
+ }
293
+ return false;
294
+ }
295
+ function applySubLifecycle(prevValue, nextValue) {
296
+ const prevStateDef = def.states[prevValue];
297
+ const nextStateDef = def.states[nextValue];
298
+ if (prevStateDef?.sub !== void 0 && childRuntime !== void 0) {
299
+ const child = childRuntime;
300
+ childRuntime = void 0;
301
+ childAbortCleanup?.();
302
+ childAbortCleanup = void 0;
303
+ try {
304
+ child.dispose();
305
+ } catch (cause) {
306
+ throw new SubMachineError(prevValue, "dispose", cause);
307
+ }
308
+ }
309
+ if (nextStateDef?.sub !== void 0) {
310
+ let newChild;
311
+ try {
312
+ newChild = createRuntime(nextStateDef.sub, nextStateDef.subImpl ?? {});
313
+ } catch (cause) {
314
+ throw new SubMachineError(nextValue, "init", cause);
315
+ }
316
+ childRuntime = newChild;
317
+ childAbortCleanup = wireChildAbort(newChild);
318
+ }
319
+ }
258
320
  function send(event) {
259
321
  if (disposed) throw new RuntimeDisposedError();
260
322
  const prev = snapshot;
261
323
  const result = step(def, prev, event, impl);
324
+ const isExternal = result.changed && (prev.value !== result.snapshot.value || findChosenIsExternal(prev.value, event, prev.context));
325
+ if (result.changed && isExternal) applySubLifecycle(prev.value, result.snapshot.value);
262
326
  snapshot = result.snapshot;
263
327
  const committed = result.snapshot;
264
328
  runMiddleware(prev, event, result.effects, result.changed);
265
- if (shouldDispatch) {
266
- dispatchEffects(result.effects, committed.context, event);
267
- }
329
+ if (shouldDispatch) dispatchEffects(result.effects, committed.context, event);
268
330
  if (result.changed) {
269
331
  notify(committed);
270
- const payload = {
332
+ emit("transition", {
271
333
  prev,
272
334
  next: committed,
273
335
  event,
274
336
  effects: result.effects,
275
337
  changed: true
276
- };
277
- emit("transition", payload);
338
+ });
278
339
  }
279
340
  return snapshot;
280
341
  }
281
342
  function reset(event) {
282
343
  if (disposed) throw new RuntimeDisposedError();
283
344
  const prev = snapshot;
284
- snapshot = initialSnapshot(def);
285
- const changed = prev.value !== snapshot.value;
345
+ const nextSnap = initialSnapshot(def);
346
+ const changed = prev.value !== nextSnap.value;
347
+ if (childRuntime) {
348
+ const child = childRuntime;
349
+ childRuntime = void 0;
350
+ childAbortCleanup?.();
351
+ childAbortCleanup = void 0;
352
+ try {
353
+ child.dispose();
354
+ } catch (cause) {
355
+ throw new SubMachineError(prev.value, "dispose", cause);
356
+ }
357
+ }
358
+ const initStateDef = def.states[nextSnap.value];
359
+ if (initStateDef?.sub) {
360
+ let newChild;
361
+ try {
362
+ newChild = createRuntime(initStateDef.sub, initStateDef.subImpl ?? {});
363
+ } catch (cause) {
364
+ throw new SubMachineError(nextSnap.value, "init", cause);
365
+ }
366
+ childRuntime = newChild;
367
+ childAbortCleanup = wireChildAbort(newChild);
368
+ }
369
+ snapshot = nextSnap;
286
370
  const triggerEvent = event ?? RESET_EVENT;
287
371
  runMiddleware(prev, triggerEvent, [], changed);
288
372
  if (changed) {
289
373
  notify();
290
- const payload = {
374
+ emit("transition", {
291
375
  prev,
292
376
  next: snapshot,
293
377
  event: triggerEvent,
294
378
  effects: [],
295
379
  changed: true
296
- };
297
- emit("transition", payload);
380
+ });
298
381
  }
299
382
  return snapshot;
300
383
  }
@@ -345,6 +428,15 @@ function createRuntime(def, impl, opts = {}) {
345
428
  function dispose() {
346
429
  if (disposed) return;
347
430
  disposed = true;
431
+ if (childRuntime) {
432
+ childAbortCleanup?.();
433
+ childAbortCleanup = void 0;
434
+ try {
435
+ childRuntime.dispose();
436
+ } catch {
437
+ }
438
+ childRuntime = void 0;
439
+ }
348
440
  controller.abort();
349
441
  listeners.clear();
350
442
  emit("dispose", void 0);
@@ -352,7 +444,7 @@ function createRuntime(def, impl, opts = {}) {
352
444
  for (const cleanup of externalAbortCleanups) cleanup();
353
445
  externalAbortCleanups.clear();
354
446
  }
355
- return {
447
+ const runtime = {
356
448
  getSnapshot: () => snapshot,
357
449
  snapshot: () => snapshot,
358
450
  send,
@@ -371,8 +463,22 @@ function createRuntime(def, impl, opts = {}) {
371
463
  };
372
464
  listeners.add(listener);
373
465
  return () => listeners.delete(listener);
374
- }
466
+ },
467
+ subRuntime: () => childRuntime,
468
+ onTransition: (handler, options) => on("transition", handler, options)
375
469
  };
470
+ const bootStateDef = def.states[snapshot.value];
471
+ if (bootStateDef?.sub) {
472
+ let newChild;
473
+ try {
474
+ newChild = createRuntime(bootStateDef.sub, bootStateDef.subImpl ?? {});
475
+ } catch (cause) {
476
+ throw new SubMachineError(snapshot.value, "init", cause);
477
+ }
478
+ childRuntime = newChild;
479
+ childAbortCleanup = wireChildAbort(newChild);
480
+ }
481
+ return runtime;
376
482
  }
377
483
 
378
484
  // src/fsm/definition.ts
@@ -399,6 +505,15 @@ function validateDefinition(def) {
399
505
  );
400
506
  }
401
507
  for (const [stateName, stateDef] of Object.entries(def.states)) {
508
+ if (stateDef.sub !== void 0) {
509
+ const sub = stateDef.sub;
510
+ const subStates = sub.states;
511
+ if (typeof sub !== "object" || sub === null || typeof subStates !== "object" || subStates === null || typeof sub.initial !== "string") {
512
+ throw new InvalidDefinitionError(
513
+ `state "${stateName}".sub is not a valid sub-machine definition (missing states or initial)`
514
+ );
515
+ }
516
+ }
402
517
  if (!stateDef.on) continue;
403
518
  for (const [evtType, entry] of Object.entries(stateDef.on)) {
404
519
  const transitions = Array.isArray(entry) ? entry : [entry];
@@ -455,6 +570,7 @@ exports.AsyncGuardError = AsyncGuardError;
455
570
  exports.InvalidDefinitionError = InvalidDefinitionError;
456
571
  exports.RESET_EVENT_TYPE = RESET_EVENT_TYPE;
457
572
  exports.RuntimeDisposedError = RuntimeDisposedError;
573
+ exports.SubMachineError = SubMachineError;
458
574
  exports.UnknownActionError = UnknownActionError;
459
575
  exports.UnknownGuardError = UnknownGuardError;
460
576
  exports.assign = assign;