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 signals 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 TC39 Signals 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 TC39 Signals — 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 | Passive Infrastructure | Infrastructure observes Actor signals, never controls state |
| INV-04 | Signal-Only Reactivity | All Actor→Infrastructure state changes flow through TC39 Signals (Signal.State, Signal.Computed). No subscriptions or event emitters |
| INV-05 | State-Driven Reset | Invalid external navigation is overwritten by Actor-derived state |
Component Diagram
graph TD
subgraph Layer0["Layer 0 — Protocol / Primitives"]
play["@xmachines/play\n(PlayEvent, PlayError)"]
signals["@xmachines/play-signals\n(TC39 Signal, watchSignal)"]
shared["@xmachines/shared\n(tsconfig, oxlint, oxfmt)"]
end
subgraph Layer1["Layer 1 — Abstract Actor Base"]
actor["@xmachines/play-actor\n(AbstractActor, Routable, Viewable, PlaySpec)"]
end
subgraph Layer2["Layer 2 — Concrete Implementations"]
xstate["@xmachines/play-xstate\n(PlayerActor, definePlayer, guards, routing)"]
router["@xmachines/play-router\n(RouterBridgeBase, RouteMap, extractMachineRoutes)"]
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
play --> actor
signals --> actor
actor --> xstate
actor --> router
actor --> Views
signals --> xstate
signals --> router
play --> xstate
play --> router
router --> RouterAdapters
xstate -.->|"play.route events"| router
Component Responsibilities
| Package | Responsibility | Key File |
|---|---|---|
@xmachines/play | Core protocol types and error base class | packages/play/src/index.ts |
@xmachines/play-signals | TC39 Signal polyfill isolation wrapper | packages/play-signals/src/index.ts |
@xmachines/play-actor | AbstractActor base class + capability interfaces | packages/play-actor/src/abstract-actor.ts |
@xmachines/play-xstate | XState v5 adapter: definePlayer, PlayerActor | packages/play-xstate/src/player-actor.ts |
@xmachines/play-router | Route extraction, RouteMap, RouterBridgeBase | packages/play-router/src/router-bridge-base.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-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 v7 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/docs | API docs and RFC documentation | packages/docs/ |
Data Flow
Actor State Change → UI Render
flowchart TD
A["XState machine transition"]
B["PlayerActor xstateActor.subscribe(snapshot)"]
C["StateSignalManager.scheduleUpdate(snapshot)\nactor.state Signal.State updated — synchronous"]
D["actor.currentRoute Signal.Computed recomputes\nderives URL from meta.route + context.params"]
E["validateAndCacheView(snapshot)\nactor.currentView Signal.State updated"]
F["Framework signal watcher fires\nmicrotask"]
G["ActorProvider reads actor.currentView.get()"]
H["PlayRenderer renders via @json-render/* Renderer"]
A --> B --> C --> D --> E --> 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 → signals 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 Signal.Computed changes"]
B["RouterBridgeBase routeWatcher\nTC39 Signal.subtle.Watcher fires"]
C["watchSignal microtask queued\nneedsEnqueue 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 stateId vs actor.initialRoute"}
G["syncActorFromRouter()\nrouter wins — deep-link"]
H["navigateRouter()\nactor wins — restore"]
A --> B --> C --> D --> E --> F
F -->|"URL ≠ machine initial state"| G
F -->|"URL = machine initial state"| H
View Prop Enrichment
flowchart TD
A["XState snapshot.getMeta()"]
B["resolveViewMeta(meta)\nfinds first meta.view with root + elements"]
C["Extract context.params\nURL path params set by formatPlayRouteTransitions assign"]
D["Extract contextProps allowlist\nexplicit opt-in from PlaySpec.contextProps"]
E["mergeRouteParamsIntoProps()\npriority: spec prop > URL param > contextProps value"]
F["Enriched PlaySpec set on currentView signal"]
A --> B --> C --> D --> E --> F
State signals on the actor:
| Signal | Type | Description |
|---|---|---|
actor.state | Signal.State<AnyMachineSnapshot> | XState snapshot; updated synchronously on active states only |
actor.currentRoute | Signal.Computed<string | null> | Derived from meta.route + context.params; null when unroutable |
actor.currentView | Signal.State<PlaySpec | null> | Set explicitly per-transition (not computed) to ensure watcher notifications on self-transitions |
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 all @xmachines/* typed runtime 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";import { RouterSyncError } from "@xmachines/play-router/errors";
if (err instanceof RouterSyncError) { /* err.scope, err.code, err.cause */}AbstractActor<TLogic, TEvent>
Abstract base class extending XState Actor. Declares public abstract state: Signal.State<unknown> and public abstract override send(event: TEvent): void. Maintains XState ecosystem compatibility (devtools, inspection) while enforcing the signal protocol. Concrete implementations extend this (e.g. PlayerActor).
Routable
Optional capability interface. Exposes currentRoute: Signal.Computed<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: Signal.State<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 @json-render/core Spec with readonly contextProps?: readonly string[] — an explicit allowlist of machine context fields that deriveCurrentView merges into element props. Only fields named here are ever exposed to components. typedSpec<TContext>() provides compile-time validation of contextProps entries against the machine’s context type.
PlayerActor<TMachine>
Concrete XState v5 actor implementing the full signal protocol. Extends AbstractActor; implements both Routable and Viewable. Wraps an internal xstate.Actor and bridges its subscription to TC39 Signals via StateSignalManager. Exposes: state, currentRoute, currentView, initialRoute, send(), start(), stop(), can(), dispose().
definePlayer(config) → PlayerFactory
Factory creator. Pre-computes initialRoute once from the machine’s initial state (zero extra actor instantiation per factory call). Returns (input?, restore?) => PlayerActor<TMachine>.
import { definePlayer } from "@xmachines/play-xstate";
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.
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) → RouteMap. createRouteMapFromTree(routeTree) takes the output of extractMachineRoutes(machine) directly. Internally uses URLPattern for parameterized route matching (e.g. /profile/:userId).
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)}watchSignal(signal, callback)
Subscribe to a single TC39 signal with microtask batching and memory-safe cleanup. Uses a one-shot Signal.subtle.Watcher lifecycle — re-arms after each notification. disposed flag guards post-cleanup callbacks; needsEnqueue deduplicates rapid synchronous signal changes. Returns a () => void cleanup 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 actor.currentView signal; owns signal lifecycle; provides view context |
PlayUIProvider | Convenience wrapper combining ActorProvider + framework’s JSONUIProvider |
PlayRenderer | Zero-prop leaf component; reads from context; delegates to @json-render/* Renderer |
useActor() | Hook/composable for accessing the raw actor in consuming components |
Framework-specific signal bridging:
| Package | Signal → Render mechanism |
|---|---|
@xmachines/play-react | useSignalEffect → 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)├── play/ # Layer 0: Core protocol: PlayEvent, PlayError, assertNonNullable├── play-signals/ # Layer 0: TC39 Signal polyfill wrapper — all signal imports go here│ └── src/ # Re-exports signal-polyfill; watchSignal utility├── play-actor/ # Layer 1: AbstractActor base + Routable, Viewable, PlaySpec interfaces│├── play-xstate/ # Layer 2: Concrete XState v5 actor: definePlayer, PlayerActor│ └── src/│ ├── player-actor.ts # PlayerActor — concrete actor│ ├── define-player.ts # definePlayer factory│ ├── guards/ # composeGuards, composeGuardsOr, negateGuard, hasContext...│ ├── routing/ # deriveRoute, buildRouteUrl, formatPlayRouteTransitions│ └── signals/ # StateSignalManager (XState → TC39 Signal bridge)│├── play-router/ # Layer 2: Route extraction, bidirectional mapping, bridge base│ └── src/│ ├── router-bridge-base.ts # RouterBridgeBase (Template Method for all adapters)│ ├── extract-routes.ts # extractMachineRoutes → RouteTree│ ├── base-route-map.ts # RouteMap (bidirectional stateId ↔ path)│ ├── create-route-map.ts # createRouteMap factory (URLPattern-based)│ ├── 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 v7├── 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 all packages in layer order — leaves first, dependents last. tsc --build at the root resolves the full dependency graph automatically.
flowchart LR
subgraph L0["Layer 0 — no internal deps"]
ps[play-signals]
p[play]
d[docs]
end
subgraph L1["Layer 1 — depends on L0"]
pa[play-actor]
end
subgraph L2["Layer 2 — depends on L0 + L1"]
pr[play-router]
pdr[play-dom-router]
psk[play-sveltekit-router]
px[play-xstate]
prea[play-react]
pv[play-vue]
pso[play-solid]
psv[play-svelte]
pdo[play-dom]
ptsr[play-tanstack-react-router]
pvr[play-vue-router]
psor[play-solid-router]
pssr[play-svelte-spa-router]
ptss[play-tanstack-solid-router]
end
subgraph L3["Layer 3 — examples / play-react-router"]
ex["play-react-router\nplay-*/examples/demo"]
end
L0 --> L1 --> L2 --> L3
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:
| Layer | May import from | Must not import from |
|---|---|---|
Protocol (play, play-signals, play-actor) | External libs only | Any other @xmachines/* package |
Actor logic (play-xstate) | Protocol layer | View renderers, router adapters |
Router infrastructure (play-router) | Protocol layer, @statelyai/graph | View renderers |
Router adapters (play-*-router) | play-router, protocol layer, framework router lib | Other view renderers |
View renderers (play-*) | Protocol layer, @json-render/* | Router adapters |
Error Handling
All @xmachines/* errors extend PlayError 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")
Error hierarchy:
flowchart TD
PE["PlayError\n@xmachines/play"]
PE --> NNE["NonNullableError\nPLAY_NON_NULLABLE"]
PE --> RSE["RouterSyncError\nPLAY_ROUTER_SYNC_FAILED"]
PE --> UPE["URLPatternUnavailableError\nPLAY_ROUTE_MAP_URLPATTERN_UNAVAILABLE"]
PE --> DBE["DuplicateBridgeError\nPLAY_ROUTER_DUPLICATE_BRIDGE"]
PE --> IEE["InvalidEventError\nPLAY_INVALID_EVENT"]
PE --> IME["InvalidMachineError\nPLAY_INVALID_MACHINE"]
PE --> MRP["MissingRouteParamError\nPLAY_ROUTE_PARAM_MISSING"]
PE --> MSI["MissingStateIdError\nPLAY_MISSING_STATE_ID"]
PE --> MQC["MissingQueryContextError\nPLAY_MISSING_QUERY_CONTEXT"]
PE --> IRE["InvalidRouteMetadataError\nPLAY_INVALID_ROUTE_METADATA"]
Package-specific errors are exported from ./errors subpath imports:
import { PlayError } from "@xmachines/play";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 |
MissingQueryContextError | play-xstate | Structural programmer error — always re-throws |
RouterSyncError | play-router | Wraps router sync failures |
DuplicateBridgeError | play-router | Two bridges registered for the same actor |
URLPatternUnavailableError | play-router | URLPattern API missing — load urlpattern-polyfill |
| View errors | play-xstate | Caught in validateAndCacheView(); forwarded to onError hook; last valid view retained |
Application Bootstrap (React example)
import { setup } from "xstate";import { definePlayer, formatPlayRouteTransitions } from "@xmachines/play-xstate";import { extractMachineRoutes } from "@xmachines/play-router";import { createRouteMapFromTree, PlayRouterProvider } from "@xmachines/play-tanstack-react-router";import { PlayUIProvider, PlayRenderer, defineRegistry } 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 actorconst createPlayer = definePlayer({ machine });const actor = createPlayer();actor.start();
// 4. Define component registry for json-renderconst registryResult = defineRegistry({ /* components */ });
// 5. Render — framework providers wire actor → router → viewfunction App() { return ( <PlayRouterProvider actor={actor} router={router} routeMap={routeMap}> <PlayUIProvider actor={actor} registryResult={registryResult}> <PlayRenderer /> </PlayUIProvider> </PlayRouterProvider> );}Anti-Patterns
Importing signal-polyfill directly
// ❌ Wrong — bypasses the TC39 Signal isolation layerimport { Signal } from "signal-polyfill";
// ✅ Correct — all signal imports go through play-signalsimport { Signal } from "@xmachines/play-signals";Using framework state for business logic
// ❌ Wrong — violates INV-04 (Signal-Only Reactivity) and INV-02 (Passive Infrastructure)const [currentUser, setCurrentUser] = useState(null);
// ✅ Correct — observe the actor signal directlyconst currentUser = useActorSelector(actor, (s) => s.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. needsEnqueue in watchSignal deduplicates rapid synchronous signal changes via microtask batching.
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. No console.* calls in library source code.
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.
TC39 Signal isolation: All signal imports go through @xmachines/play-signals, which re-exports the signal-polyfill reference implementation. This isolates the codebase from Stage-1 API churn — a polyfill 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(). Signal updates are synchronous to ensure router bridges see guard redirects immediately.
See Play RFC for the authoritative protocol specification.