npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@xmachines/play-router

v3.0.0

Published

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

Readme

@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

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:

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 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";

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

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

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

Query Utilities

| Export | Description | | ------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | 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

| 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? } | | RoutableActor | The minimal actor interface that RouterBridgeBase requires: currentRoute, initialRoute, and send(PlayRouteEvent) | | PlayActor | The complete actor interface that PlayRouterProvider uses. It extends RoutableActor with currentView (Routable + Viewable) | | RouteMapping | The { stateId, path } pair that builds a RouteMap | | RouteMapping as BaseRouteMapping | The alias of RouteMapping, for compatibility with an earlier version | | MachineGraph | The typed @statelyai/graph Graph, with MachineNodeData and MachineEdgeData | | 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 | | URLPatternUnavailableError | PLAY_ROUTE_MAP_URLPATTERN_UNAVAILABLE | The URLPattern API is absent, and no polyfill is loaded | | 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,
	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

# 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
	},
});

Related Packages

License

MIT — see LICENSE.