@xmachines/play-router
API / @xmachines/play-router
Route tree extraction from XState v5 state machines. Part of @xmachines/play Universal Player Architecture.
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 functionat the extraction of the routes. The root README carries the table.
Installation
pnpm add xstate@^5.33.0pnpm add @xmachines/play-routerPeer dependencies:
xstate^5.33.0 — XState v5 state machine runtime (an OPTIONAL peer: the@xmachines/play-router/xstatesubpath alone reads it)
URLPattern: this package matches each route with the URLPattern API. It uses the native API when the runtime has one, and it falls back to urlpattern-polyfill, which it carries as an ordinary dependency. You install nothing and you load nothing.
The routing guide states the pattern language: the forms, the modifiers, the prefix rule, and the one divergence from the standard.
Usage
Extract routes from a machine
import { createMachine } from "xstate";import { extractMachineRoutes, createRouteMap } from "@xmachines/play-router/xstate";
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 mapsconst tree = extractMachineRoutes(machine);
// Path → RouteNodeconst node = tree.byPath.get("/dashboard"); // RouteNode for "dashboard"
// State ID → RouteNodeconst overview = tree.byStateId.get("overview");console.log(overview?.fullPath); // "/overview"
// Build a RouteMap for framework adaptersconst 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 IDconst event: PlayRouteEvent = { type: "play.route", to: "#dashboard",};actor.send(event);
// Navigate with route parametersactor.send({ type: "play.route", to: "#profile", params: { userId: "123" },});
// Navigate with query parametersactor.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 router | basePath = "/abc123/play" |
|---|---|
/abc123/play | the / of the machine → play.route |
/abc123/play/about | the /about of the machine → play.route |
/abc123/play/nope | unknown INSIDE the mount → the URL follows the actor |
/account/billing | a route of the host → the bridge does nothing |
/abc123/playground | another 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/xstate";
const tree = extractMachineRoutes(machine);
// Concrete, for a route that the host adds after a loader resolved the mountgetRouteMappings(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 hostgetRouteMappings(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-routerand@xmachines/play-solid-routerread 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 withURLPatterninstead.Without a prefix both adapters keep the parse of their framework, with its decoding, 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 name that the pattern makes OPTIONAL is covered whether the framework reports it or not: a part that did not match has no value, and that is a complete answer.
A location that fills NO optional part runs no match at all.
/settingsis the bare form of/settings/:section?, so the params are{}, and the adapters read that from the path alone rather than from their framework.
How to write a RouterBridgeBase adapter
Extend RouterBridgeBase, then implement the three abstract methods for your framework:
import { RouterBridgeBase } from "@xmachines/play-router";import { createRouteMap } from "@xmachines/play-router/xstate";import type { RoutableActor } from "@xmachines/play-router";
// Shape of your framework's router — adjust to its real APItype 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 actorconst routeMap = createRouteMap(machine);const bridge = new MyRouterBridge(myRouter, actor, routeMap);bridge.connect();// ...bridge.disconnect();API Summary
Route Extraction
| Export | Description |
|---|---|
buildRouteTree(routes) | Builds a RouteTree from an array of RouteInfo objects |
createRouteMapFromTree(tree, options?) | Builds a RouteMap from a RouteTree that you extracted before |
ROOT_NODE_ID | The ID of the synthetic root node. A walk of a tree must skip that node |
The MAIN entry point reads no state machine. The machine half is
@xmachines/play-router/xstate, below.
Route Matching
| Export | Description |
|---|---|
RouteMap | The 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 |
The machine half — subpath @xmachines/play-router/xstate
A route of XMachines is a fact about a STATE. This entry point reads that fact out of an
XState v5 machine. The main entry point reads it never, so xstate is an OPTIONAL peer
dependency of this package, and a bundler drops @statelyai/graph for a consumer that
imports this entry point nowhere.
import { createRouteMap, extractMachineRoutes } from "@xmachines/play-router/xstate";| Export | Description |
|---|---|
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 |
machineToGraph(machine) | Converts a machine into a typed @statelyai/graph Graph, for a graph algorithm |
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 |
MachineGraph | The typed @statelyai/graph Graph, with MachineNodeData and MachineEdgeData |
No framework router adapter re-exports createRouteMap. Each one names no state machine,
so a consumer of an adapter imports the function from @xmachines/play-router/xstate.
Router Bridge
| Export | Description |
|---|---|
RouterBridgeBase | The 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
| Export | Description |
|---|---|
BasePathOptions | { basePath?, basePathParams? } — every bridge, provider, and connectRouter takes it |
RouterBridgeBase#basePath | The resolved prefix of the mount, or "" when the machine owns the complete router |
RouterBridgeBase#basePathParams | The 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_PATH | The 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.
| Export | Description |
|---|---|
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.
| Export | Description |
|---|---|
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 |
RouterConnection | The 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
| Export | Description |
|---|---|
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
| Export | Description |
|---|---|
RouterBridge | The interface of the connect() and disconnect() lifecycle |
MountableRouterBridge | A RouterBridge whose mount can move — it adds basePath and setBasePath() |
RouteTree | The hierarchical tree, with root, byStateId, byPath, and an optional graph |
RouteNode | One node of the tree, with id, path, fullPath, stateId, children, and parent |
RouteInfo | The flat route descriptor that comes from a state node |
PlayRouteEvent | Routing event { type: "play.route", to, params?, query?, data? } |
Routable | The optional routing capability of an actor: currentRoute and initialRoute |
RoutableActor | The actor interface that RouterBridgeBase and PlayRouterProvider require: currentRoute, initialRoute, and send(PlayRouteEvent) |
RouteMapping | The { stateId, path } pair that builds a RouteMap |
RouteObject | The object form of meta.route: path, and the handler, reenter and data fields |
RouteMetadata | The union of the two forms of meta.route: the path string, or a RouteObject |
RouteData | The resolved extra data of a route, which reaches the machine as context.data |
RouteDataResolver | The function form of RouteObject.data: ({ context, event }) => RouteData |
WindowLike | The minimal window interface that you can inject for SSR and for a test |
LocationLike | The minimal location interface that you can inject for SSR and for a test |
Errors (subpath @xmachines/play-router/errors)
| Class | Code | When thrown |
|---|---|---|
RouterSyncError | PLAY_ROUTER_SYNC_FAILED | syncActorFromRouter() cannot send a play.route event |
DuplicateBridgeError | PLAY_ROUTER_DUPLICATE_BRIDGE | A second bridge tries to connect to an actor that already has one |
InvalidRoutePatternError | PLAY_ROUTE_MAP_INVALID_PATTERN | A route pattern does not compile, or two of its params land on one URLPattern group |
EmptyRoutePathError | PLAY_ROUTE_EMPTY_PATH | A state declares meta.route: "" |
InvalidStateIdError | PLAY_ROUTE_INVALID_STATE_ID | A route names a state ID that the machine graph does not hold |
DuplicateRoutePathError | PLAY_ROUTE_DUPLICATE_PATH | Two or more states share the same URL path |
UnknownStateTypeError | PLAY_ROUTE_UNKNOWN_STATE_TYPE | A state node has an XState .type value that the package does not know |
InvalidBasePathError | PLAY_ROUTER_INVALID_BASE_PATH | A basePath resolves to one concrete prefix never (*, :p?, $p, ?, #, a scheme, ./.., whitespace) |
MissingBasePathParamError | PLAY_ROUTER_MISSING_BASE_PATH_PARAM | A :param of a basePath has no value in basePathParams |
import { RouterSyncError, DuplicateBridgeError } from "@xmachines/play-router/errors";
// bridge from the adapter example abovetry { 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 },}The object form
The object form carries the path and any metadata of your own, such as a title or a breadcrumb. It also carries three fields that the routing layer reads:
| Field | Default | What it does |
|---|---|---|
path | required | The template of the route path |
handler | "root" | Where the generated play.route transition sits: "root", "local", "both" |
reenter | false | Whether the transition re-enters its own domain. See the note below. |
data | none | The extra data of the route: a literal value, or a function of the event |
@xmachines/play-xstate reads handler, reenter, and data when it generates the
transitions. See the README of that package for what each value does.
The four names are RESERVED, and this is a change of behaviour. An earlier release
read path alone, so a state could put any name of its own beside it. A state that
carries its own handler value now throws an InvalidRouteHandlerError at the format
time, and the machine builds never; a state that carries its own data value now writes
that value to context.data, where the /context projection of every view reads it.
Give such a field another name.
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.
Related Packages
- @xmachines/play — Core protocol types (
PlayEvent,PlayError) - @xmachines/play-actor — the actor contract (
PlayActor). An actor that addsRoutableof this package satisfiesRoutableActorstructurally - @xmachines/play-url — the URL language. This package re-exports its base path and its framework params, for an adapter on
RouterBridgeBase - @xmachines/play-signals — the TC39 Signals polyfill that observes the actor route
- @xmachines/play-xstate — the XState v5 logic adapter, which works with a route tree
- @xmachines/play-tanstack-router — Shared TanStack Router bridge base (framework-agnostic)
- @xmachines/play-tanstack-react-router — TanStack Router adapter (React)
- @xmachines/play-tanstack-solid-router — TanStack Router adapter (SolidJS)
- @xmachines/play-react-router — React Router 7/8 adapter
- @xmachines/play-vue-router — Vue Router adapter
- @xmachines/play-solid-router — SolidJS Router adapter
License
MIT — see LICENSE.