XState v6 - #5543
Open
davidkpiano wants to merge 465 commits into
Open
Conversation
@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 detectedLatest commit: 1e099f7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
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 |
* 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>
* 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>
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>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…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>
* Harden spawned actor source persistence * Add typed string transition spawning
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moved from #5257