Skip to content

@xmachines/play-router

API / @xmachines/play-router

Route tree extraction from XState v5 state machines. Part of @xmachines/play Universal Player Architecture.

License: MIT Version

This package extracts the routes from a machine graph and looks them up in both directions. The Actor therefore keeps the authority over the navigation.

Browser floor: Chrome 110, Firefox 115, Safari 16.4. This package calls the ES2023 change-by-copy array methods, so a browser below that floor throws TypeError: ... is not a function at the extraction of the routes. Vite 8 resolves its default baseline-widely-available target to Firefox 114, which is below it — raise build.target when you bundle for the browser. The root README carries the table.

A parameterized route raises Firefox to 117, which is the version that has the URLPattern API. Load the urlpattern-polyfill for an older target, as the Installation section below says.

Installation

Terminal window
pnpm add xstate@^5.31.0
pnpm add @xmachines/play-router

Peer dependencies:

  • xstate ^5.31.0 — XState v5 state machine runtime

URLPattern polyfill (Node.js < 24 / older browsers):

@xmachines/play-router matches each dynamic route with the URLPattern API. URLPattern is native in Node.js 24+ and in a modern browser (Chrome 95+, Firefox 117+, Safari 16.4+).

In an environment without the native API, load a polyfill before you import this package:

// Entry point — must run before any @xmachines/play-router import
import "urlpattern-polyfill";

Install the polyfill:

Terminal window
pnpm add urlpattern-polyfill

urlpattern-polyfill is an optional peer dependency. A package manager does not install it for you. Install it and load it yourself when your runtime has no native URLPattern.

When you need it: RouteMap compiles each parameterized route in its CONSTRUCTOR, and it throws a URLPatternUnavailableError there when no URLPattern is available. One route that holds a :param or a * therefore makes the polyfill a startup requirement on such a runtime. A route map whose paths are all static needs URLPattern never.

Usage

Extract routes from a machine

import { createMachine } from "xstate";
import { extractMachineRoutes, createRouteMap } from "@xmachines/play-router";
const machine = createMachine({
id: "app",
initial: "home",
states: {
home: {
id: "home",
meta: { route: "/" },
},
dashboard: {
id: "dashboard",
meta: { route: "/dashboard" },
initial: "overview",
states: {
overview: {
id: "overview",
meta: { route: "/overview" },
},
settings: {
id: "settings",
meta: { route: "/settings/:section?" }, // optional parameter
},
},
},
profile: {
id: "profile",
meta: { route: "/profile/:userId" }, // required parameter
},
},
});
// Build hierarchical route tree with bidirectional maps
const tree = extractMachineRoutes(machine);
// Path → RouteNode
const node = tree.byPath.get("/dashboard"); // RouteNode for "dashboard"
// State ID → RouteNode
const overview = tree.byStateId.get("overview");
console.log(overview?.fullPath); // "/overview"
// Build a RouteMap for framework adapters
const routeMap = createRouteMap(machine);
routeMap.getStateIdByPath("/profile/123"); // "profile"
routeMap.getPathByStateId("profile"); // "/profile/:userId"

Sending play.route events

import { definePlayer } from "@xmachines/play-xstate";
import type { PlayRouteEvent } from "@xmachines/play-router";
// machine: your routable machine (states carry meta.route)
const actor = definePlayer({ machine })();
actor.start();
// Navigate to a state by ID
const event: PlayRouteEvent = {
type: "play.route",
to: "#dashboard",
};
actor.send(event);
// Navigate with route parameters
actor.send({
type: "play.route",
to: "#profile",
params: { userId: "123" },
});
// Navigate with query parameters
actor.send({
type: "play.route",
to: "#settings",
params: { section: "billing" },
query: { tab: "invoices" },
});

Sharing a router with the host (basePath)

A machine owns the complete URL space of its router by default. Give a basePath, and it owns a prefix only: the host keeps the rest of the same router.

The shape that motivates this option is a URL such as /:machineId/play/dashboard. The host resolves :machineId — in a loader of TanStack, in a useParams() call, in a load function of SvelteKit — and the meta.route tree of the machine gives everything below /:machineId/play.

// The route of the host is /$machineId/play/$ and its loader resolved machineId.
const disconnect = connectRouter({
actor,
router,
routeMap, // unchanged — the route map stays relative to the machine
basePath: "/:machineId/play",
basePathParams: { machineId },
});
// The "/dashboard" of the machine is now the URL "/abc123/play/dashboard".
// The "/" of the machine is now the URL "/abc123/play".

The prefix can be a pattern, so that you keep one string that mirrors your route config, but every :param needs a value in basePathParams. The bridge writes a real browser URL, so it resolves the prefix in advance, and it refuses each shape that resolves to one concrete path never: a * wildcard, a :param?, a $param (the spelling of TanStack — write :param), a query string, and a hash. A base path is a PATHNAME, so it also refuses a URL scheme: give /admin, and never https://app.example.com/admin.

It also refuses every segment that a browser REWRITES, and it refuses the resolved value of a :param on the same rule: a dot segment (., .., and their percent-encoded forms), a backslash, whitespace, and a character that a URL percent-encodes, such as the é of /café. Each of them makes the prefix that comes back differ from the prefix that went out, so every location would read as foreign and the machine would go permanently silent. Give the segment in the form that a URL carries: /caf%C3%A9.

An unresolved :param throws a MissingBasePathParamError, at the construction and not on the first navigation. There is no “not ready yet” fallback, on purpose: a bridge without a prefix would claim the complete router, and it would start to correct the URLs of the host, which is worse than a loud failure. Resolve the value before you connect or render — a route loader or a useParams() call holds it already.

A location outside the prefix belongs to the host. The bridge sends no play.route event there, it runs no corrective navigation there, and it writes no route of its actor there. That silence lets the two halves live together, and it holds in both directions: a route change that no URL event caused — an after timer, an async guard that settles, a restore of a snapshot — would otherwise drag the user off the page of the host. The bridge remembers such a move, and it writes it when the location comes back under the prefix, so the machine keeps its place. Inside the mount nothing changes: a path that the machine does not know is still a 404 of its own URL space, and the URL still follows the actor.

Location of the routerbasePath = "/abc123/play"
/abc123/playthe / of the machine → play.route
/abc123/play/aboutthe /about of the machine → play.route
/abc123/play/nopeunknown INSIDE the mount → the URL follows the actor
/account/billinga route of the host → the bridge does nothing
/abc123/playgroundanother segment → the host’s, the bridge does nothing

The machine is authoritative over its own params. event.params holds what the pattern of the machine declares, and nothing else. The params of the prefix belong to the host — the host wrote the prefix and resolved them — so they travel in no play.route event, and a param of the host that happens to share a name with one of the machine cannot shadow it.

Read the resolved mount from the bridge instead, where it cannot go stale:

bridge.basePath; // "/abc123/play"
bridge.basePathParams; // { machineId: "abc123" }

A machine that needs the identity of its host — a machineId, a tenant — takes it through the input of the actor, where it belongs: that identity decides WHICH machine runs, and it is not a param of a route inside the machine.

Several machines alive at once

More than one machine can be alive at a time, each mounted at its own prefix, all sharing one router. Every bridge hears every location change of that router, and each one recognises its own half and leaves the rest alone — a location under another machine’s prefix is foreign in exactly the way a route of the host is. So a play.route reaches the machine that owns the URL and no other, and a correction of an unknown path happens only inside the prefix that owns it.

Each bridge can stay connected, as long as each one has its own prefix. A bridge outside its mount keeps the silence in BOTH directions: it sends no play.route, it corrects no URL, and it writes no route of its actor. So the machine that the location belongs to is the only one that writes, and an actor that nobody is looking at cannot take the URL from the one on screen. A route that the hidden machine moved to is not lost: the bridge remembers it, and it writes it when the host navigates back under its prefix.

Two bridges contend when neither prefix separates their halves of the URL. That is the case for two bridges with no prefix, for two with the same prefix, and also for two whose prefixes NEST: a mount at /a and a mount at /a/b both claim /a/b/x, because /a is a prefix of it. Give sibling prefixes — /a/one and /a/two — and no location belongs to two machines. Where a prefix cannot separate them, connect the bridge that owns the address bar and let the others keep their state with no bridge attached. connect() refuses a second bridge for one actor, but it cannot know which of two different actors should own the URL.

How to load and unload routes

The prefix lives on the bridge, and not on the RouteMap. A route map is static, it is shared, and it holds an LRU cache inside, so one map serves every mount without a rebuild.

setBasePath() moves WHERE an actor is mounted, and never WHICH actor is mounted:

// The host moved this actor from one region of its URL space to another.
bridge.setBasePath("/:region/:machineId/play", { region: "us", machineId });

The call also brings the location in step. Nothing else moves the address bar, so a move to a prefix that the URL is not under writes the new mount, and it keeps the route of the actor: /eu/abc123/play/about becomes /us/abc123/play/about. A location that already lies under the new prefix drives the actor instead, exactly as it does on connect().

An actor never changes identity. A segment of the prefix that IDENTIFIES the actor — a machineId that names the document it runs — therefore never moves through this method. A new identity is a new ACTOR, and a new actor takes a new bridge, because connect() permits one bridge for each actor. Give the provider the new actor and let it rebuild: that is the correct shape, and it is what the host wants, because the new document starts at its own state.

The segments that move here are the ones that LOCATE: a region, a locale, a tenant, a workspace slug. They say where the same actor lives, and they decide nothing about it.

Nothing goes away: the actor, the route map, and its cache all stay, and the bridge runs the same first-synchronization decision against the new prefix. A call that resolves to the same prefix reconciles nothing, and it still takes the new params, so a move between "/abc123/play" and "/:machineId/play" with { machineId } keeps event.params honest. A call with no argument removes the prefix, and it gives the machine the complete router again.

Every PlayRouterProvider gives basePath and basePathParams as reactive props, wired to setBasePath(). They need no stable reference, unlike actor, router, and routeMap, and they rebuild the bridge never:

// A render with a new `region` moves the mount, and it rebuilds the bridge never. A
// render with a new `machineId` gives a different `actor` prop, and THAT rebuilds the
// bridge, which is correct: one actor takes one bridge.
<PlayRouterProvider
actor={actor}
router={router}
routeMap={routeMap}
basePath="/:machineId/play"
basePathParams={{ machineId }}
renderer={(a) => <PlayRenderer actor={a} registry={registry} />}
/>

To register the routes of the machine in a host router that declares real route objects, ask for the list — and drop it again when the machine unloads:

import { extractMachineRoutes, getRouteMappings } from "@xmachines/play-router";
const tree = extractMachineRoutes(machine);
// Concrete, for a route that the host adds after a loader resolved the mount
getRouteMappings(tree, { basePath: "/:machineId/play", basePathParams: { machineId } });
// [{ stateId: "home", path: "/abc123/play" },
// { stateId: "profile", path: "/abc123/play/profile/:userId" }, ...]
// The pattern, for a static route declaration of the host
getRouteMappings(tree, { basePath: "/:machineId/play" });
// [{ stateId: "home", path: "/:machineId/play" }, ...]

Each stateId comes from the route tree, so it carries NO #. A host that keys its route table on the target of a play.route event adds the # itself, because the event always carries the prefixed form.

Under a mount, @xmachines/play-vue-router and @xmachines/play-solid-router read the pre-parsed route params of their framework never: under a prefix the framework matched a route of the HOST by construction, because the machine owns the suffix of the path only. Those params therefore describe the route of the machine never, even when a name collides — a collision carries the value of the HOST. Both adapters resolve each param from the stripped path with URLPattern instead, and they therefore need a polyfill on an older runtime when they are mounted.

Without a prefix both adapters keep the parse of their framework, with its decoding and with no polyfill, but restricted to the names that the pattern of the machine declares. A splat of a catch-all, and a param of a wrapper route, reach the actor never.

A location that fills NO optional segment reaches URLPattern never. /settings is the bare form of /settings/:section?, so the params are {}, and the adapters read that from the path alone rather than from their framework. The route map that HOLDS that pattern still needed URLPattern when it was built.

How to write a RouterBridgeBase adapter

Extend RouterBridgeBase, then implement the three abstract methods for your framework:

import { RouterBridgeBase, createRouteMap } from "@xmachines/play-router";
import type { RoutableActor } from "@xmachines/play-router";
// Shape of your framework's router — adjust to its real API
type MyRouter = {
navigate(path: string): void;
subscribe(handler: (location: { pathname: string; search: string }) => void): () => void;
state: { location: { pathname: string } };
};
export class MyRouterBridge extends RouterBridgeBase {
private unsubscribe: (() => void) | null = null;
constructor(
private readonly myRouter: MyRouter,
actor: RoutableActor,
routeMap: ReturnType<typeof createRouteMap>,
) {
super(actor, routeMap);
}
// Tell the framework router to navigate to a path
protected navigateRouter(path: string): void {
this.myRouter.navigate(path);
}
// Subscribe to router location changes, call syncActorFromRouter on each
protected watchRouterChanges(): void {
this.unsubscribe = this.myRouter.subscribe((location) => {
this.syncActorFromRouter(location.pathname, location.search);
});
}
// Unsubscribe from router location changes
protected unwatchRouterChanges(): void {
this.unsubscribe?.();
this.unsubscribe = null;
}
// Provide the router's current path for initial deep-link sync
protected override getInitialRouterPath(): string {
return this.myRouter.state.location.pathname;
}
}
// Usage — myRouter: your framework's router instance;
// machine/actor: your routable machine and its started actor
const routeMap = createRouteMap(machine);
const bridge = new MyRouterBridge(myRouter, actor, routeMap);
bridge.connect();
// ...
bridge.disconnect();

API Summary

Route Extraction

ExportDescription
extractMachineRoutes(machine)Converts an XState machine into a RouteTree with the state ID ↔ path maps
createRouteMap(machine, options?)Builds a RouteMap directly from a machine. An adapter uses this form
createRouteMapFromTree(tree, options?)Builds a RouteMap from a RouteTree that you extracted before
buildRouteTree(routes)Builds a RouteTree from an array of RouteInfo objects
machineToGraph(machine)Converts a machine into a typed @statelyai/graph Graph, for a graph algorithm

Route Matching

ExportDescription
RouteMapThe stateId ↔ path lookup class for both directions. It matches an exact path in O(1) and a pattern in O(k)
findRouteById(tree, id)Finds a RouteNode by its state ID
findRouteByPath(tree, path)Finds a RouteNode by its URL path. It also matches a dynamic pattern

Query Utilities

ExportDescription
getRoutableRoutes(tree)Returns every routable RouteNode in one flat array
getNavigableRoutes(tree, stateId)Returns the child routes that a state can reach, through the hierarchy and through a transition
routeExists(tree, path)Tells you if the tree holds a path
getRouteMappings(tree, options?)The { stateId, path } entries for a route table of a host, with an optional prefix
getTransitionReachableRoutes(graph, stateId)Returns the route paths that a state can reach through an XState transition
isRouteReachable(graph, fromStateId, toStateId)Tells you if a transition path is present between two states

Router Bridge

ExportDescription
RouterBridgeBaseThe abstract base class of each framework router adapter. It implements the RouterBridge protocol
sanitizePathname(path)Normalizes a raw pathname. It returns null for a path of more than 2048 characters, and for malformed input
buildPlayRouteEvent(options)Builds a PlayRouteEvent from a pathname and a route-map match result
extractRouteParams(pathname, pattern)Reads the path parameters of a URL with URLPattern
extractQuery(search)Reads the query parameters of a URL search string

Base Path

ExportDescription
BasePathOptions{ basePath?, basePathParams? } — every bridge, provider, and connectRouter takes it
RouterBridgeBase#basePathThe resolved prefix of the mount, or "" when the machine owns the complete router
RouterBridgeBase#basePathParamsThe values of the :param segments of the mount — they travel in no play.route event
RouterBridgeBase#setBasePath(p, prm?)Moves the mount of a live bridge, with no teardown, and brings the location in step
normalizeBasePath(basePath?)Normalizes a prefix, and it keeps each :param segment
resolveBasePath(basePath?, params?)Resolves a prefix to { path, params }, and it substitutes every :param
stripBasePath(pathname, basePath)The machine half of a location, or null when the location belongs to the host
joinBasePath(basePath, path)Adds a prefix to a machine path, and it keeps a query string or a hash at the end
NO_BASE_PATHThe frozen { path: "", params: {} } of a bridge that takes no basePath

Framework Params

A bridge whose framework parses the path params itself — Vue Router and SolidJS Router both do — keeps that parse instead of running URLPattern again. The decision that makes it safe is the same in both, so it lives here.

ExportDescription
resolveFrameworkParams(source)Decides which params describe the route of the machine: {}, the parse of the framework, or the fallback
getPatternParamNames(pattern)The names of every :param of a route pattern. A * wildcard carries none
getRequiredPatternParamNames(pattern)The names the pattern REQUIRES, so an optional :name? is left out
pickOwnParams(params, names, requiredNames?)The params that the pattern declares, or null when the framework covers them not. requiredNames says which names may NOT be absent; it defaults to every name, so a two-argument call treats an optional :name? as a gap
cleanFrameworkParams(params)The params of a framework with no absent value, each one a string

Provider Lifecycle

A PlayRouterProvider of a framework is two things: the lifecycle of a bridge, and about fifteen lines that bind that lifecycle to the effects of the framework. This package holds the lifecycle, and it holds every decision in it. An adapter keeps its own effects and nothing else.

ExportDescription
PlayRouterProviderBaseProps<TRouter, TActor, TNode>The documented props. TNode is what the framework renders
PlayRouterBridgeConstructor<TRouter>The constructor shape a bridge class must satisfy
openProviderBridge(BridgeCtor, args)Builds the bridge, connects it, and returns it with a close
repointProviderBridge(bridge, basePath, params?)Moves the mount of a live bridge. A null bridge and a bridge with no mount API are no-ops
mountKey(basePath, params?)A key that changes when the mount changes, compared BY VALUE
isMountableBridge(bridge)The run-time probe for a bridge that can move its mount
createRouterConnection(bridge)Wraps a live bridge in the callable RouterConnection that connectRouter returns
RouterConnectionThe callable handle: disconnect(), the mount to read and to move, and a Disposable

Nothing here imports a framework, so this package keeps no framework dependency — tests/provider-factory-parity.test.ts holds that. TNode is the only thing in the props that a framework decides, which is why it is a type parameter.

Validation

ExportDescription
validateRouteFormat(route, stateId)Asserts that the route path is not empty
validateStateExists(stateId, stateIds)Asserts that the machine graph holds the state ID
detectDuplicateRoutes(routes)Throws when two states resolve to the same full path

Key Types

ExportDescription
RouterBridgeThe interface of the connect() and disconnect() lifecycle
MountableRouterBridgeA RouterBridge whose mount can move — it adds basePath and setBasePath()
RouteTreeThe hierarchical tree, with root, byStateId, byPath, and an optional graph
RouteNodeOne node of the tree, with id, path, fullPath, stateId, children, and parent
RouteInfoThe flat route descriptor that comes from a state node
PlayRouteEventRouting event { type: "play.route", to, params?, query? }
RoutableActorThe minimal actor interface that RouterBridgeBase requires: currentRoute, initialRoute, and send(PlayRouteEvent)
PlayActorThe complete actor interface that PlayRouterProvider uses. It extends RoutableActor with currentView (Routable + Viewable)
RouteMappingThe { stateId, path } pair that builds a RouteMap
RouteMapping as BaseRouteMappingThe alias of RouteMapping, for compatibility with an earlier version
MachineGraphThe typed @statelyai/graph Graph, with MachineNodeData and MachineEdgeData
WindowLikeThe minimal window interface that you can inject for SSR and for a test
LocationLikeThe minimal location interface that you can inject for SSR and for a test

Errors (subpath @xmachines/play-router/errors)

ClassCodeWhen thrown
RouterSyncErrorPLAY_ROUTER_SYNC_FAILEDsyncActorFromRouter() cannot send a play.route event
DuplicateBridgeErrorPLAY_ROUTER_DUPLICATE_BRIDGEA second bridge tries to connect to an actor that already has one
URLPatternUnavailableErrorPLAY_ROUTE_MAP_URLPATTERN_UNAVAILABLEThe URLPattern API is absent, and no polyfill is loaded
InvalidRoutePatternErrorPLAY_ROUTE_MAP_INVALID_PATTERNA route pattern does not compile, or two of its params land on one URLPattern group
EmptyRoutePathErrorPLAY_ROUTE_EMPTY_PATHA state declares meta.route: ""
InvalidStateIdErrorPLAY_ROUTE_INVALID_STATE_IDA route names a state ID that the machine graph does not hold
DuplicateRoutePathErrorPLAY_ROUTE_DUPLICATE_PATHTwo or more states share the same URL path
UnknownStateTypeErrorPLAY_ROUTE_UNKNOWN_STATE_TYPEA state node has an XState .type value that the package does not know
InvalidBasePathErrorPLAY_ROUTER_INVALID_BASE_PATHA basePath resolves to one concrete prefix never (*, :p?, $p, ?, #, a scheme, ./.., whitespace)
MissingBasePathParamErrorPLAY_ROUTER_MISSING_BASE_PATH_PARAMA :param of a basePath has no value in basePathParams
import {
RouterSyncError,
DuplicateBridgeError,
URLPatternUnavailableError,
} from "@xmachines/play-router/errors";
// bridge from the adapter example above
try {
bridge.connect();
} catch (err) {
if (err instanceof DuplicateBridgeError) {
// Actor already bridged — call disconnect() first
} else if (err instanceof RouterSyncError) {
console.error("Router sync failed:", err.message, err.cause);
}
}

Route Configuration

meta.route patterns

Declare the route of an XState state node in its meta.route field:

states: {
home: {
id: "home",
meta: { route: "/" }, // static route
},
profile: {
id: "profile",
meta: { route: "/profile/:userId" }, // required parameter
},
settings: {
id: "settings",
meta: { route: "/settings/:section?" }, // optional parameter
},
docs: {
id: "docs",
meta: { route: { path: "/docs", title: "Documentation" } }, // object form
},
}

Relative vs absolute paths

A child route that starts with / is absolute, and it does not inherit the path of its parent. A child route without the first / is relative to its nearest routable ancestor:

states: {
dashboard: {
id: "dashboard",
meta: { route: "/dashboard" },
states: {
overview: {
id: "overview",
meta: { route: "/overview" }, // absolute → fullPath: "/overview"
},
stats: {
id: "stats",
meta: { route: "stats" }, // relative → fullPath: "/dashboard/stats"
},
},
},
}

Always use node.fullPath to match a browser URL and to build a route map. Never use node.path for this.

Testing

Terminal window
# Run tests for this package
pnpm --filter @xmachines/play-router test
# Watch mode
pnpm --filter @xmachines/play-router run test:watch

@xmachines/play-router-shared holds a contract test suite of the router bridge, for the author of an adapter. That suite drives a real actor. Therefore it is one layer above this package, and @xmachines/play-router keeps no dependency on an actor runtime. @xmachines/play-router-shared is a private workspace package. Thus only an adapter author in this repository can use the suite:

import { runBridgeContractTests } from "@xmachines/play-router-shared/test/router-bridge-contract.js";
runBridgeContractTests({
name: "MyRouterBridge",
createHarness(initialPath) {
// return ContractHarness with bridge, actor, simulateNavigation, getLastNavigatedPath
},
createRestoredHarness(routedPath) {
// return ContractHarness whose actor is restored to routedPath
// while the mock router starts at the machine's initial route
},
});

License

MIT — see LICENSE.

Classes

Interfaces

Type Aliases

Variables

Functions