Architecture
XMachines Play implements the Universal Player Architecture — a layered, actor-authority design where XState machines are the single source of truth for routing, navigation, and view selection. Infrastructure (routers, renderers) is strictly passive: it observes actor atoms and reflects them, never enforcing guards or making decisions.
System Overview
XMachines JS is the JavaScript/TypeScript reference implementation of the Universal Player Architecture (Play RFC). It is a monorepo of modular packages that strictly separates business logic (the Actor) from infrastructure (router adapters and view renderers). An XState v5 state machine is the single source of truth; it owns all state, guards, and route validity. Infrastructure is passive: it observes atoms emitted by the Actor and proposes state changes via typed events. The Actor’s guards make every navigation and transition decision. Communication across the boundary is exclusively through atoms — never subscriptions, callbacks, or direct state mutation. Five architectural invariants (documented in the Play RFC) enforce this contract across the entire package graph.
Architectural Invariants
| ID | Name | Rule |
|---|---|---|
| INV-01 | Actor Authority | Infrastructure proposes intents via play.route events; Actor decides via XState guards |
| INV-02 | Strict Separation | No direct dependencies between Actor and Infrastructure layers. play-xstate never imports UI frameworks or routing libraries |
| INV-03 | Atom-Only Reactivity | All Actor→Infrastructure state changes flow through atoms (Atom, ReadonlyAtom). No subscriptions or event emitters |
| INV-04 | Passive Infrastructure | Infrastructure observes Actor atoms, never controls state |
| INV-05 | State-Driven Reset | Invalid external navigation is overwritten by Actor-derived state |
Component Diagram
graph TD
subgraph Foundation["Foundation"]
core["@xmachines/core\n(BaseError, asDisposable, cache)"]
shared["@xmachines/shared\n(tsconfig, oxlint, oxfmt)"]
end
subgraph Protocol["Protocol / Primitives"]
play["@xmachines/play\n(PlayEvent, PlayError)"]
atoms["@xmachines/play-atom\n(atoms, watchAtom)"]
end
subgraph ActorBase["Abstract Actor Base"]
actor["@xmachines/play-actor\n(PlayActor)"]
end
subgraph ViewHalf["The View Half"]
view["@xmachines/play-view\n(Viewable, PlaySpec, the view session)"]
end
subgraph Concrete["Concrete Implementations"]
xstate["@xmachines/play-xstate\n(PlayerActor, definePlayer, routing)"]
router["@xmachines/play-router\n(RouterBridgeBase, RouteMap, Routable)"]
routerxstate["@xmachines/play-router/xstate\n(extractMachineRoutes, createRouteMap)"]
subgraph Views["View Renderers"]
react["@xmachines/play-react"]
vue["@xmachines/play-vue"]
solid["@xmachines/play-solid"]
svelte["@xmachines/play-svelte"]
dom["@xmachines/play-dom"]
end
subgraph RouterAdapters["Router Adapters"]
tsr["@xmachines/play-tanstack-react-router"]
rr["@xmachines/play-react-router"]
vr["@xmachines/play-vue-router"]
sr["@xmachines/play-solid-router"]
tss["@xmachines/play-tanstack-solid-router"]
sk["@xmachines/play-sveltekit-router"]
ssr["@xmachines/play-svelte-spa-router"]
dr["@xmachines/play-dom-router"]
end
end
core --> play
atoms --> actor
actor --> xstate
actor --> router
actor --> Views
atoms --> xstate
atoms --> router
play --> xstate
play --> router
router --> RouterAdapters
xstate -.->|"play.route events"| router
The diagram groups the packages by role. The build order is a separate view: each package sits one layer above its deepest workspace dependency, and Build Layers lists them.
Component Responsibilities
| Package | Responsibility | Key File |
|---|---|---|
@xmachines/play | Core protocol types and error base class | packages/play/src/index.ts |
@xmachines/play-atom | The atom primitives, isolated in one package | packages/play-atom/src/index.ts |
@xmachines/play-actor | PlayActor, and nothing else | packages/play-actor/src/play-actor.ts |
@xmachines/play-view | Viewable, PlaySpec, the shared view half | packages/play-view/src/view-types.ts |
@xmachines/play-xstate | XState v5 adapter: definePlayer, PlayerActor | packages/play-xstate/src/player-actor.ts |
@xmachines/play-pattern | The route pattern language: one parse, and buildPath | packages/play-pattern/src/pattern-grammar.ts |
@xmachines/play-url | The URLPattern API, a base path, framework params | packages/play-url/src/url-pattern-utils.ts |
@xmachines/play-router | RouteMap, RouterBridgeBase, the provider lifecycle | packages/play-router/src/router-bridge-base.ts |
@xmachines/play-router/xstate | Route extraction from an XState machine | packages/play-router/src/xstate/extract-routes.ts |
@xmachines/play-react | React renderer: ActorProvider, PlayRenderer | packages/play-react/src/ActorProvider.tsx |
@xmachines/play-vue | Vue 3 renderer: ActorProvider.vue, PlayRenderer.vue | packages/play-vue/src/ActorProvider.vue |
@xmachines/play-solid | SolidJS renderer | packages/play-solid/src/ActorProvider.tsx |
@xmachines/play-svelte | Svelte 5 renderer | packages/play-svelte/src/ActorProvider.svelte |
@xmachines/play-dom | Vanilla DOM renderer | packages/play-dom/src/create-play-ui.ts |
@xmachines/play-tanstack-router | The framework-free TanStack bridge base | packages/play-tanstack-router/src/tanstack-router-bridge-base.ts |
@xmachines/play-tanstack-react-router | TanStack React Router bridge | packages/play-tanstack-react-router/src/tanstack-router-bridge.ts |
@xmachines/play-tanstack-solid-router | TanStack SolidJS Router bridge | packages/play-tanstack-solid-router/src/solid-router-bridge.ts |
@xmachines/play-vue-router | Vue Router 4/5 bridge | packages/play-vue-router/src/vue-router-bridge.ts |
@xmachines/play-react-router | React Router 7/8 bridge | packages/play-react-router/src/react-router-bridge.ts |
@xmachines/play-solid-router | SolidJS Router bridge | packages/play-solid-router/src/solid-router-bridge.ts |
@xmachines/play-dom-router | Vanilla DOM hash/history router bridge | packages/play-dom-router/src/dom-router-bridge.ts |
@xmachines/play-sveltekit-router | SvelteKit router bridge | packages/play-sveltekit-router/src/sveltekit-router-bridge.ts |
@xmachines/play-svelte-spa-router | Svelte SPA Router bridge | packages/play-svelte-spa-router/src/svelte-spa-router-bridge.ts |
@xmachines/shared | Shared configs: tsconfig, oxlint, oxfmt, vitest | packages/shared/config/ |
@xmachines/core | BaseError, the release protocol, a cache, helpers | packages/core/src/ |
@xmachines/docs | API docs and RFC documentation | packages/docs/ |
Data Flow
Actor State Change → UI Render
flowchart TD
A["XState machine transition"]
B["PlayerActor observes its own transitions"]
C["actor.state atom updated — the ONE write of the transition"]
D["actor.currentRoute computed atom recomputes\nderives URL from meta.route + context.params"]
E["actor.currentView computed atom recomputes\nderives the PlaySpec from meta.view"]
F["Framework atom subscription fires\nmicrotask"]
G["ActorProvider reads actor.currentView.get()"]
H["PlayRenderer renders via @xmachines/json-render-* Renderer"]
A --> B --> C
C --> D
C --> E
D --> F
E --> F
F --> G --> H
User Navigation (Browser URL → Actor)
flowchart TD
A["Browser popstate / router navigation event"]
B["Router adapter watchRouterChanges() handler fires"]
C["RouterBridgeBase.syncActorFromRouter(pathname, search)"]
D["sanitizePathname(pathname)\nlength/content guard"]
E["buildPlayRouteEvent(path, routeMap)\nresolves stateId from URL via URLPattern"]
F["actor.send({ type: 'play.route', to: '#stateId', params, query })"]
G["XState machine guard evaluates transition"]
H["machine transitions → atoms update\n→ URL bar updated"]
I["guard blocks → machine stays\n→ router re-syncs to actor's current route"]
A --> B --> C --> D --> E --> F --> G
G -->|valid| H
G -->|invalid| I
Actor State Change → Router URL Sync
flowchart TD
A["actor.currentRoute computed atom changes"]
B["RouterBridgeBase routeWatcher\natom subscription fires"]
C["watchAtom microtask queued\nscheduled dedup guard"]
D["syncRouterFromActor(route) called"]
E["lastSyncedPath set\necho suppression — prevents circular callback"]
F["navigateRouter(path)\ncalls framework router's navigate API"]
G["Router updates URL bar"]
A --> B --> C --> D --> E --> F --> G
Initial Connection (deep-link vs. restore detection)
flowchart TD
A["bridge.connect()"]
B["routeWatcher armed on actor.currentRoute"]
C["watchRouterChanges() subscribed"]
D["getInitialRouterPath() reads current browser URL"]
E["resolves sanitized URL path → stateId via routeMap"]
F{"Compare the URL, path and query, vs actor.initialRoute"}
G["syncActorFromRouter()\nrouter wins — deep-link"]
H["navigateRouter()\nactor wins — restore"]
A --> B --> C --> D --> E --> F
F -->|"URL ≠ initial URL (path or query)"| G
F -->|"URL = initial URL"| H
View derivation
flowchart TD
A["XState snapshot"]
T["resolveViewTree(root, snapshot.value)\na view with outlets composes the active children that an outlet shows;\nany other node takes its deepest view, the routed region first\nviewKey = the id of the state of the selected view"]
G["composeViews(tree) in play-view\nkept for each tree of each machine: elements, authored state, viewParts\nviewKey = the id of the outermost composing state"]
C["snapshot.context\nprojected wholesale as the /context slice"]
D["composePlayState(spec.state, slice)\nstate: { ...authored, context: slice }"]
E["reuseComposedState (the emit gate of the currentView atom)\nthe previous spec when nothing changed,\nits state reference when the slice is value-unchanged"]
F["Derived PlaySpec (with viewKey) is the value of the currentView atom"]
A --> T
T -->|"the selected view composes"| G --> C
T -->|"a view without outlets"| C
C --> D --> E --> F
The composition keeps each element of the outer view by reference, except an element that receives a child view. It prefixes each key of a child view with the id of its state. play-xstate keeps one result of composeViews for each tree of views of each machine. A context assign therefore emits the same elements and viewParts references, and the emit gate of withView compares the top-level fields alone. One walk derives every view: a machine without outlets is its simple case, in which the deepest view wins. When the viewKey stays and the viewParts value changes, createViewStoreLifecycle writes the authored keys that the store does not hold. play-dom then gives the new spec to setSpec of the incremental renderer, which keeps each node whose element did not change.
Each provider reads the view through createViewSession of play-view. The session resolves the store when the actor emits, when the host hands over a new actor or controlled store, and for a retry — never in a render. A provider binds the snapshot of the session to its framework: useState and a layout effect in play-react, a shallowRef in play-vue, a signal in play-solid, $state.raw in play-svelte, and a listener in play-dom. The subtree of the view is keyed on the generation of the store, and the error boundary resets on a new snapshot.
State atoms on the actor:
| Atom | Type | Description |
|---|---|---|
actor.state | Atom<AnyMachineSnapshot> | XState snapshot; the ONE write of a transition, on active states only |
actor.currentRoute | ReadonlyAtom<string | null> | Derived from meta.route + context.params; null when unroutable. It carries compare: () => false, so EVERY transition notifies — a refused guard derives the same path, and the bridge must still hear it |
actor.currentView | ReadonlyAtom<PlaySpec | null> | Derived from meta.view; the previous value gates an emission, so an unchanged view keeps its reference |
Key Abstractions
PlayEvent<TPayload>
The minimal event contract for Actor communication: any object with a readonly type: string property. TPayload extends Record<string, unknown> for additional fields. Framework-agnostic; used directly by XState, router adapters, and domain logic. Part of @xmachines/play.
import type { PlayEvent } from "@xmachines/play";
type LoginEvent = PlayEvent<{ userId: string; timestamp: number }>;PlayError
Base class for the typed runtime errors of every Play package. It extends BaseError of @xmachines/core/errors. Carries scope (throwing class/module) and code (stable machine-readable identifier, e.g. "PLAY_ROUTER_SYNC_FAILED"). Subclassed per package, exported from each package’s ./errors subpath. Never match on .message — always match on .code or the subclass.
import { PlayError } from "@xmachines/play/errors";import { RouterSyncError } from "@xmachines/play-router/errors";
declare const err: unknown;
if (err instanceof RouterSyncError) { /* err.scope, err.code, err.cause */}PlayActor<TSnapshot, TEvent>
The actor contract, as an INTERFACE. It asks for two members: a reactive state: Atom<TSnapshot>, and a typed send(event: TEvent): void. It names no state machine library, so a consumer of the contract installs xstate never. An implementation satisfies it (e.g. PlayerActor).
Routable
Optional capability interface. Exposes currentRoute: ReadonlyAtom<string | null> (derived URL path) and readonly initialRoute: string | null (machine’s initial route, fixed at definition time). Router bridges consume this interface directly — they never depend on the concrete PlayerActor.
Viewable
Optional capability interface. Exposes currentView: ReadonlyAtom<PlaySpec | null>. View renderers (PlayRenderer) consume this contract to resolve the current view description into UI without coupling to the framework or actor implementation.
PlaySpec
Extends @xmachines/json-render-core Spec. deriveCurrentView projects the machine’s whole context into the derived spec’s state under the read-only /context subtree, and stamps a viewKey from the selected meta entry so providers can reseed vs refresh their store. An authored outlets field composes the views of the active substates into the view of their parent, and the composed spec also carries viewParts. Specs read context through the ordinary { $state: "/context/…" } grammar; element props are never enriched. typedSpec() type-checks the spec literal at the definition site (XState’s meta is untyped).
PlayerActor<TMachine>
Concrete XState v5 actor. It extends XState’s own Actor directly, and it implements PlayActor. It receives the machine in its constructor, so an instance IS the actor rather than a wrapper around one. Bridges its own subscription to atoms. Exposes: state, send(), start(), stop(), can().
Routable and Viewable are OPTIONAL capabilities, and the class implements neither. Each one is a mixin behind its own entry point — @xmachines/play-xstate/routing and @xmachines/play-xstate/view — and compose builds the class that an application needs:
import type { AnyStateMachine } from "xstate";import { compose, definePlayer, PlayerActor } from "@xmachines/play-xstate";import { withRouting } from "@xmachines/play-xstate/routing";import { withView } from "@xmachines/play-xstate/view";
declare const machine: AnyStateMachine;
definePlayer({ machine, actor: compose(PlayerActor, withRouting, withView) });The core imports neither capability, so an application that declares no route loads no routing code, and one that renders no view loads no view code and installs no json-render.
The composition order decides nothing. PlayerActor writes ONE atom for each transition — state — and every capability that publishes a value DERIVES it with a computed atom over that one. The engine evaluates currentRoute and currentView from a single write, in topological order, so a router bridge sees a guard redirect and a renderer sees the view of the same snapshot whatever order compose applied.
A capability that must run an EFFECT on a transition overrides onSnapshot, and the override calls super.onSnapshot() first.
definePlayer(config) → PlayerFactory
Factory creator. Each call constructs one PlayerActor, which is itself the XState actor and derives its own initialRoute from its pre-start snapshot. Restoring a snapshot is the exception: the default initial route can only come from XState’s pure initialTransition helper, whose inert actor scope constructs a throwaway actor and evaluates the initial transition twice. Returns (input?, restore?) => PlayerActor<TMachine>.
import type { AnyStateMachine } from "xstate";import { definePlayer, type PlayerOptions } from "@xmachines/play-xstate";
declare const machine: AnyStateMachine;declare const options: PlayerOptions<AnyStateMachine>;
const createPlayer = definePlayer({ machine, options });const actor = createPlayer(); // or createPlayer(input) or createPlayer(undefined, { snapshot })actor.start();RouterBridgeBase
Abstract base class capturing all common router bridge logic. Template Method pattern — subclasses implement exactly three abstract methods: navigateRouter(path), watchRouterChanges(), unwatchRouterChanges(). Manages isConnected, lastSyncedPath (echo suppression), isProcessingNavigation (re-entrant guard redirect prevention), and routeWatcher lifecycle. Enforces one-bridge-per-actor via a module-level WeakMap.
import { RouterBridgeBase } from "@xmachines/play-router";
class MyRouterBridge extends RouterBridgeBase { protected navigateRouter(path: string): void { /* push URL */ } protected watchRouterChanges(): void { /* subscribe to router */ } protected unwatchRouterChanges(): void { /* unsubscribe */ }}RouteMap / createRouteMap
Bidirectional stateId ↔ URL path lookup. createRouteMap(machine) of @xmachines/play-router/xstate → RouteMap. createRouteMapFromTree(routeTree) of @xmachines/play-router takes the output of extractMachineRoutes(machine) directly. Both drop the synthetic root node of the tree, so neither map answers a state ID that the machine does not hold. The match of a parameterized route, for example /profile/:userId, uses URLPattern of @xmachines/play-url.
PlayRouteEvent
Unified routing event sent by router adapters to the Actor.
interface PlayRouteEvent { readonly type: "play.route"; readonly to: string; // e.g. "#home", "#profile" readonly params?: Record<string, string>; // URL path params, e.g. { userId: "123" } readonly query?: Record<string, string>; // Query string params readonly match?: unknown; // URLPatternResult (optional, for debugging)}watchAtom(atom, callback)
Subscribe to a single atom with microtask coalescing and memory-safe cleanup. It builds one subscribe for the life of the watch, so no code arms anything again. The disposed flag guards post-cleanup callbacks, and the scheduled guard deduplicates rapid synchronous atom changes. Returns a plain release function.
formatPlayRouteTransitions(machineConfig)
Crawls machine state configs looking for states with meta.route and auto-generates play.route transition handlers at the root machine level. Each generated transition targets the matching state, guards on event.to === "#stateId", and assigns event.params and event.query to context. Returns the same config type T — directly usable by setup().createMachine().
View Renderer Pattern
All five view renderer packages follow an identical structural pattern:
| Component | Role |
|---|---|
ActorProvider | Subscribes to the actor.currentView atom; owns the atom lifecycle; provides view context |
PlayUIProvider | Convenience wrapper combining ActorProvider + framework’s JSONUIProvider |
PlayRenderer | Zero-prop leaf component; reads from context; delegates to @xmachines/json-render-* Renderer |
useActor() | Hook/composable for accessing the raw actor in consuming components |
Framework-specific atom bridging:
| Package | Atom → Render mechanism |
|---|---|
@xmachines/play-react | useAtomEffect → useReducer force-update |
@xmachines/play-vue | Vue reactivity bridge via watchEffect equivalent |
@xmachines/play-solid | SolidJS native reactive integration |
@xmachines/play-svelte | Svelte 5 runes ($effect) via actor-context.svelte.ts |
@xmachines/play-dom | Manual DOM updates via createPlayUI / createRenderer / PlayRenderer |
Per-view component state is managed by @xstate/store: a fresh store atom is created per PlaySpec transition (uncontrolled mode), or an external store prop is passed via ActorProvider/PlayUIProvider (controlled mode).
Router Adapter Pattern
All eight router adapter packages follow the Template Method pattern via RouterBridgeBase. Each adapter implements exactly three abstract methods:
// How to tell the framework router to change URLprotected abstract navigateRouter(path: string): void;
// How to subscribe to router location change eventsprotected abstract watchRouterChanges(): void;
// How to unsubscribe from router location change eventsprotected abstract unwatchRouterChanges(): void;| Package | Bridge class |
|---|---|
@xmachines/play-tanstack-react-router | TanStackReactRouterBridge |
@xmachines/play-react-router | ReactRouterBridge |
@xmachines/play-vue-router | VueRouterBridge |
@xmachines/play-solid-router | SolidRouterBridge |
@xmachines/play-tanstack-solid-router | TanStackSolidRouterBridge |
@xmachines/play-sveltekit-router | SvelteKitRouterBridge |
@xmachines/play-svelte-spa-router | SvelteSpaRouterBridge |
@xmachines/play-dom-router | DomRouterBridge |
Each adapter also exports a framework-integrated provider component (e.g. PlayRouterProvider) and a createRouteMapFrom* factory appropriate for its router’s route definition format.
Directory Structure
packages/├── shared/ # Zero-dependency shared configs (tsconfig, oxlint, oxfmt, vitest)├── core/ # Layer 0: BaseError, the release protocol, the cache, helpers; no module state├── play/ # Layer 1: Core protocol: PlayEvent, PlayError, assertNonNullable├── play-atom/ # Layer 0: the atom primitives — all atom imports go here│ └── src/ # Declares the atom contract, binds the engine; watchAtom utility├── play-actor/ # Layer 1: the PlayActor contract — state and send, and nothing else├── play-pattern/ # Layer 1: the route pattern language — one parse, and the path it builds back├── play-url/ # Layer 2: the URL layer — the URLPattern API, a base path, framework params├── play-view/ # Layer 2: Viewable, PlaySpec, the view session, the context projection│├── play-xstate/ # Layer 4: Concrete XState v5 actor: definePlayer, PlayerActor│ └── src/│ ├── player-actor.ts # PlayerActor — concrete actor│ ├── define-player.ts # definePlayer factory│ ├── routing/ # deriveRoute, buildRouteUrl, formatPlayRouteTransitions│ └── view/ # deriveCurrentView (meta.view → PlaySpec)│├── play-router/ # Layer 3: Route extraction, bidirectional mapping, bridge base│ └── src/│ ├── router-bridge-base.ts # RouterBridgeBase (Template Method for all adapters)│ ├── base-route-map.ts # RouteMap (bidirectional stateId ↔ path)│ ├── xstate/ # the machine half — extractMachineRoutes, createRouteMap│ ├── router-sync.ts # buildPlayRouteEvent, extractRouteParams, sanitizePathname│ └── types.ts # PlayRouteEvent, RouterBridge, RouteTree, WindowLike...│├── play-react/ # View renderer: React (ActorProvider, PlayRenderer)├── play-vue/ # View renderer: Vue 3├── play-solid/ # View renderer: SolidJS├── play-svelte/ # View renderer: Svelte 5├── play-dom/ # View renderer: Vanilla DOM│├── play-tanstack-react-router/ # Router adapter: TanStack Router (React)├── play-tanstack-solid-router/ # Router adapter: TanStack Router (SolidJS)├── play-react-router/ # Router adapter: React Router 7/8├── play-vue-router/ # Router adapter: Vue Router 4/5├── play-solid-router/ # Router adapter: SolidJS Router├── play-svelte-spa-router/ # Router adapter: Svelte SPA Router├── play-sveltekit-router/ # Router adapter: SvelteKit├── play-dom-router/ # Router adapter: Vanilla DOM (History API)│└── docs/ # @xmachines/docs — API docs (auto-generated) + RFC specifications └── rfc/ # Living RFC documents (play.md, streams.md, etc.)Each package follows the same internal structure:
packages/<name>/├── src/│ ├── index.ts # Single public barrel — only file consumers import│ └── *.ts / *.tsx # Implementation files (kebab-case)├── test/ # Test files (*.spec.ts, *.test.ts)├── dist/ # Build output (gitignored)├── examples/ # Runnable demo apps (present in some packages)├── package.json # "type": "module"; exports only via "./dist/index.js"└── tsconfig.json # Extends @xmachines/shared/tsconfig; composite: trueTypeScript Build Graph
The monorepo uses TypeScript composite project references ("composite": true per package) for a correct, incremental, dependency-ordered build. The root tsconfig.json lists every package in layer order — leaves first, dependents last — and it is the ONLY build order of the workspace. tsc --build at the root reads that array and resolves the graph.
The layer table and the diagram live in development.md.
Two facts about the graph belong to this document, because they are architecture and not build order:
- The routers read NO view package.
play-routerand the eight adapters nameplay-viewand the five renderers in no manifest. A consumer that routes and renders nothing of its own therefore installs no json-render. - The renderers read no router. The two halves meet in the application alone, which is INV-02.
With declarationMap: true in the base tsconfig (from @xmachines/shared/tsconfig), IDE “Go to Definition” jumps to TypeScript source files rather than compiled .d.ts files, and refactoring works correctly across package boundaries without requiring a build step.
Dependency rules:
The protocol layer is play, play-atom and play-actor. play reads core alone, for the base of PlayError. play-atom reads no workspace package. play-actor reads play-atom, and no other package of the workspace.
| Layer | May import from | Must not import from |
|---|---|---|
Core (core) | No package | Any @xmachines/* package |
Protocol (play) | core | Any other @xmachines/* package |
Protocol (play-atom) | @xstate/store | Any other @xmachines/* package |
Protocol (play-actor) | play-atom | Any other @xmachines/* package |
Pattern language (play-pattern) | core | Any other @xmachines/* package |
URL layer (play-url) | Protocol layer, play-pattern, urlpattern-polyfill | Any other @xmachines/* package |
View half (play-view) | Protocol layer, @xmachines/json-render-core | Router adapters, framework code |
Actor logic (play-xstate) | Protocol layer, play-pattern, xstate | View renderers, router adapters |
Router infrastructure (play-router) | Protocol layer, play-url, @statelyai/graph | View renderers |
Router adapters (play-*-router) | play-router, protocol layer, framework router lib | View renderers |
View renderers (play-*) | Protocol layer, play-view, @xmachines/json-render-* | Router adapters |
Every layer above the protocol layer may also import @xmachines/core, and play imports it for BaseError. It is a peer, and not a dependency: err instanceof BaseError answers false for an error of a second copy, so an install must hold one copy. See the packages that must exist one time.
play-view and play-router are OPTIONAL peer dependencies of play-xstate, because the two capability subpaths carry them: @xmachines/play-xstate/view names the first and @xmachines/play-xstate/routing names the second. The core entry point imports neither.
Error Handling
All @xmachines/* errors extend BaseError of @xmachines/core/errors, and carry two stable fields:
scope— the class or module that threw (e.g."RouterBridgeBase")code— a stable machine-readable identifier (e.g."PLAY_ROUTER_SYNC_FAILED")
Every error of a Play package extends PlayError, which extends BaseError. A package below the Play protocol extends BaseError directly: @xmachines/core throws DisposeConflictError when asDisposable receives a named release that the scope would never run.
Error hierarchy:
flowchart TD
BE["BaseError\n@xmachines/core/errors"]
BE --> DCE["DisposeConflictError\nCORE_DISPOSE_CONFLICT"]
BE --> PE["PlayError\n@xmachines/play/errors"]
PE --> NNE["NonNullableError\nPLAY_NON_NULLABLE"]
PE --> RSE["RouterSyncError\nPLAY_ROUTER_SYNC_FAILED"]
PE --> DBE["DuplicateBridgeError\nPLAY_ROUTER_DUPLICATE_BRIDGE"]
PE --> IEE["InvalidEventError\nPLAY_XSTATE_INVALID_EVENT"]
PE --> IME["InvalidMachineError\nPLAY_XSTATE_INVALID_MACHINE"]
PE --> MRP["MissingRouteParamError\nPLAY_XSTATE_ROUTE_PARAM_MISSING"]
PE --> IRP["InvalidRouteParamError\nPLAY_XSTATE_ROUTE_PARAM_INVALID"]
PE --> MSI["MissingStateIdError\nPLAY_XSTATE_MISSING_STATE_ID"]
PE --> IRE["InvalidRouteMetadataError\nPLAY_XSTATE_INVALID_ROUTE_METADATA"]
PE --> ATN["ActorThrewNonErrorError\nPLAY_XSTATE_NON_ERROR_THROWN"]
Package-specific errors are exported from ./errors subpath imports:
import { PlayError } from "@xmachines/play/errors";import { RouterSyncError } from "@xmachines/play-router/errors";import { InvalidEventError } from "@xmachines/play-xstate/errors";Error behavior by case:
| Error | Package | Behavior |
|---|---|---|
MissingRouteParamError | play-xstate | Transient — currentRoute returns null; does not throw |
InvalidRouteParamError | play-xstate | Permanent — deriveCurrentRoute passes it on: currentRoute reports it through onError and keeps its last route, and initialRoute throws from the constructor |
ActorThrewNonErrorError | play-xstate | Constructed for onError when the machine throws a non-Error; original value on cause |
RouterSyncError | play-router | Wraps router sync failures |
DuplicateBridgeError | play-router | Two bridges registered for the same actor |
| View errors | play-xstate | Caught in the currentView atom; forwarded to onError hook; last valid view retained |
Application Bootstrap (React example)
import { createRootRoute, createRouter } from "@tanstack/react-router";import { setup } from "xstate";import { definePlayer, compose, PlayerActor } from "@xmachines/play-xstate";import { formatPlayRouteTransitions, withRouting } from "@xmachines/play-xstate/routing";import { withView } from "@xmachines/play-xstate/view";import { extractMachineRoutes } from "@xmachines/play-router/xstate";import { createRouteMapFromTree, PlayRouterProvider } from "@xmachines/play-tanstack-react-router";import { PlayUIProvider, PlayRenderer, type PlayUIProviderProps } from "@xmachines/play-react";
// 1. Define machine — meta.route declares virtual routes; meta.view declares UI specsconst machineConfig = formatPlayRouteTransitions({ id: "app", initial: "home", context: { params: {}, query: {} }, states: { home: { id: "home", meta: { route: "/", view: { root: "home", elements: { home: { type: "HomePage", props: {}, children: [] } }, }, }, }, profile: { id: "profile", meta: { route: "/profile/:userId", view: { root: "profile", elements: { profile: { type: "ProfilePage", props: { userId: undefined }, children: [], }, }, }, }, }, },});
const machine = setup({}).createMachine(machineConfig);
// 2. Build route map from machine definition (done once at startup)const routeTree = extractMachineRoutes(machine);const routeMap = createRouteMapFromTree(routeTree);
// 3. Create player factory and start actor — the providers need BOTH capabilitiesconst createPlayer = definePlayer({ machine, actor: compose(PlayerActor, withRouting, withView) });const actor = createPlayer();actor.start();
// 4. Create the framework router. The machine owns the routes, so the route tree is one root routeconst router = createRouter({ routeTree: createRootRoute() });
// 5. Define component registry for json-render: defineRegistry(catalog, { components })declare const registryResult: PlayUIProviderProps["registryResult"];
// 6. Render — the router provider wires actor → router, and its renderer callback renders the viewfunction App() { return ( <PlayRouterProvider actor={actor} router={router} routeMap={routeMap} renderer={(currentActor) => ( <PlayUIProvider actor={currentActor} registryResult={registryResult}> <PlayRenderer /> </PlayUIProvider> )} /> );}Anti-Patterns
Importing @xstate/store directly
// ❌ Wrong — bypasses the atom isolation layer, and names the engine in your own .d.tsimport { createAtom } from "@xstate/store";
// ✅ Correct — all atom imports go through play-atomimport { createAtom } from "@xmachines/play-atom";Using framework state for business logic
// ❌ Wrong — violates INV-03 (Atom-Only Reactivity) and INV-04 (Passive Infrastructure)const [currentUser, setCurrentUser] = useState(null);
// ✅ Correct — observe the actor atom directly; React state only triggers the renderconst [currentUser, setCurrentUser] = useState(actor.state.get().context.currentUser);useAtomEffect([actor.state], () => setCurrentUser(actor.state.get().context.currentUser));Calling navigateRouter inside watchRouterChanges
Calling navigateRouter() before updating lastSyncedPath inside the watchRouterChanges callback causes an echo loop. The router navigation fires the watcher again, which sends a duplicate play.route event. Always update lastSyncedPath before calling navigateRouter() — see RouterBridgeBase source at packages/play-router/src/router-bridge-base.ts.
Skipping sanitizePathname in custom bridge implementations
Custom watchRouterChanges() implementations that bypass syncActorFromRouter() and process pathnames directly skip the length/content guards that protect the route-map lookup from malformed or oversized paths. Always call sanitizePathname(path) before processing.
Cross-Cutting Concerns
Circular update prevention: lastSyncedPath in RouterBridgeBase provides echo suppression for the actor→router direction; isProcessingNavigation flag guards against re-entrant guard-redirect loops in syncActorFromRouter. scheduled in watchAtom deduplicates rapid synchronous atom changes via microtask coalescing.
SSR / test injection: WindowLike and LocationLike structural interfaces in @xmachines/play-router allow injecting mock objects in router adapters, enabling testing without a real browser environment.
Logging: No structured logging framework. PlayerOptions exposes lifecycle hooks — onStart, onStop, onTransition, onStateChange, onError — as observability extension points. A renderer writes to console.error in ONE case: it contained a failure, and no handler of the host receives that failure. Such a failure is the one result that nobody can debug. Give onError to send the failure of a view to your own observability tool instead.
Five paths reach that case. The host gives no onError prop. The handler of the host throws, or the producer of the fallback throws, or a release of the caller throws — each of those reports the failure of the HOST, which no handler of the host receives. ActorProvider of play-vue stops a second report of one error, and onError stays silent there on purpose, because the four other renderers see such an error never.
Three other places write to console.warn, and each one reports a configuration that the library cannot refuse: warnOnce of @xmachines/play-view names a /context projection that it cannot derive, one time for each spec; @xmachines/play-xstate names a meta.view.contextProps field, which the view does not read, one time for each spec; and @xmachines/play-router names a basePath that a bridge with no mount ignores. A warning is the answer where a throw would stop an application for a fault that it can still run with. No other library source calls console.*.
Authentication: Not in scope. Enforced via XState guards on play.route transitions. The guard evaluates machine context (e.g., isLoggedIn) before permitting navigation. No library-level auth primitives — the machine defines all access control.
Atom isolation: All atom imports go through @xmachines/play-atom, which re-exports the atom of @xstate/store. This isolates the codebase from churn in that library — a version upgrade or API change requires editing only one package.
Global state: An activeBridges WeakMap at module level in packages/play-router/src/router-bridge-base.ts prevents duplicate bridges per actor. This is the only module-level mutable state in the library.
Threading: Single-threaded event loop. No worker threads. XState actor subscription callbacks fire synchronously in send(). Atom updates are synchronous to ensure router bridges see guard redirects immediately.
See Play RFC for the authoritative protocol specification.