Skip to content

@xmachines/play-dom-router

API / @xmachines/play-dom-router

Vanilla DOM router (Browser History API) for XMachines Play Architecture.

License: MIT Version

This framework-agnostic router integration keeps the currentRoute TC39 Signal of a Play actor and the window.history API of the browser in step. It needs no framework. It implements the same RouterBridgeBase pattern as every other router adapter in the XMachines ecosystem.

Installation

Terminal window
pnpm add @xmachines/play-dom-router @xmachines/play-router @xmachines/play-actor

Peer dependency:

Terminal window
pnpm add xstate@^5.31.0

Overview

@xmachines/play-dom-router connects a Play actor to the browser URL through the DomRouterBridge (extends RouterBridgeBase from @xmachines/play-router):

  • The actor route signal (actor.currentRoute) drives history.push(). The actor is the authority.
  • Each browser navigation event (popstate, pushState, replaceState) sends a play.route intent to the actor.
  • The actor keeps the ownership of each guarded state transition (Actor Authority).
  • The isProcessingNavigation flag stops a circular update. The bridge inherits the flag from RouterBridgeBase.

Key Exports

ExportDescription
createBrowserHistory(options)Wraps window.history with a subscribable BrowserHistory interface
createRouter(options)Creates a VanillaRouter from a BrowserHistory and a RouteTree
connectRouter(options)Connects a VanillaRouter to a Routable actor — returns a disconnect cleanup function
DomRouterBridgeThe low-level bridge class. It extends RouterBridgeBase. Use it directly for full lifecycle control
createRouteMapIt comes from @xmachines/play-router. It builds the bidirectional path ↔ state ID map
BrowserHistoryInterface for the history wrapper
BrowserWindowStructural window interface (accepts Window, JSDOM, or any test double)
VanillaRouterInterface for the router wrapper
ConnectRouterOptionsOptions type for connectRouter
RouteLookupContractStructural interface for bidirectional route lookup
RoutableActorMinimal actor interface from @xmachines/play-routercurrentRoute, initialRoute, and send(PlayRouteEvent)
RouterBridge, PlayRouteEventTypes re-exported from @xmachines/play-router
RouteMap, RouteMapping, RouteMapOptionsTypes re-exported from @xmachines/play-router

Quick Start

import { createMachine } from "xstate";
import { definePlayer, formatPlayRouteTransitions } from "@xmachines/play-xstate";
import {
createBrowserHistory,
createRouter,
connectRouter,
createRouteMap,
} from "@xmachines/play-dom-router";
import { extractMachineRoutes } from "@xmachines/play-router";
// 1. Define a routable machine — states carry meta.route
const machine = createMachine(
formatPlayRouteTransitions({
id: "app",
initial: "home",
states: {
home: { id: "home", meta: { route: "/" } },
about: { id: "about", meta: { route: "/about" } },
},
}),
);
// 2. Extract route tree and build route map from the machine
const routeTree = extractMachineRoutes(machine);
const routeMap = createRouteMap(machine);
// 3. Create browser history wrapper (accepts window or any BrowserWindow-compatible object)
const history = createBrowserHistory({ window });
// 4. Create router
const router = createRouter({ routeTree, history });
// 5. Start actor and connect
const actor = definePlayer({ machine })();
actor.start();
const disconnect = connectRouter({ actor, router, routeMap });
// Cleanup (e.g. on page unload)
window.addEventListener("beforeunload", () => {
disconnect();
router.destroy();
});

API

createBrowserHistory(options)

This function wraps window.history, and it gives you a history interface with a subscription. It patches pushState and replaceState. Therefore the wrapper also detects a navigation from the code, not only a popstate event from the BACK or FORWARD button.

const history = createBrowserHistory({ window });
// Subscribe to URL changes
const unsubscribe = history.subscribe((location) => {
console.log("URL changed:", location.pathname, location.search);
});
// Programmatic navigation
history.push("/dashboard");
history.replace("/login");
history.back();
// Cleanup — safe to call more than once; cooperates with other wrappers on the same window
unsubscribe();
history.destroy();

BrowserHistory interface:

MethodDescription
locationRead-only { pathname, search, hash, state }
push(path, state?)Push a new entry to history
replace(path, state?)Replace the current history entry
go(delta)Navigate relative to current position
back()Navigate backward
forward()Navigate forward
subscribe(listener)Subscribe to location changes — returns unsubscribe function
createHref(path)Create an href from a path
destroy()Cleans up. It removes the listeners, and it restores the patched methods when it is the last wrapper

BrowserWindow interface:

The interface accepts window, a JSDOM window, or every other object that implements it. It holds only the properties that the package uses. Therefore the package does not depend on Window & typeof globalThis.

createRouter(options)

This function creates a VanillaRouter around a history and a routeTree. Its setup flow is the same as the setup flow of TanStack Router.

// routeTree and history from the Quick Start above
const router = createRouter({ routeTree, history });
// router.history — the BrowserHistory instance
// router.routeTree — for structure reference
// router.destroy() — calls history.destroy()

connectRouter(options)

This function connects a VanillaRouter to a Routable actor. It does all the work in both directions:

  • On connect: it sets the actor state from the initial URL, or it writes the actor route to the browser. The bridge detects a restore and a deep link.
  • While it is connected: each actor route change goes to the history, and each browser navigation sends a play.route event.
  • Returns a cleanup function that disconnects the bridge.
const disconnect = connectRouter({
actor, // RoutableActor — any AbstractActor subclass satisfies this structurally
router, // VanillaRouter from createRouter()
routeMap, // RouteLookupContract — any object with getStateIdByPath / getPathByStateId
});
// Later:
disconnect();

ConnectRouterOptions:

OptionTypeDescription
actorRoutableActorThe actor to keep in step with the browser URL
routerVanillaRouterRouter from createRouter()
routeMapRouteLookupContractBidirectional path ↔ state ID lookup

RouteLookupContract:

interface RouteLookupContract {
getStateIdByPath(path: string): string | null | undefined;
getPathByStateId(id: string): string | null | undefined;
}

The bridge accepts every object that implements this structural interface. A RouteMap instance from @xmachines/play-router, a subclass, and a test double all work.

createRouteMap (re-export)

This function comes from @xmachines/play-router. It builds a bidirectional RouteMap from an XState machine:

import { createRouteMap } from "@xmachines/play-dom-router";
// machine from the Quick Start above (states carry meta.route)
const routeMap = createRouteMap(machine);
routeMap.getStateIdByPath("/dashboard"); // "dashboard"
routeMap.getPathByStateId("dashboard"); // "/dashboard"

URLPattern Support

This package matches each route pattern with the URLPattern API, through @xmachines/play-router. URLPattern is native in Node.js 24+ and in a modern browser (Chrome 95+, Firefox 117+, Safari 16.4+). In an older environment, load a polyfill before you import this package. See @xmachines/play-router for the details.

Testing

Run tests in isolation:

Terminal window
pnpm test
# or from monorepo root:
pnpm --filter @xmachines/play-dom-router test

The tests run in a Node.js environment with the URLPattern polyfill setup. The browser tests are in test/browser/. They run separately, through vitest.browser.config.ts.

Coverage thresholds:

TypeThreshold
Lines80%
Functions80%
Branches75%
Statements80%

Architecture

The bridge-first data flow:

  1. connectRouter creates a DomRouterBridge, which extends RouterBridgeBase, and calls bridge.connect().
  2. On connect, RouterBridgeBase does the first synchronization. If the browser URL is different from the actor route, the bridge sends a play.route event. If the actor route is different and the browser is at the initial route of the machine (a restore), the actor wins, and the bridge updates the history.
  3. Each actor route change, through the currentRoute Signal, calls history.push(path).
  4. Each browser URL change (a popstate event, or a patched pushState or replaceState call) calls syncActorFromRouter(pathname, search), which sends a play.route event.
  5. The isProcessingNavigation flag in RouterBridgeBase stops a circular update.
Browser URL
│ popstate / pushState / replaceState
DomRouterBridge
│ play.route event
Actor (XState machine)
│ currentRoute Signal change
DomRouterBridge
│ history.push(path)
Browser URL

License

MIT — see LICENSE.

Classes

Interfaces

Functions