Skip to content

XState v6 - #5543

Open
davidkpiano wants to merge 465 commits into
mainfrom
next
Open

XState v6#5543
davidkpiano wants to merge 465 commits into
mainfrom
next

Conversation

@davidkpiano

@davidkpiano davidkpiano commented Jun 15, 2026

Copy link
Copy Markdown
Member

Moved from #5257


Open in Devin Review

davidkpiano and others added 29 commits April 27, 2026 06:21
@vue/test-utils was resolving to vue@3.5.33 while test code used
vue@3.5.17, causing two separate Vue reactivity systems and preventing
DOM updates in Vue store tests.
Audit-driven improvements for v6. All non-breaking except the DoneActorEvent
type tightening (acceptable for a major).

Engine:
- Fix invoked-flag clone bug in enterStates: the flag was set once and never
  reset, so cloneMachineSnapshot re-ran for every node entered after the first
  invoke even when children were unchanged. Now scoped per node.
- getAllStateNodes: early-terminate the ancestor walk when a node is already in
  the set, so shared ancestor chains aren't re-walked once per sibling
  (O(n*depth) -> ~O(n+depth)). Safe because Set iteration visits nodes added
  mid-loop, so every chain is still fully covered.

Actors:
- Clear _deferred in the init-failure catch so functions deferred during a
  throwing getInitialSnapshot/restoreSnapshot can't run against an error actor.

Atoms (core):
- createAsyncAtom: default to a value-based compare (status + Object.is on
  data/error) so pending->pending and identical results no longer emit
  spurious notifications.
- Atom.subscribe: route observer throws to reportUnhandledError (try/catch)
  instead of letting them break reactive propagation, matching Actor.subscribe.

Types:
- InvokeConfig.onDone: DoneActorEvent<any> -> DoneActorEvent<unknown>, aligning
  with the type's own defaults and stopping unchecked event.output flow.

Cleanup:
- Export isAtom from @xstate/store and remove the 4 byte-identical copies in
  the react/preact/vue/solid bindings (-32 LOC, single source of truth).
- Remove dead optionsRef (+ orphaned useRef import) from useIdleActorRef.

scxml.ts: documented as @internal (test-only, not public API) per maintainer.
The `Implementations['actions']` constraint was `(...args: any[]) => void`,
which let any function be registered as a named action with zero compile-time
feedback — e.g. `foo: (n: number) => 'hello'` type-checked despite returning a
value an action can never return, then silently misbehaved at runtime.

Tighten the action return type to `void | { context?, children? }` so the
definition site rejects functions that don't return a valid action result.
Add a focused negative test at the createMachine site.
The enqueue object used to evaluate `to:` transition functions diverged from
the one used in microstep:

- raise() skipped the string-event validation, so enq.raise('SOME_EVENT') in a
  transition function silently pushed a bare string as an internal event
  instead of throwing the helpful "use raise({ type: ... })" error that entry/
  exit actions already produce.
- sendTo() pushed an @xstate.sendTo action even when the target actorRef was
  undefined, unlike microstep which no-ops. Now guarded to match.

Add transition() tests covering both.

Note: the delayed-raise path still differs intentionally (getTransitionResult
schedules inline vs microstep's @xstate.raise builtin); unifying that would be
a behavior change and is left out of this fix.
The react/preact/solid store bindings typed `compare` (and `defaultCompare`)
as `(a: T | undefined, b: T)`, but every call site guards `previous !==
undefined` before invoking it, so the `undefined` was never actually passed —
a dishonest signature that produced spurious TS errors when sharing a compare
function with @xstate/react's actor `useSelector`, which uses `(a: T, b: T)`.

Standardize on `(a: T, b: T)`, matching the actor binding and the already-
correct vue/svelte/angular store bindings. Existing `(a: T | undefined, b: T)`
user compares remain assignable.
…ization

- Shrink the minimal createMachine+createActor bundle 16.8 → 14.8 kB min+gz
  (−12%): decouple atom/alien reactivity from createActor via a lazily
  installed interop hook, dedupe listener/subscription logic through a shared
  attached-actor factory, slim machineConfigToJSON, and gate long diagnostic
  messages to development builds. Add scripts/bundle-size.mjs with CI
  thresholds and a --why per-module attribution flag.
- Typecheck invoke `input` (static values and mappers) against the registered
  actor logic's input type by distributing the invoke config over the
  `actors` map; `src: ({ actors }) => actors.x` resolvers are correlated too.
- Choice states: rename `choices` → `choice`, function-only (the
  array-of-guarded-targets form is removed). A choice state declares one
  function that must resolve to a target.
- Machines as data: spawned actors register on snapshot.children and persist
  like invoked actors; invoked actors keep their logical `src` in persisted
  snapshots; rehydrated children restart on start(); machine.toJSON() /
  .definition emit JSON-safe definitions with $unserializable markers that
  round-trip through createMachineFromConfig.
- Add the first-ten-minutes DX benchmark suite and a type-error-quality
  harness (scripts/type-error-quality.mjs + fixtures).
Clears all pre-existing lint errors so the CI lint step passes:
- core/src: remove redundant type assertions, @ts-ignore@ts-expect-error,
  prefix unused generics/args, guard base-to-string in error fallbacks,
  brace case-block lexical decls, drop now-unused ActorSrcKey
- xstate-react: drop redundant createActor(...) as Actor<TLogic> casts
- xstate-svelte test fixture: async-without-await
- xstate-store-react/src/test.tsx (stray SWAPI demo): suppress misused-promises

No runtime/behavior changes.
- Add types<T>() for type-only schemas (inference, no runtime validation) —
  the v6 replacement for v5's types: {} as {...}. Exported with isTypeSchema.
- Add @xstate/codemod + a zero-dep `xstate migrate` launcher in core that
  delegates via npx (so `npm i xstate` stays dependency-free). First release
  automates Tier A renames (interpret/Interpreter, from*->create*Logic),
  flags manual sites; fromPromise left for manual (shape change).
- Exclude packages/codemod from preconstruct (it's a CLI, not a bundled lib).
…protocol

Atoms as machine input:
- enq.subscribeTo() now accepts an atom directly (createAtom /
  createReducerAtom / createAsyncAtom); the mapper receives the atom's value.
  Adds the `isAtom` brand + predicate (exported).
- Attached subscription/listener actors tear down on parent stop and via
  enq.stop(); fix special actions returning only `{ children }` (e.g.
  registerSpawnedChild/unregisterChild) wiping context to undefined.

Inspection:
- Lossless inspection protocol (createActor/inspection/system) with
  conformance tests and changeset.

Docs:
- migration.md updated for atoms/reactive input, actor.select/get, route
  states, the actor registry, snapshot versioning, and serialization.
@changeset-bot

changeset-bot Bot commented Jun 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1e099f7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
xstate Major
@xstate/react Major
@xstate/store Patch
@xstate/codemod Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

davidkpiano and others added 4 commits August 17, 2026 10:26
* Type persisted snapshot round-trips

actor.getPersistedSnapshot() now returns a snapshot branded to its
source logic, and ActorOptions['snapshot'] accepts persisted snapshots
from any version of the same machine ID (cross-version restore stays
assignable for runtime migration via migrate). Restoring into a machine
with a different ID is now a compile-time error, mirroring the existing
runtime guard.

* Brand persisted snapshots by machine ID even without a version

Addresses Devin review: the persisted-snapshot brand collapsed to never
for unversioned machines, so cross-ID restore was silently accepted.
The brand identity now requires only a machine id (version falls back
to string), and provide() returns 'this' so the MachineIdentity
intersection from createMachine survives providing implementations.
* Add schema-backed machine snapshot versions

* Unify historical snapshot and event schemas

* Unify machine version schema interface

* Fix machine version schema edge cases

* Update docs/persistence.md

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
davidkpiano and others added 4 commits August 19, 2026 13:55
* docs: synchronize recent audit findings

* docs: remove unsupported FSM final-state claim
* Spike: address-first actor identity

- Generated child ids are src-keyed per-parent counters (worker:0) instead
  of flat x:N; anonymous logic keeps the x prefix; root actors are named
  after their logic id.
- Actors expose a deterministic logical address (/-joined id path), stable
  across persist/restore while sessionId identifies one incarnation.
- getEffectDescriptor() returns JSON-safe effect views with addresses and
  src keys instead of live refs.
- enq.spawn recovers the registered setup source key so spawned children
  persist by key instead of inline logic.
- enq.sendTo accepts a child id string, resolved against same-transition
  spawns first, then existing children.

* Spike: execution-scoped runtime, root ref accessor, incarnation contract

- ActorSystem gains an official runtimeOverride slot; the default runtime
  verbs delegate to it, so one override covers every actor in the system
  including snapshot views and later children.
- createDurable accepts adapter.systemRuntime and installs it on the actor
  system of every snapshot it produces; adds getActorRef(snapshot) for
  addressing/inspection.
- New public deliverEvent/stopActor/terminateActor helpers expose the
  default local behaviors custom runtimes delegate to.
- The in-memory durable reference host now uses zero private members: no
  Object.assign(actor.system, ...), no _parent/_send/_stop; root routing
  compares logical addresses.
- Tests document the sessionId-as-incarnation contract: completions from a
  previous incarnation are dropped after restore; current ones transition.

* Reframe host runtime as system.runtime, not an override

The system delegates each runtime operation to system.runtime when
provided; the built-in local behavior is the default runtime at the same
level as any other. Rename runtimeOverride accordingly and document the
model.

* Fix preconstruct build: generic arrow to function declaration

* Fix systemRuntime-only adapters; durable effect descriptors; rootAddress

Findings from re-pointing the Rivet POC at this branch:
- executeEffects passed {} when the adapter had no per-effect runtime,
  defeating the effect default parameter, so systemRuntime-only adapters
  crashed on the first spawn. Now the runtime stays undefined when a
  systemRuntime is installed; without either, unsupported operations still
  throw rather than silently running local behavior on a durable host.
- Every DurableEffect now carries a JSON-safe descriptor for journaling
  and deduplication (builtins previously never reached hosts as data).
- DurableExecution.rootAddress names the root before any transition, so
  hosts can label mailboxes without deriving 'root address == machine id'
  themselves.

* Async handoff: executeEffects settles transitively, returns root events

Location transparency: every inter-actor edge is an async handoff to the
runtime. createDurable now hands runtime operations over strictly
sequentially in initiation order, tracks the transitive closure of
initiated operations (including operations from live child actors
reacting to delivered events), and resolves executeEffects only when all
of them have been accepted — so 'effects executed' means 'safe to
checkpoint and suspend'. Events addressed to the root actor are captured
instead of delivered and returned from executeEffects as
DurableRootEvent records ({event, source}) for the durable loop; run()
processes them before durably waiting. Per-effect and system runtimes
are both wrapped; unsupported sendEvent still throws for per-effect
runtimes while systemRuntime keeps local delivery as the fallback.

* Children-by-address persistence; per-actor id counters

Decision 4 (location transparency): persisted snapshots reference
children by logical address; the embedded child state remains as the
co-locating runtime's whole-tree checkpoint capability.

Generated-child-id counters move from the shared system snapshot into
each actor's own machine snapshot (_nextActorIds keyed by src prefix),
allocated in the transition enqueue from the parent's snapshot with
collision avoidance against existing children. A subtree persisted on
its own carries its numbering with it and continues deterministically
when restored under a different placement. System-level counters remain
a process-local backstop fed by restored-id reservation and are no
longer persisted.

* Detached children as location-transparent remote handles

Actor-model prior art (Erlang PIDs, Akka ActorRefs, Orleans grain
references) agrees: the reference is the address — a lazy handle
constructed from identity alone, sends route through the runtime, and
remote state is never synchronously readable. Accordingly:

- getPersistedSnapshot({ embedChildren: false }) persists children by
  logical address only; embedding stays the co-locating default.
- Restoring an address-only child creates a remote handle (no lookup):
  send() routes through system.runtime, getSnapshot() returns an opaque
  active placeholder, and persisting again keeps the address reference.
- A child without a known sessionId is a remote handle, so completion
  staleness for it is the owning runtime's authority: done/error events
  for remote children match any incarnation locally.

* Journalable ref identity, restored-children runtime coverage, docs, changeset

- Actor.toJSON now yields {id, address, src key}: any actor reference
  passed to a runtime operation is journalable as its logical identity.
- The durable execution installs its systemRuntime on restored children
  and remote handles carried into a transition, not only on the root's
  system, closing the restore-path coverage gap.
- durable-execution.md rewritten for the address-first contract:
  addresses and incarnations, the system runtime and async handoff,
  settle semantics, root-event capture, descriptors, embedChildren and
  remote handles. Wire-protocol note: namespace host keys outside the
  logical address; the machine id is the logical root name.
- Changeset for the address-first identity work.

* Self-review: remove dead legacy counter, unify snapshot access, JSDoc

- Remove the legacy flat _nextActorId counter: it was written, persisted
  and restored but never allocated from — per-parent map counters cover
  generated ids and requested-id reservation covers restore collisions.
  Old persisted snapshots keep the optional field in the historical type.
- Unify the enqueue's working-snapshot access on one raw-_snapshot helper;
  the string-id sendTo path previously used getSnapshot(), which throws
  while the actor initializes.
- Merge the duplicated persisted-children entry construction into one
  entry with a conditional embedded snapshot.
- Tighten pushSpawnedChild (id is always allocated now), document the
  local runtime's spawn no-op, Actor.toJSON, and enq.spawn's
  deterministic id allocation; fix stale 'override' wording in the
  runtime helpers.

* Document getSnapshot as last-published-value; freeze remote placeholder

getSnapshot is the BehaviorSubject contract, not a state query: the
cached last emission from the actor, coherent within a run-to-completion
turn. Remote handles expose lifecycle only, and their 'active'
placeholder is accurate by construction — completions remove the child
from its parent's children, so a handle only exists while the child is
presumed active. Freeze the shared placeholder snapshot.

* Fix restoreOnto helper typing broken by persisted-snapshot branding

Mirrors the in-progress fix from the shared checkout: the helper typed
its snapshot through the un-parameterized createActor, which resolves
the machine-identity brand to never, so no concretely-branded snapshot
was assignable. Pre-existing on next; blocks CI here.

* Address review findings: allocation shared per transition, unique roots

- Generated-id allocation state (counters + spawned-by-id) is now shared
  by every enqueue object of one transition via a per-scope transaction
  begun at each transition boundary, so spawns of one source across
  action functions and microsteps get distinct ids and string-id sendTo
  resolves children spawned in earlier microsteps. The system counters
  floor the allocation so context-created spawns during initialization
  cannot collide with entry spawns.
- Parentless actors of the same logic in a shared system no longer share
  an address: the first keeps the bare logic name, later ones number
  past it, and a restored root's bare name is reserved.
- Remote handles expose a self ref so context persistence recognizes
  them as actor references instead of recursing into their system
  (stack overflow on re-persisting a restored snapshot).
- Remove the unused isRemoteActorRef export (knip).

Each finding is covered by a regression test.

* Address second-round review findings

- Runtime operations initiated while another operation runs execute
  inline instead of queueing behind it, so a host sendEvent that awaits
  a nested runtime operation no longer deadlocks executeEffects; the
  serialization constraint is documented.
- Custom actions receive the wrapped system runtime when no per-effect
  runtime is configured, matching the documented contract (built-in
  effects already fell back through the actor's system).
- Host runtimes keep inspection parity: sends and scheduled timers are
  recorded for the sent[] facet before delegating.
- Persisted address-only children carry an explicit 'remote: true'
  marker, so a child whose own persisted snapshot is undefined can never
  be misrestored as a remote handle after a JSON round-trip; restore
  keys on the marker, registers the handle under its registryKey, and
  round-trips syncSnapshot.
- The incarnation-staleness exemption is scoped to the _remote marker
  instead of any child lacking a sessionId.
- Explicit ids shaped like generated ones ('worker:5') reserve their
  numbering in the parent snapshot, so live runs and pure replays
  allocate identical later ids; counter merges never regress.
- String-id sendTo tracks in-flight child additions and removals across
  microsteps (invokes included), so it neither misses children added
  earlier in the transition nor resolves ones already stopped.
- Remote-send-without-runtime error explains how to install a runtime;
  descriptor docs and changeset soften 'JSON-safe' to serializable
  identity, and the changeset notes the root-capture behavior change for
  existing per-effect runtime adapters.

Every behavioral finding has a regression test.

* Address third-round review findings

- Context-factory spawns record their generated-shaped ids in the
  pre-initial snapshot's own counters, so the allocation survives
  persistence: a freed id (e.g. a stopped context child's 'worker:0') is
  never handed out again after a restore or in a fresh replay process.
- FSM transitions begin a spawn-allocation transaction at their
  transition and initialTransition boundaries, so spawns from the
  transition function and entry actions of one step share allocation
  state and get distinct ids (and string-id sendTo resolves across
  them), matching machine behavior.

Both with regression tests.

* String-id sendTo resolves same-step invoke and context children; docs

- Children created outside enq.spawn register with the transition's
  spawn allocation: invoked children at their insertion site and
  context-factory children in _getPreInitialState, so
  enq.sendTo('childId') resolves them from actions running in the same
  step instead of surfacing a communication error.
- docs/spawn.md documents the src-keyed generated id format, and the
  persistence schema example uses _nextActorIds.

* Address fourth-round review findings

- Runtime operation failures are collected as they settle, so a rejected
  host operation always rejects executeEffects even when it left the
  pending set before settle() sampled it.
- A per-effect runtime overrides the system runtime
  operation-by-operation; omitted operations keep the system runtime's
  behavior instead of throwing.
- A failed executeEffects batch clears its captured root events so a
  retrying host never sees them duplicated or replayed out of order.
- Actor ids containing '/' (the address path delimiter) throw in
  development; '/' is documented as reserved.
- Restoring an address-only child whose persisted src is not a
  registered source key fails loudly instead of fabricating one.
- system._unregister tolerates sessionId-less remote handles.
- Docs: executeEffects ordering describes inline execution of operations
  initiated mid-operation and failure propagation; root-event capture
  scope (host-originated deliveries belong in the host mailbox);
  embedChildren applies to whole subtrees; remote handles keep their
  persisted address verbatim; descriptor src uniqueness caveat; changeset
  notes runtime wrapping. Comments document the broad id-reservation
  choice, the remote handle's intentional surface, and string-id
  resolution order (with a stop-then-respawn test).

* Align executeEffects JSDoc with the documented handoff ordering

* Address fifth-round review findings

- Clear recorded operation failures when a batch of effects fails.
  `track()` pushes every runtime-operation rejection into an
  execution-scoped `operationFailures` array, but only `settle()` drained
  it. When an awaited effect rejected first, `settle()` was skipped and
  the stale failure was thrown by the *next* `executeEffects(...)` call,
  so a durable workflow could never make progress after a transient host
  failure even once the retry succeeded. The catch block now drains the
  operations still in flight (so their rejections are recorded before the
  clear) and resets both `capturedRootEvents` and `operationFailures`.
  Covered by a regression test that fails the first host spawn and
  succeeds on the retry.

- Document the new actor identity surface. `docs/create-actor.md` now
  lists the `address` member and the `getPersistedSnapshot(options?)`
  signature, and `docs/persistence.md` gains a "Persist children by
  address" section covering `{ embedChildren: false }`, the
  location-transparent handle it restores, and address stability versus
  `sessionId`.

* Scope durable capture and tracking to an executeEffects batch

The wrapped system runtime stays installed on the actor system for the
whole execution, so it also saw operations initiated while the durable
loop was parked in waitForEvent() — for example a live child replying to
its parent after the host delivered an event to it. Three consequences,
all now fixed:

- Root-addressed sends from a parked window were pushed into
  `capturedRootEvents`, where nothing drained them until some unrelated
  later `executeEffects` happened to return them (or the execution ended
  and they were lost). This contradicted the contract documented in
  docs/durable-execution.md, that the capture covers only operations the
  execution itself drives.

- Failures of parked-window operations landed in the execution-wide
  `operationFailures`, so the next, otherwise successful batch was
  rejected with an unrelated error and had its own captured root events
  discarded.

- `settle()` waited on operations the batch it was settling had not
  initiated.

`executingBatch` now gates the wrapped runtime. Inside a batch, behavior
is unchanged: root-bound events are captured, other operations go
through `handOff` and are tracked. Outside a batch, operations are
invoked directly — untracked, since the host awaits its own delivery
chains — and a root-addressed send is routed to the runtime's `sendEvent`
like any other target, so it lands in the host's mailbox. The flag is
cleared in a `finally` that runs after `settle()` and after the catch
path's drain, so operations a batch initiated stay in-batch while they
finish.

The in-repo reference host now enqueues a root-addressed target into its
inbox, and the `systemRuntime` JSDoc plus docs/durable-execution.md state
the narrowed invariant ("not during executeEffects" rather than "never").

Separately, the location-transparent remote handle
(src/remoteActorRef.ts) is cast to `AnyActor` but omitted `stop()`,
`select()` and `trigger`, so user code following the documented actor
member list got a bare `TypeError`. Those three now throw descriptive
errors in the same style as `_send`, explaining that the member requires
a co-located actor and how to reach the remote one. Observable interop
and `_processingStatus` remain omitted, and no real implementations were
added.

Regression tests cover each finding; all three fail without the fixes.

* Changeset: document the parked-window sendEvent contract for hosts

* Simplification pass: one allocator, one batch, shared primitives

Reviewed the branch diff for reuse, simplification, efficiency, and
altitude; behavior-preserving cleanups only (full suite arbitrates).

Altitude:
- Generated child ids now have ONE allocator: the per-transition spawn
  allocation transaction serves enq.spawn and the context-factory
  spawner alike, seeded from the parent snapshot's own persisted
  counters. This deletes the system-counter floor, the collision scan,
  and the pre-initial regex fold — the three redundant defenses that
  reconciled two counter stores. resolveActorId keeps only explicit-id
  pass-through, requested-id reservation, and system-scoped allocation
  for parentless roots and non-snapshot actors (listeners). A restore-
  time fold of generated-shaped child ids remains as the single legacy-
  snapshot safety.
- createDurable's per-batch state (captured root events, failures,
  pending operations) is one Batch object created per executeEffects
  call and discarded on failure, replacing four coordinated module
  bindings, two manual clears, and a copied drain loop. dispatch()
  expresses the batched-vs-parked handoff once.

Reuse:
- parseGeneratedActorId (lastIndexOf, no regex backtracking) is the
  single definition of the generated-id shape, used by reservation,
  spawn, and restore. getRootActorId is the single parentless-naming
  rule, shared by resolveActorId and durable.rootAddress.
  RUNTIME_OPERATIONS lives next to ActorSystemRuntime. The system's
  default stop/terminate/deliver behaviors are the exported runtime
  helpers, so the helpers and the built-in runtime cannot drift.
  isRemoteActorRef owns the remote brand at all three consumers.
- resolveActorId's two duplicate allocation branches collapse to one;
  remote-handle error messages share their restore hint; the persisted
  children entry is built once with a conditional snapshot.

Efficiency:
- actor.address memoizes (id and parent are construction-final), making
  descriptor building, allocation keys, and persistence O(1) per access.
- DurableEffect.descriptor is a lazy memoized accessor: executing hosts
  never pay for descriptor construction or address walks.
- The spawn src-key reverse lookup caches per sources.actors object.
  installSystemRuntime skips children sharing the root's system.

* API polish from review: flat adapter, ref-only sendTo, docs restructure

- createDurable adapters carry their runtime operations directly
  (DurableExecutionAdapter extends Partial<ActorSystemRuntime>) instead
  of nesting them under a systemRuntime key; the execution picks them
  off the adapter and installs them as before. An adapter with no
  runtime operations is strict-throw mode, as documented.
- enq.sendTo is ref-only again. The string-id form was weakly typed
  (event side fell back to AnyEventObject) and added a shadow child
  registry (spawnedById/removedIds/registerSpawnedChild across three
  files) purely for name resolution, while refs are typed and effect
  descriptors record addresses either way. Seven tests that existed only
  to pin the string form's resolution semantics are deleted with it.
- docs/durable-execution.md restructured implementor-first: a short
  'Write an adapter' section with the complete integration, then
  Identity / The effect contract / Checkpoints and placement as the
  precise contract reference. Changeset updated for both changes.

* Fix generated-id rewind after an explicit low id; return _relay's result

- An explicit generated-shaped id below the parent's persisted counter
  (enq.spawn(actors.worker, { id: 'worker:0' }) on a snapshot whose
  counter is 3) seeded the transaction counter from the requested index
  alone, so the next generated spawn allocated a freed id — breaking the
  'a freed id is never handed out again' invariant. Both allocation and
  reservation now floor against the parent snapshot's persisted counter
  through one helper: the requested id is still used as asked, but
  numbering never rewinds. Regression test included.

- RuntimeSystem._relay returns the runtime's result, matching its own
  declaration and the enq.sendTo path that already returns it, so a host
  runtime's asynchronous delivery is observable to callers that can
  await it. Documented on system.runtime that a promise-returning
  operation owns its own failure handling; createDurable tracks its
  adapter's operations and fails executeEffects when one rejects.

* Fix four review findings: FSM counters, id encoding, helper ids, runtime fallback

All four verified by reproduction before fixing; each has a regression
test that failed first.

- FSM snapshot clones dropped the generated-id counters, so a state
  change that spawned nothing reset them and the next spawn silently
  replaced a still-running child (probe: two spawns across three states
  produced one child). cloneSnapshot/stopSnapshot now carry
  _nextActorIds, and it is part of FSMSnapshot.

- The development-time rejection of '/' in actor ids rejected ids XState
  generates itself: an invoke inside a state whose name contains a slash
  produced '0.b.a/b' and the machine failed to start. Addresses now
  percent-encode '/' in each segment instead, so the path stays
  unambiguous — in production too, where the check never ran.

- Listener and subscription actors allocated from the system counters
  while spawns allocate from snapshot counters, so an anonymous spawn
  and a listener could both take 'x:1' and two live actors shared one
  address. Internal helper logics now carry their own id ('xstate.
  listener', 'xstate.subscription') and allocate in their own namespace.

- Supplying a per-effect runtime replaced the adapter's runtime entirely,
  so any operation neither implemented crashed instead of keeping local
  behavior (probe: 'runtime.spawnActor is not a function'). The wrapper
  now falls back to the execution's system for those operations.

* Serialize every runtime handoff, not just top-level ones

Validating the branch against the Rivet proof of concept surfaced a real
failure the unit tests could not: 'EntryInProgressError: Cannot start a
new workflow entry while another is in progress'. A stop cascade
initiates cancelAllTimers and stopActor without awaiting either; both ran
inline through the nested-operation escape hatch, so their host steps
overlapped and Rivet — whose step model forbids concurrent entries —
aborted the workflow.

The escape hatch existed to protect a host operation that awaits another
runtime operation of the same execution, which the docs already forbid.
Serialization is the property real hosts depend on, so every handoff now
queues behind the last one. That deletes the runningOperation flag and
the inline branch, makes the ordering guarantee unconditional, and lets
the docs state it plainly.

* Address ninth-round review findings

- Make address encoding injective: escape % before / in address segments
  (ids 'a/b' and 'a%2Fb' now produce distinct addresses)
- Invalidate memoized address when a late parent is assigned in transition()
- Move the internal-event guard into the deliverEvent helper so host
  runtimes delegating local delivery keep the protection
- Remote handles: pass syncSnapshot through on restore; getPersistedSnapshot
  throws with a restore hint (state lives with the owning runtime)
- parseGeneratedActorId rejects trailing-colon ids
- Per-effect runtime fallback resolves omitted operations against the
  operation's own actor's system instead of a captured execution system
- Throw when a parked root-addressed event has no host sendEvent to
  receive it (was silently lost in an inert mailbox)
- Guard against overlapping executeEffects calls
- Persist-time error for by-address children without a registered source key
- Strip the legacy _nextActorId field when restoring old snapshots
- Dev warning when child ids are generated outside a spawn allocation
- Docs: retry re-delivery semantics, runtime-op local-cleanup delegation,
  remote-completion dedup, inert remote subscriptions, address-persistence
  preconditions and children-shape migration note

* Reserve the xstate. id namespace for internal helper actors

Internal helpers (enq.listen / enq.subscribeTo) number their ids from
system-level counters under xstate.-prefixed names, while snapshot-owned
children number from per-snapshot counters; the two spaces stay
collision-free only because their prefixes are disjoint. A user source or
logic id named xstate.listener could still produce a second live actor at
the same address. Spawning a source whose generated-id prefix (or explicit
generated-shaped id) enters the reserved namespace now throws in
development.

* Enforce unique child ids per parent; fix leaked invoke on history reentry

An address must name at most one live actor, but a duplicate explicit id
(enq.spawn options.id, or the same invoke id in two parallel regions)
silently constructed and started a second actor at the same address; only
one was reachable through snapshot.children and the other leaked past the
parent's stop cascade. Spawns and invokes now throw when an explicit id is
already held by a live child of the same parent. Ids stopped earlier in the
same transition are free to reuse, which keeps the stop-then-spawn restart
pattern and invoke restarts on reentry working.

The guard exposed a second bug of the same class: a transition to a history
state that restores the source itself computed an exit set that excluded the
source while the enter set restored it, so the source's invoked actors were
re-created without the previous incarnation ever being stopped — leaking a
running actor at the same address. The transition domain now treats a
history target that restores the source as exiting and reentering the
source, matching the SCXML exit set.

Docs: delivery semantics (at-most-once, pairwise FIFO, global serialization
on the durable path), a determinism-constraints section for durable
execution, and timer restore semantics in the persistence guide.

* Add dead letters, incarnation tokens, and durable version pinning

- deadLetter(source, target, event, reason) joins ActorSystemRuntime: the
  default logs in development and emits the new @xstate.deadletter
  inspection event; hosts implement it to observe undeliverable events.
  Delivery stays at-most-once. The send-to-stopped-actor warning now routes
  through it (two tests asserting the old warning text updated).
- Persisted remote child entries round-trip an optional opaque incarnation
  token, verbatim. XState never stamps one (a local sessionId would make
  persisted snapshots nondeterministic across replays); when a host
  supplies one, completions from a different incarnation of the address are
  dropped on the referencing side and sendTo effect descriptors journal the
  target's token, so a send replayed after reincarnation is detectable.
- createDurable exposes machineId and machineVersion for pinning an
  execution's journal to the machine version that produced it.
- docs(durable-execution): journaling rules — a journaled step's closure
  must not start another journaled operation and must not mutate local
  actors (skipped on replay); annotate the executeAction example
  accordingly. Document incarnation tokens, dead letters, and version
  pinning; inspection docs cover @xstate.deadletter.

* Persist timer wall-clock starts so restore honors absolute deadlines

Timers persisted from a running actor carry startedAt, derived from the
runtime's scheduled dueAt minus the declared delay so repeated
persist/restore cycles keep the same deadline. Restoring schedules the
remaining time toward that deadline — a timer past due fires immediately —
clamped to the declared delay, which also neutralizes a start from a
different clock domain (persisted under the wall clock, restored under a
simulated one). Pure-transition snapshots run no local schedule, persist no
timestamp, and stay byte-deterministic across replays; restoring them keeps
the previous restart-the-declared-delay behavior.

Two tests codifying the previous no-timestamp decision were updated to the
new contract (timers.persistence, persistenceConformance #5331).

* Route async-actor steps through a runStep runtime operation

enq.step keeps its meaning — a keyed once-only step — while who journals
it becomes the runtime's business, like every other effect. The built-in
behavior is unchanged: results memoize into the actor's own snapshot
(effects[key]) via the runStep helper, now exported for hosts to delegate
to. A durable host that implements runStep owns the step journal instead:
memoized results replay without re-running the step, and the snapshot
mini-journal steps aside, removing the double-journaling that forced
adapters to re-drive async logic by hand.

runStep is deliberately not in RUNTIME_OPERATIONS: a step is an
orchestration frame that may itself await runtime operations of the same
execution, so durable executions pass it through undispatched instead of
serializing it behind the handoff queue (which would deadlock with the
operations the step body initiates).

The step effect constants move to constants.ts (re-exported from
actors/logic.ts) so runtimeHelpers stays a leaf module.

* Address tenth-round review findings

- A stale completion for a remote child with an incarnation token no longer
  removes the still-running child: removeTerminatedChild applies the same
  incarnation rule as transition selection instead of trusting any
  completion for a remote handle.
- Timer startedAt is stamped only under the wall clock, and honored only
  under the wall clock: a custom clock's readings (SimulatedClock, a
  monotonic counter) are meaningless in another process, and restoring them
  under a different clock fired every pending delay instantly (or never).
  Custom-clock actors keep declared-delay restore semantics; the two timer
  tests reverted to their original no-timestamp assertions for
  simulated-clock actors.
- A terminated child no longer occupies its explicit id: the supervisor
  pattern — respawn under the same name while handling the child's
  done/error event — works again instead of erroring the parent.
- durable.rootAddress is now the encoded address segment the root actor
  reports, so machines whose id contains '/' or '%' capture root events
  instead of silently losing them.
- docs(spawn): reusing a live child's id throws now; documented the stop
  and completion carve-outs.

* Cleanup pass: reattach stray JSDoc, hoist loop-invariant timer lookup

- Reattach allocateChildId's JSDoc, which the reserved-namespace guard's
  insertion had orphaned above assertUnreservedPrefix.
- getPersistedSnapshot: resolve the wall-clock scheduled-timer record map
  once instead of re-deciding the clock domain per timer, and use the typed
  system fields instead of a structural cast.
- Spell wrapRuntime's fallbackToActorSystem parameter consistently.
- Deduplicate the restored-timer record type in Actor.start.

* Serialize remote handles with the actor-reference marker

A remote handle's toJSON now produces the same shape as Actor.toJSON
({ xstate$$type, id, address, src }), so serialized snapshots carry one
actor-reference discriminant whether a child is co-located or remote, and
tooling that detects actor references classifies detached children
correctly.

* Replace the xstate$$type marker with xstate$type: 'actorRef'

The serialized actor-reference discriminant was a v5 leftover: a magic 1
under a double-dollar key. Serialized references (Actor.toJSON, remote
handles, persisted context refs) now carry a readable
xstate$type: 'actorRef'; reviveContext still recognizes the legacy v5
marker so old persisted context refs restore.

* Carry a timer's persisted startedAt through schedule-less re-persists

getPersistedSnapshot only stamped startedAt from a live scheduled record,
so a restore → persist cycle that never starts the actor locally (the
durable-host shape) dropped the carried-in start and the next restore
restarted the full declared delay — a long delay could be postponed
forever. Without a live schedule the timer's own startedAt now passes
through; it can only ever have originated from a wall-clock stamp, so the
clock-domain rule is unchanged. startedAt joins LogicalTimer as an
optional field since it legitimately flows through runtime snapshots.

* Drop v5 marker recognition from reviveContext

The alpha never shipped the old wire format, and v5 snapshots already
restore through machineVersions/migrate — recognizing xstate$$type: 1 in
core was dead compatibility code. xstate$type: 'actorRef' is the only
actor-reference marker.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
davidkpiano and others added 5 commits August 20, 2026 09:10
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* Deterministic durable execution identity + host-guidance docs

Field testing the durable contract against five hosts (Restate, Rivet,
Temporal, Inngest, Trigger.dev) surfaced one API gap and three
documentation gaps:

- executionId on the durable adapter pins the execution's actor identity:
  session ids become <executionId>:<n>, a deterministic function of
  actor-creation order, so a host that journals the execution's own events
  can replay them — a journaled xstate.done.actor completion still matches
  the child the replay re-creates. Two hosts (Rivet, Restate) independently
  hit the stale-sessionId form of this and had to segregate internal from
  external events as a workaround.
- docs: hosts whose wait is durable rather than in-process must quiesce
  in-flight steps before parking (two hosts deadlocked on this).
- docs: runStep's exec is a closure over live execution state — run it
  in-process and journal the result; it cannot ship to a remote executor.
- docs: replay reconstructs the same effects in the same order, not just
  the same snapshot — the guarantee positional-journal hosts depend on.

* Add runLogic: the invoked async actor as the primary durable unit

A developer writes a normal promise and invokes it; the runLogic runtime
operation hands the host the whole body as one journal entry. The actor's
identity (address, string src key, serializable input) crosses any
boundary, so a host wraps the provided exec in its own step primitive or
ignores it and re-runs the registered logic on a remote executor from
(src, input) alone — no closure ships, no step vocabulary appears in the
machine. Output flows back as the actor's ordinary completion event.
enq.step/runStep remain for opt-in finer granularity. Tests cover the
journal-wrap shape (exactly-once across replay, journaled completion
matching the replayed child) and the closure-free remote shape.

* docs: reposition enq.step as hostless durability; portable actions; driving effects yourself

- enq.step is the hostless tool — snapshot as journal (client reload-resume,
  restore-per-request); durable hosts write plain promise actors and let
  runLogic journal them. actor-logic, durable-execution and
  backend-workflows all point the same direction so nobody reaches for
  enq.step on a durable host.
- Portable actions: durability crosses a boundary as declared identity plus
  serializable data — enq(namedFn, serializableArgs) ships to a remote
  executor via its (type, args) descriptor; inline closures run in-process.
- Driving effects yourself: createDurable is sugar over the pure
  transition() APIs; effects are plain objects a host may execute itself,
  taking on transitive settlement, root-event capture, ordered handoff and
  batch discard.

* Fold the remote incarnation token into sessionId

One incarnation identity, one field, one rule: a ref that knows its
incarnation compares it; a remote handle without a host-supplied token
(sessionId undefined) defers to the owning runtime. Deletes the
_incarnation field and the remote special-cases in matchesActorSession and
removeTerminatedChild. The persisted wire field stays  — it is
a claim about the owner's sessionId, and local children persisted by
address still never stamp their own (replay determinism). One test updated
from asserting the deleted field to asserting sessionId.

* Fix next build and untyped-machine send regression from #5664

- The declare class field for _internalEventType breaks preconstruct's
  babel pipeline; declaration merging onto the class keeps the marker
  zero-emit and builds.
- IsInternalEventType treated descriptors that collapsed to broad string
  (an untyped machine's config) as matching every event type, so sendable
  events reduced to never — 74 type errors across the framework packages'
  tests. Broad descriptors now classify nothing; regression test with an
  untyped machine added.

* Use a definite-assignment field for the internal-event marker

Declaration merging tripped no-unsafe-declaration-merging and unused type
parameters; a plain annotated field builds under the babel pipeline and
emits one inert undefined property per machine instance.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
davidkpiano and others added 2 commits August 21, 2026 19:13
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
davidkpiano and others added 2 commits August 22, 2026 09:18
…p, output inference (#5669)

- executeEffects retains captured root events; execution.waitForEvent()
  hands them out before deferring to the adapter. The drive loop no longer
  routes root events by hand (executeEffects resolves void).
- createDurable(logic, adapter, { inspect }) observes @xstate.actor /
  @xstate.transition across the whole live tree, including pure-path
  transitions: fresh systems attach the ambient inspector at construction,
  and snapshot systems created under it forward inspection to their base
  instead of stubbing it (planning branches stay silent as before).
- execution.getActorRef(snapshot, address) resolves a logical address
  against the live actor tree, for string-addressed durable mailboxes.
- Machine output types infer from the config's output function when no
  schemas.output is declared; a declared schema stays authoritative.
- DurableSnapshot keeps the Snapshot discriminant visible for unresolved
  TLogic, and adapter waitForEvent may return plain event objects — generic
  host libraries need no casts.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
davidkpiano and others added 2 commits August 22, 2026 17:02
* Harden spawned actor source persistence

* Add typed string transition spawning
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
davidkpiano and others added 4 commits August 22, 2026 23:22
Distinguish live snapshot machine references from persisted identity, infer optional input through the actor-logic contract before setup identity intersections, and add a root-only durable mailbox hook.
…in enq.stop/listen/subscribeTo (#5647)

* Contextually type guard/delay sources from schemas; widen enq consumer APIs to ActorRef

- Guard and delay source maps in setup(), extend(), and createMachine() now
  get typed args ({ context, event, ... }) from the call's schemas via typed
  companions (GuardSourceMap/DelaySourceMap) intersected at authoring sites.
- Sources['guards'|'delays'] constraints are now signature-free
  (Record<string, Function>) so they no longer leak 'any' into contextual types.
- Fix the TSchemas setup() overload never matching zod schemas (drop the
  unsatisfiable 'TSchemas & SetupSchemas' self-intersection); calls no longer
  fall through to the whole-config overload that cannot type sources.
- Guard sources are args-first: (args, ...params) => boolean, matching the
  serialized-machine runtime; helper-style guard tests updated.
- enq.stop/listen/subscribeTo accept AnyActorRef, so ActorRefFrom-typed refs
  work everywhere enq.spawn results do.

* Address review: mirror runtime schema merge in extend() source typing; keep provide() sources checked

- MergedSetupSchemas now merges map-valued schema keys (events, emitted,
  children, actions, guards) entry-by-entry like the runtime mergeSchemas,
  so extension guards/delays see base-declared events.
- provide() guards/delays intersect typed source signatures mapped over the
  known names, restoring return-type checking (lost when Sources constraints
  became signature-free) while still rejecting unknown source names.
- DelayMapFromNames preserves authored delay entry types instead of erasing
  them to the generic constraint.

* docs: update named-guard examples to args-first calling convention

Guard sources receive the transition args object first, then params; forward
args at call sites (guards.md, cheatsheet.md, choice-states.md,
setup-and-provide.md, xstate-v5-to-v6.md). All updated examples typechecked
against the package source.

* Fix restoreOnto snapshot param typing for branded persisted snapshots

* Allow provide() to swap a fixed delay for a computed one

Delay entries in provide() are widened to number | fn per known name instead
of Partial<TDelayMap>, so setup-declared numeric delays can be replaced with
computed ones (and vice versa) while unknown names and wrong return types are
still rejected.
* docs: define durable event-journal timers

* chore: add durable timer changeset
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants