# @macrulez/vue-state-machine — AI Reference Reactive finite state machines for Vue 3: `defineMachine()` + `useMachine()` for a single instance, `useSharedMachine()` for an id-keyed singleton across the app, `useWizard()` built on top of a generated machine for step-flow UIs, `localStorage` persistence, and a minimal Vue DevTools integration. Flat FSM per level, with one specific compound feature: **parallel regions** (each an independently-running nested machine, one level deep only — a region cannot itself declare further regions). No hierarchical/nested (non-parallel) states, no history-pseudostates. Zero dependencies beyond Vue. Version 0.2.5. This document is hand-written for AI agents and other tools that generate code against this package: every signature, default, and behavior note below is verified directly against the TypeScript source (not summarized from prose docs). For human-readable narrative docs, see the interactive site instead: - Full docs (EN): https://npm.vuecraft.ru/en/packages/vue-state-machine/guide/overview - Full docs (RU): https://npm.vuecraft.ru/packages/vue-state-machine/guide/overview - GitHub: https://github.com/macrulezru/vue-state-machine - npm: https://www.npmjs.com/package/@macrulez/vue-state-machine Links below starting with "/" are relative to https://npm.vuecraft.ru. --- ## 1. Package map | Import path | Contains | |---|---| | `@macrulez/vue-state-machine` | `defineMachine`, `useMachine`, `useSharedMachine`, `useWizard`, `useMachineStore`, `VueMachinePlugin`, every shared type. | | `@macrulez/vue-state-machine/devtools` | `VueMachineDevtools` — a minimal Vue DevTools browser-extension integration (section 9). Separate entry so it costs nothing when unused. | `peerDependencies`: only `vue: ^3.3.0`. No Nuxt module, no React support, no CLI — a plain Vue-3-only composable library, despite the "statechart" wording in the description (see section 2 for what that actually means here). **As of 0.2.5** (this document describes the current, fixed source): two real build bugs were fixed — the published `dist/index.d.ts` used to ship as an empty `export {}` (zero TypeScript types for any consumer), and every dev-time diagnostic (`defineMachine`'s validation, the parallel-region conflict warning, `useWizard`'s `canProceed`-threw log) was silently compiled out of the production bundle and could never fire for any consumer, in any environment. Both are fixed as of this version — see section 10 for the mechanism, worth knowing since the same `import.meta.env.DEV` pitfall is easy to reintroduce in a hand-rolled dev-check inside a pre-built library. (0.2.4 fixed this for `defineMachine`'s validation and the parallel-region warning, but one of `useWizard`'s two `canProceed`-threw call sites — the one inside `goTo()` — was missed in that pass and still shipped dead; 0.2.5 is the first version where all three diagnostics genuinely fire.) --- ## 2. Architecture — what kind of state machine this actually is - **Not a full statechart engine** in the XState sense. It's a flat FSM per level, with exactly one compound feature: `StateConfig.parallel` — a state may declare one or more named **regions**, each an independently-running, recursively-typed `MachineRunner` of its own (`SubMachineConfig`). **Regions cannot nest further regions** — this is enforced at the type level (`SubMachineConfig.states` uses `Omit, 'parallel'>`). - **No hierarchical (non-parallel) child states** — there's no `StateConfig` field for "this state contains a nested sub-machine" other than `parallel`. **No history-state support** at all (no `type: 'history'`, no SCXML-style history pseudostates). - **Context** (`Ctx = Record`) is a single flat object per machine instance, reactive via a `shallowRef` at the `useMachine` layer — reactivity triggers only on whole-value reassignment, never on mutating a field of `context.value` directly (the type is `Readonly>`, consistent with treating it as immutable from outside). It's **deep-cloned** (`structuredClone`, with a shallow `{...}`/`[...]` fallback for values that can't be structured-cloned, e.g. functions/class instances) on construction and on `restore()`. - **Guards** (`(context, event) => boolean`) see the runner's **live, current** context at the moment of the transition attempt — not a stale snapshot — both for top-level transitions and independently inside each region's own runner. A thrown guard is caught and treated as `false`. - **Actions** (`(context, event) => void | Partial | Promise<...>`) may be sync or async; a truthy return is merged via a **shallow** `{...context, ...partial}` at every step (exit → transition-level actions → entry, in that order). The union of every partial merged during one transition is tracked separately as `TransitionResult.contextPatch` (the delta only, distinct from `nextContext`, the full resulting context) — this delta-tracking is exactly what makes parallel-region merging non-destructive (§2.1). - **Event queue**: every `send()`/`enqueue()` call is pushed onto a private array and drained serially by a self-recursive async loop — an in-order guarantee even when `send()` is called multiple times without awaiting in between. - **Final states**: `states[s].type === 'final'` makes `transition()` return `changed: false` immediately, without evaluating anything — including any `on` handler that happens to still be declared on it; a final state is a hard stop. ### 2.1 Parallel regions — activation, dispatch, and merge semantics - `activateParallelRegions(state)` runs in the constructor (for the initial state) and again at the end of **any top-level transition that matched the containing state's own `on` map** — it fully discards prior region runners and builds fresh ones from the region's `SubMachineConfig`. A region's initial context is a **shallow** copy of the parent's context at that moment (deep-cloning happens one level down, inside the region's own `MachineRunner` constructor — so it's clone-once-total, not double-cloned). - **Non-obvious**: this reset happens on *any* top-level transition matching the state's own `on` handler — including a self-transition, or a state that has both `parallel` regions and its own `on` entries. Hitting an event wired on the state itself resets every region back to its `initial` sub-state, discarding in-progress region state. Hitting an event that's only wired inside a region's own `on` map (see next point) does **not** reset anything. - `dispatchToRegions(event)` is the path taken when the *containing* state has **no** top-level `on` handler for the event — it fans the event out to every active region's own `transition()`, and merges in only `result.contextPatch` (the delta), never the region's whole context, which is the fix for the historical "sibling region clobbering" class of bug. If two regions' patches touch the same key in one dispatch, `isDevMode()` gates a `console.warn` naming the conflicting keys, and whichever region is processed **last** (object declaration order in `parallel`) wins. - `changed` for a regions-only dispatch is correctly derived from whether **any** region actually transitioned — so a region-only change does reach `useMachine`'s reactive `state`/`context` refs via `send()`. - **Gotcha**: because `useMachine`'s `pushHistory()` is keyed on `result.changed`, not on whether the *top-level* state moved, a region-only transition still pushes a `TransitionRecord` where `from === to` — code consuming `history` and assuming every record represents a real top-state change will misread these entries. - **Gotcha**: `canTransition()`/`can(event)` only ever looks at the *current state's own* `on` map — it never considers events that only a region's `on` map would handle. A state with `parallel` regions whose event is wired only inside a region returns `can(event) === false` even though `send(event)` would actually cause that region to transition. This inconsistency is untested in the package's own test suite. --- ## 3. Core types (`Ctx`, `EventObject`, `Guard`, `Action`, `MachineConfig`, …) ```ts type Ctx = Record interface EventObject { type: TEvent [key: string]: unknown // arbitrary payload fields — e.g. send({ type: 'FAILURE', message: 'x' }) } type Guard = (context: TContext, event: EventObject) => boolean type Action = (context: TContext, event: EventObject) => void | Partial | Promise | void> interface TransitionConfig { target: TState guard?: Guard actions?: Action[] } interface SubMachineConfig { initial: TState states: Record, 'parallel'>> // regions can't nest regions } interface StateConfig { on?: Partial>> entry?: Action[] exit?: Action[] type?: 'final' parallel?: Record> } interface MachineConfig { id: string initial: NoInfer // TState must be inferable from `states`' keys, not from `initial` itself context?: TContext // omitted → runtime context defaults to {} (a real empty object, never undefined) states: Record> } interface TransitionRecord { from: TState; to: TState; event: EventObject; timestamp: number } interface MachineSnapshot { state: TState; context: TContext; history: TransitionRecord[] } interface TransitionResult { nextState: TState; nextContext: TContext; executed: string[]; changed: boolean contextPatch: Partial // the delta only — see §2/§2.1 } interface UseMachineOptions { historyLimit?: number // default 50 — the default lives in useMachine(), NOT documented on this type itself persist?: { key: string; storage?: Storage } // storage defaults to the global `localStorage` } interface MachineInstance { state: Readonly> context: Readonly> send(event: TEvent | EventObject): Promise matches(state: TState | TState[] | Partial>): boolean can(event: TEvent): boolean history: Readonly[]>> isDone: ComputedRef snapshot: ComputedRef> restore(snapshot: MachineSnapshot): void } ``` --- ## 4. `defineMachine(config)` ```ts function defineMachine( config: MachineConfig ): MachineConfig ``` Identity function — returns `config` unmodified, no cloning, no transformation. Its only real effect is a **dev-only** validation pass (`isDevMode()`-gated, see section 10): checks `config.id` is non-empty, `config.initial` exists in `config.states`, and every top-level `states[s].on[event].target` exists in `config.states` — throwing `Error` with a `[vue-state-machine]`-prefixed message on the first violation found. **Gotcha**: validation **never recurses into `stateConfig.parallel`** — a typo'd `target` inside a region's own `on` map (or a bad region `initial`) is completely unvalidated, even in dev mode; it only ever surfaces at runtime as a silent no-op transition, never a thrown error. --- ## 5. `useMachine(config, options?)` ```ts function useMachine( config: MachineConfig, options?: UseMachineOptions, ): MachineInstance ``` - Constructs one `MachineRunner` (internal, not exported) per call — the actual FSM engine everything else is built on. - **`send(event)`**: normalizes a bare string to `{ type: event }`, `enqueue()`s it on the runner, and **only if `result.changed`**: pushes a `TransitionRecord`, re-syncs `state`/`context` off the runner, and persists (if `persist` configured). If a guard blocks the transition or the event is unrecognized (no top-level `on` entry, no region handles it either), `send()` resolves with **zero observable side effects** — no error, no reactivity change — silent no-op is the contract. - **`matches(query)`**: `string` → exact equality. `array` → `some()` (OR). `object` (`Partial>`) → checked against `runner.getRegionStates()` (called fresh every invocation, not cached), requiring **every** key in the query to match (`every()` — AND, vacuously `true` for `{}`) — this is how a parallel-region assertion like `matches({ saving: 'idle' })` works. - **`can(event)`** is a direct passthrough to the runner's `canTransition()` — inherits the "doesn't see region-only events" gotcha from section 2.1. - **`restore(snapshot)`** re-seeds `state`/`context`/`history` from the given snapshot verbatim (no re-clone at this layer — but the runner's own `restore()` deep-clones the context internally). No entry/exit actions run. - **`historyLimit`** (default `50`, a literal default parameter — not documented on `UseMachineOptions`'s own JSDoc): keeps the **most recent** N records (`slice(next.length - historyLimit)`). - **`persist: { key, storage? }`**: `storage` defaults to the global `localStorage` (not injected/mockable without passing a fake explicitly). `persistSnapshot()`/`loadPersistedSnapshot()` both no-op on `typeof window === 'undefined'` — genuinely SSR-safe. Writing is `try { storage.setItem(...) } catch {}` — a full quota or a non-serializable context value is swallowed with **zero diagnostics**, even in dev. Loading (in `onMounted()`) only restores if `snap.state && snap.state in config.states` — a persisted state from a config that's since changed (renamed/removed state) is silently discarded, leaving the machine at its normal `initial` state, no error. **The persisted `context`'s shape is never validated** — a `JSON.parse()`'d blob is handed straight to `restore()` even if the live config's context shape has since diverged from what was saved. - **Store registration**: `inject(MACHINE_STORE_KEY, null)` — nullable with a default, so `useMachine()` works standalone without `VueMachinePlugin` installed (unlike `useMachineStore()`, which throws). If a store *is* present, `store.register(config.id, instance)` runs unconditionally on every call. **Gotcha**: there is **no** `onUnmounted`/automatic unregistration anywhere in the package — a machine instance is never removed from the store/DevTools registry when its owning component unmounts. For an app that mounts/unmounts many `useMachine()` instances with distinct ids under a shared `VueMachinePlugin`, this registry only grows; call `useMachineStore().unregister(id)` yourself if that matters. --- ## 6. `useSharedMachine(config, options?)` ```ts function useSharedMachine( config: MachineConfig, options?: UseMachineOptions, ): MachineInstance ``` Requires `VueMachinePlugin` (calls `useMachineStore()`, which throws if absent — unlike plain `useMachine`). `store.get(config.id)` — if already registered under that id, returns the existing instance as-is (a type cast through `unknown`, **no runtime check** that it actually matches `TState`/`TEvent`/`TContext`); otherwise calls `useMachine(config, options)`, which registers it as a side effect. **Gotcha**: `options` (including `persist`) are applied **only on the very first** `useSharedMachine(config, options)` call for a given id in the app's lifetime — every subsequent call anywhere else in the tree gets whatever the first caller configured, regardless of what it itself passes. Not reflected in the type signature, which accepts `options?` unconditionally as if it always applied. --- ## 7. `useWizard(steps, options?)` ```ts interface WizardStep { id: string label?: string component?: Component canProceed?: (context: TContext) => boolean | Promise onEnter?: (context: TContext) => void | Partial // return value merges into context, same as a machine Action onLeave?: (context: TContext) => void | Partial // usual place to persist a step's data before the next step's canProceed reads it } interface WizardOptions { id?: string // default: auto-generated `__wizard_${n}__`, unique per call via a module-level counter initialStep?: number // default 0 allowSkip?: boolean // default false circular?: boolean // default false — true wraps NEXT from the last step back to the first } interface WizardInstance { currentStep: Ref> currentIndex: ComputedRef totalSteps: number progress: ComputedRef // currentIndex / max(steps.length - 1, 1) — 0 for a single-step wizard, never NaN isFirst: ComputedRef isLast: ComputedRef history: Ref // unique visited-step ids in first-visit order — NOT a full navigation trail, see below context: Readonly> next(): Promise prev(): void goTo(id: string): Promise reset(): void } function useWizard(steps: WizardStep[], options?: WizardOptions): WizardInstance ``` Throws synchronously if `steps.length === 0` (`useWizard: steps array cannot be empty`). ### 7.1 How it's built on top of `defineMachine`/`useMachine` Every step becomes a state. `NEXT` targets the next step (wraps to the first step if `circular`; absent on the last step otherwise). `PREV` targets the previous step (absent on the first step). **Every step also gets a `GOTO_` transition to every other step** — an all-to-all jump graph — which is how `goTo()`/`reset()` work (`reset()` sends `GOTO_`). `onEnter`/`onLeave` become single-action `entry`/`exit` arrays on the generated state config. ### 7.2 `canProceed` — live context, forward-only gating `next()`/`goTo()` read `context.value` from the **live** `MachineInstance` returned by the internal `useMachine(machine)` call — the real, current context, not the static config (this was a real, now-fixed historical bug; verified by a regression test chaining an `onLeave` write with a `canProceed` read of that same field on the very next step). `canProceed` is checked **entirely outside** the state machine — it's not wired in as a `Guard` on the generated transitions; `next()`/`goTo()` call it manually and only `send()` if it resolves truthy. A thrown `canProceed` is caught, treated as `false`, and (as of 0.2.5 — see section 10's note on the 0.2.4/0.2.5 distinction) logged via `console.error` in dev mode. **`goTo(id)` only checks `canProceed` when moving strictly forward** (`targetIndex > currentIndex.value`) **and `allowSkip` is falsy**. Moving backward via `goTo()`, or moving forward at all with `allowSkip: true`, never calls `canProceed`. **`prev()` never calls `canProceed` either.** `canProceed` is a forward-only, skip-respecting gate — going backward is always unconditionally allowed, which isn't spelled out anywhere in the types/README. ### 7.3 `history` `historyRef` (the wizard's own `history: Ref` — distinct from the underlying machine's `TransitionRecord[]` history, which is unused here) only appends the target id `if moved && !historyRef.value.includes(state.value)` — re-visiting an already-visited step (e.g. `PREV` then `NEXT` again) produces **no** duplicate entry and doesn't reorder it. `history` is a set of unique visited ids in first-visit order, not a full navigation trail — don't use it to reconstruct exact navigation sequence/count. ### 7.4 Wizard/machine id collisions The **default** (no explicit `id`) case is collision-free — a module-level counter (`wizardCounter`) guarantees a distinct auto-id (`__wizard_1__`, `__wizard_2__`, …) per `useWizard()` call. **An explicitly-passed `WizardOptions.id` can still collide silently** with another `useWizard()`/`useMachine()`/`defineMachine` using the same literal id — see section 8, `MachineStore.register()` has zero collision detection of any kind. --- ## 8. `MachineStore` / `useMachineStore()` / `VueMachinePlugin` ```ts interface MachineStoreAPI { register(id: string, instance: MachineInstance): void unregister(id: string): void get(id: string): MachineInstance | undefined getAll(): Map> // the LIVE map, not a copy } function useMachineStore(): MachineStoreAPI // throws if VueMachinePlugin isn't installed const VueMachinePlugin: { install(app: App): void } // app.provide()s one MachineStoreAPI, app-wide, no options ``` A trivial `Map`-backed registry, one per `app.use(VueMachinePlugin)`. **`register()` is a plain `Map.set()` — a duplicate `id` silently overwrites the prior entry, in dev or prod, with no warning of any kind**, regardless of where the id came from (`defineMachine.id`, a `useSharedMachine` call, an explicit `WizardOptions.id`). `getAll()` returns the actual live `Map`, not a defensive copy — mutating what it returns mutates the real store. Combined with `useMachine`'s lack of auto-unregistration (section 5), this registry only ever grows across an app's lifetime unless something explicitly calls `unregister()`. --- ## 9. `VueMachineDevtools` (`/devtools`) ```ts const VueMachineDevtools: { install(app: App): void } ``` A **minimal** Vue DevTools browser-extension integration — no-ops on `typeof window === 'undefined'` (SSR-safe). Must be installed **after** `VueMachinePlugin` (`console.warn`s and returns if the store isn't found on `app._context.provides`). If `window.__VUE_DEVTOOLS_GLOBAL_HOOK__` isn't present (no extension installed), it silently returns. What it actually does, in full: on the hook's `'app:init'` event (only for the matching `app` instance), registers a settings-panel label (`plugin:settings:set`) — nothing configurable. On `'visitComponentTree'` (fired by the extension on its own refresh cadence — a poll, **not** a push on every `send()`), it iterates `store.getAll()` and emits one `timeline:event` per registered machine, each `{ machineId, state, context }` with a title `[id] state`. **That's the entire feature set — no transition history in the emitted event, no way to send an event from the DevTools panel back into a machine, no per-transition push.** (An older draft of this package's README once described "history" and an "event sender" — those were never actually implemented; the current README's DevTools description matches this code exactly.) --- ## 10. `isDevMode()` — why `process.env.NODE_ENV`, not `import.meta.env.DEV` Every dev-only diagnostic in this package (defineMachine's validation, the parallel-region conflict warning, useWizard's `canProceed`-threw log) is gated by a small internal `isDevMode()` helper: ```ts function isDevMode(): boolean { return typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production' } ``` This deliberately reads `process.env.NODE_ENV`, **not** `import.meta.env.DEV`. This package ships as a pre-built `dist/`, and its own `vite build` always runs in production mode — `import.meta.env.DEV` would be statically replaced with the literal `false` at **this package's own build time** and dead-code-eliminated from the shipped bundle **permanently**, regardless of what mode a *consuming* app actually runs in (this was a real, shipped bug through 0.2.3 — every dev-only check silently compiled away, `defineMachine()` never validated anything for anyone, ever). `process.env.NODE_ENV` survives unreplaced into the shipped `dist/index.mjs`/`.cjs` and gets substituted correctly by the **consuming app's own bundler** at *its* build time instead — the same pattern used elsewhere in this package family (e.g. `vue-image-kit`'s `isDevMode()`). **0.2.4 vs 0.2.5**: the `isDevMode()` fix itself landed in 0.2.4, but the find-and-replace that applied it missed one of `useWizard`'s two identical `canProceed`-threw catch blocks — `next()`'s got fixed, `goTo()`'s (nested one indent level deeper, inside its own `allowSkip` guard) didn't, and silently kept the old, permanently-`false` `import.meta.env.DEV` check through 0.2.4. 0.2.5 is the first version where `defineMachine`'s validation, the parallel-region warning, *and* both of `useWizard`'s `canProceed`-threw logs all genuinely fire. --- ## 11. Consolidated gotcha list 1. `defineMachine()`'s validation never recurses into `stateConfig.parallel` — a typo'd target/`initial` inside a region is unvalidated even in dev mode; it only surfaces as a silent runtime no-op (section 4). 2. `can(event)`/`canTransition(event)` only look at the current state's own `on` map — never at what a parallel region's `on` map would handle. A state with regions can have `can(event) === false` while `send(event)` would still cause a region to transition (2.1). 3. Any top-level transition matching a state's own `on` handler resets **all** of that state's parallel regions back to their `initial` sub-states — including a self-transition. An event wired only inside a region's own `on` map does not reset anything. Two visually similar events can have very different region-reset semantics depending on where they're declared (2.1). 4. A region-only transition (`dispatchToRegions`) still pushes a `TransitionRecord` where `from === to` on the top-level `MachineInstance.history` — code assuming every history record represents an actual top-state change will misread these (2.1). 5. On a parallel-region context-key conflict, whichever region is processed **last** (declaration order in the `parallel` object) wins — silently, apart from a dev-only warning (2.1). 6. `useMachine()` never auto-unregisters a machine from the `MachineStore`/DevTools registry when its owning component unmounts — there's no `onUnmounted` cleanup anywhere in the package. Call `useMachineStore().unregister(id)` yourself if you mount/unmount many distinct-id machines (section 5, 8). 7. `MachineStore.register()` has **zero** collision detection — a duplicate id (from `defineMachine`, `useSharedMachine`, or an explicit `WizardOptions.id`) silently overwrites the prior entry, no warning, in any mode (section 8). 8. `useSharedMachine(config, options)`'s `options` only apply on the very **first** call for a given id anywhere in the app — every later call's `options` (including `persist`) are silently ignored (section 6). 9. `useWizard`'s `canProceed` is a **forward-only, skip-respecting** gate: never checked on `prev()`, never checked on `goTo()` moving backward, never checked at all when `allowSkip: true` (7.2). 10. `useWizard`'s `history` is a set of unique visited-step ids in first-visit order — revisiting a step doesn't add a duplicate or reorder it; it is not a full navigation trail (7.3). 11. `persist`'s write path (`storage.setItem`) swallows a full-quota or serialization failure with **zero diagnostics**, even in dev; its read path silently discards a persisted state that no longer exists in the current config, and never validates the persisted context's shape against the live config at all (section 5). 12. `VueMachineDevtools` only refreshes on the DevTools extension's own `'visitComponentTree'` poll, not on every `send()` — and offers no transition history or event-sending from the panel, contrary to an older (now-corrected) README draft (section 9). 13. All dev-only diagnostics are gated by `process.env.NODE_ENV`, not `import.meta.env.DEV` — this is deliberate (section 10). Fully fixed only as of **0.2.5**; ≤0.2.3 shipped every one of these checks (including `defineMachine`'s config validation) permanently dead-code-eliminated with an empty published `.d.ts`, and 0.2.4 fixed all of it except one of `useWizard`'s two `canProceed`-threw catch blocks (section 10).