Skip to content

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

IDNameRule
INV-01Actor AuthorityInfrastructure proposes intents via play.route events; Actor decides via XState guards
INV-02Strict SeparationNo direct dependencies between Actor and Infrastructure layers. play-xstate never imports UI frameworks or routing libraries
INV-03Passive InfrastructureInfrastructure observes Actor signals, never controls state
INV-04Signal-Only ReactivityAll Actor→Infrastructure state changes flow through TC39 Signals (Signal.State, Signal.Computed). No subscriptions or event emitters
INV-05State-Driven ResetInvalid 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

PackageResponsibilityKey File
@xmachines/playCore protocol types and error base classpackages/play/src/index.ts
@xmachines/play-signalsTC39 Signal polyfill isolation wrapperpackages/play-signals/src/index.ts
@xmachines/play-actorAbstractActor base class + capability interfacespackages/play-actor/src/abstract-actor.ts
@xmachines/play-xstateXState v5 adapter: definePlayer, PlayerActorpackages/play-xstate/src/player-actor.ts
@xmachines/play-routerRoute extraction, RouteMap, RouterBridgeBasepackages/play-router/src/router-bridge-base.ts
@xmachines/play-reactReact renderer: ActorProvider, PlayRendererpackages/play-react/src/ActorProvider.tsx
@xmachines/play-vueVue 3 renderer: ActorProvider.vue, PlayRenderer.vuepackages/play-vue/src/ActorProvider.vue
@xmachines/play-solidSolidJS rendererpackages/play-solid/src/ActorProvider.tsx
@xmachines/play-svelteSvelte 5 rendererpackages/play-svelte/src/ActorProvider.svelte
@xmachines/play-domVanilla DOM rendererpackages/play-dom/src/create-play-ui.ts
@xmachines/play-tanstack-react-routerTanStack React Router bridgepackages/play-tanstack-react-router/src/tanstack-router-bridge.ts
@xmachines/play-tanstack-solid-routerTanStack SolidJS Router bridgepackages/play-tanstack-solid-router/src/solid-router-bridge.ts
@xmachines/play-vue-routerVue Router 4/5 bridgepackages/play-vue-router/src/vue-router-bridge.ts
@xmachines/play-react-routerReact Router v7 bridgepackages/play-react-router/src/react-router-bridge.ts
@xmachines/play-solid-routerSolidJS Router bridgepackages/play-solid-router/src/solid-router-bridge.ts
@xmachines/play-dom-routerVanilla DOM hash/history router bridgepackages/play-dom-router/src/dom-router-bridge.ts
@xmachines/play-sveltekit-routerSvelteKit router bridgepackages/play-sveltekit-router/src/sveltekit-router-bridge.ts
@xmachines/play-svelte-spa-routerSvelte SPA Router bridgepackages/play-svelte-spa-router/src/svelte-spa-router-bridge.ts
@xmachines/sharedShared configs: tsconfig, oxlint, oxfmt, vitestpackages/shared/config/
@xmachines/docsAPI docs and RFC documentationpackages/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
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:

SignalTypeDescription
actor.stateSignal.State<AnyMachineSnapshot>XState snapshot; updated synchronously on active states only
actor.currentRouteSignal.Computed<string | null>Derived from meta.route + context.params; null when unroutable
actor.currentViewSignal.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:

ComponentRole
ActorProviderSubscribes to actor.currentView signal; owns signal lifecycle; provides view context
PlayUIProviderConvenience wrapper combining ActorProvider + framework’s JSONUIProvider
PlayRendererZero-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:

PackageSignal → Render mechanism
@xmachines/play-reactuseSignalEffectuseReducer force-update
@xmachines/play-vueVue reactivity bridge via watchEffect equivalent
@xmachines/play-solidSolidJS native reactive integration
@xmachines/play-svelteSvelte 5 runes ($effect) via actor-context.svelte.ts
@xmachines/play-domManual 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 URL
protected abstract navigateRouter(path: string): void;
// How to subscribe to router location change events
protected abstract watchRouterChanges(): void;
// How to unsubscribe from router location change events
protected abstract unwatchRouterChanges(): void;
PackageBridge class
@xmachines/play-tanstack-react-routerTanStackReactRouterBridge
@xmachines/play-react-routerReactRouterBridge
@xmachines/play-vue-routerVueRouterBridge
@xmachines/play-solid-routerSolidRouterBridge
@xmachines/play-tanstack-solid-routerTanStackSolidRouterBridge
@xmachines/play-sveltekit-routerSvelteKitRouterBridge
@xmachines/play-svelte-spa-routerSvelteSpaRouterBridge
@xmachines/play-dom-routerDomRouterBridge

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: true

TypeScript 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:

LayerMay import fromMust not import from
Protocol (play, play-signals, play-actor)External libs onlyAny other @xmachines/* package
Actor logic (play-xstate)Protocol layerView renderers, router adapters
Router infrastructure (play-router)Protocol layer, @statelyai/graphView renderers
Router adapters (play-*-router)play-router, protocol layer, framework router libOther 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:

ErrorPackageBehavior
MissingRouteParamErrorplay-xstateTransient — currentRoute returns null; does not throw
MissingQueryContextErrorplay-xstateStructural programmer error — always re-throws
RouterSyncErrorplay-routerWraps router sync failures
DuplicateBridgeErrorplay-routerTwo bridges registered for the same actor
URLPatternUnavailableErrorplay-routerURLPattern API missing — load urlpattern-polyfill
View errorsplay-xstateCaught 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 specs
const 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
const createPlayer = definePlayer({ machine });
const actor = createPlayer();
actor.start();
// 4. Define component registry for json-render
const registryResult = defineRegistry({ /* components */ });
// 5. Render — framework providers wire actor → router → view
function 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 layer
import { Signal } from "signal-polyfill";
// ✅ Correct — all signal imports go through play-signals
import { 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 directly
const 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.