@xmachines/play-dom-router
API / @xmachines/play-dom-router
Vanilla DOM router (Browser History API) for XMachines Play Architecture.
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
pnpm add @xmachines/play-dom-router @xmachines/play-routerPeer dependency. Install it with the package:
pnpm add @xmachines/play@xmachines/play— the release protocol. This barrel re-exportsasCleanup,DISPOSEandCleanupfrom it.
@xmachines/play-router is a dependency of this package already. Install it directly to
reach @xmachines/play-router/xstate, which holds createRouteMap and
extractMachineRoutes.
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) driveshistory.push(). The actor is the authority. - Each browser navigation event (
popstate,pushState,replaceState) sends aplay.routeintent to the actor. - The actor keeps the ownership of each guarded state transition (Actor Authority).
- The
isProcessingNavigationflag stops a circular update. The bridge inherits the flag fromRouterBridgeBase.
Key Exports
| Export | Description |
|---|---|
createBrowserHistory(options) | Wraps window.history. It returns a BrowserHistory, which using releases |
createRouter(options) | Creates a VanillaRouter from a BrowserHistory and a RouteTree, which using releases |
connectRouter(options) | Connects a history to a Routable actor — returns a RouterConnection, which using releases |
DomRouterBridge | The low-level bridge class. It extends RouterBridgeBase. Use it directly for full lifecycle control |
BrowserHistory | Interface for the history wrapper. Implement it to inject a history of your own — see the note below |
BrowserWindow | Structural window interface (accepts Window, JSDOM, or any test double) |
VanillaRouter | The router wrapper: a history, a route tree, and the release of the history under two keys |
ConnectRouterOptions | Options type for connectRouter |
RouteLookupContract | Structural interface for bidirectional route lookup |
RoutableActor | Minimal actor interface from @xmachines/play-router — currentRoute, initialRoute, and send(PlayRouteEvent) |
RouterBridge, PlayRouteEvent | Types re-exported from @xmachines/play-router |
RouteMap, RouteMapping, RouteMapOptions | The route map class and its types, re-exported from @xmachines/play-router |
asCleanup, DISPOSE, Cleanup | The release protocol that BrowserHistory names, re-exported from @xmachines/play |
Implementing BrowserHistory
subscribe returns a Cleanup, and the history carries the dispose key. A history that you inject for a test or for SSR therefore writes two things that a plain object does not:
import { asCleanup, DISPOSE, type BrowserHistory } from "@xmachines/play-dom-router";
const history: BrowserHistory = { // ... subscribe(listener) { listeners.add(listener); return asCleanup(() => void listeners.delete(listener)); }, destroy() { listeners.clear(); }, [DISPOSE]() { this.destroy(); },};asCleanup gives the function back as itself, with the dispose key added, so a caller that runs the release keeps that code. A caller that writes using releases at the end of the scope, and after an exception too.
Of this interface connectRouter reads three members — location, push and subscribe — and it releases the history never. The type asks for the whole of it, because that is the type of a history, and a test that stands in for one builds it from a factory rather than from a narrower type.
Quick Start
import { createMachine } from "xstate";import { definePlayer, compose, PlayerActor } from "@xmachines/play-xstate";import { formatPlayRouteTransitions, withRouting } from "@xmachines/play-xstate/routing";import { createBrowserHistory, createRouter, connectRouter } from "@xmachines/play-dom-router";import { createRouteMap } from "@xmachines/play-router/xstate";import { extractMachineRoutes } from "@xmachines/play-router/xstate";
// 1. Define a routable machine — states carry meta.routeconst 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 machineconst 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 routerconst router = createRouter({ routeTree, history });
// 5. Start actor and connect — `connectRouter` needs the routing capabilityconst actor = definePlayer({ machine, actor: compose(PlayerActor, withRouting) })();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 changesconst unsubscribe = history.subscribe((location) => { console.log("URL changed:", location.pathname, location.search);});
// Programmatic navigationhistory.push("/dashboard");history.replace("/login");history.back();
// Cleanup — safe to call more than once; cooperates with other wrappers on the same windowunsubscribe();history.destroy();BrowserHistory interface:
| Method | Description |
|---|---|
location | Read-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 an unsubscribe function, a Cleanup from createBrowserHistory |
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 aboveconst router = createRouter({ routeTree, history });// router.history — the BrowserHistory instance// router.routeTree — for structure reference// router.destroy() — calls history.destroy()Or let a scope release it, which also releases the history on an exception:
using router = createRouter({ routeTree, history });The dispose key and destroy() run the same body, and the release is idempotent, so a
caller that keeps destroy() keeps it.
The release covers the history alone. connectRouter hands back its own
RouterConnection, and a scope that ends releases no connection for you. Release the
connection first, then the router.
connectRouter(options)
This function connects a history 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.routeevent. - Returns a cleanup function that disconnects the bridge.
const disconnect = connectRouter({ actor, // RoutableActor — an actor of the `PlayActor` contract that adds `Routable` satisfies this structurally router, // the router from createRouter(), or any { history } routeMap, // RouteLookupContract — any object with getStateIdByPath / getPathByStateId});
// Later:disconnect();ConnectRouterOptions:
| Option | Type | Description |
|---|---|---|
actor | RoutableActor | The actor to keep in step with the browser URL |
router | { history } | The router from createRouter(), or any { history }. Of the history it reads location, push and subscribe |
routeMap | RouteLookupContract | Bidirectional 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 — from @xmachines/play-router/xstate
This package re-exports it never: the barrel names no state machine. The function builds a bidirectional RouteMap from an XState machine:
import { createRouteMap } from "@xmachines/play-router/xstate";// machine from the Quick Start above (states carry meta.route)const routeMap = createRouteMap(machine);routeMap.getStateIdByPath("/dashboard"); // "dashboard"routeMap.getPathByStateId("dashboard"); // "/dashboard"Architecture
The bridge-first data flow:
connectRoutercreates aDomRouterBridge, which extendsRouterBridgeBase, and callsbridge.connect().- On connect,
RouterBridgeBasedoes the first synchronization. If the browser URL is different from the actor route, the bridge sends aplay.routeevent. 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. - Each actor route change, through the
currentRouteSignal, callshistory.push(path). - Each browser URL change (a
popstateevent, or a patchedpushStateorreplaceStatecall) callssyncActorFromRouter(pathname, search), which sends aplay.routeevent. - The
isProcessingNavigationflag inRouterBridgeBasestops a circular update.
Browser URL │ popstate / pushState / replaceState ▼DomRouterBridge │ play.route event ▼Actor (XState machine) │ currentRoute Signal change ▼DomRouterBridge │ history.push(path) ▼Browser URLRelated Packages
- @xmachines/play-router —
RouterBridgeBase,RouteMap,Routable - @xmachines/play-router/xstate —
createRouteMap,extractMachineRoutes - @xmachines/play-actor —
PlayActor; an actor that addsRoutablesatisfiesRoutableActorstructurally - @xmachines/play-dom — the vanilla DOM renderer, for the view beside the routing
- @xmachines/play-xstate —
definePlayer,PlayerActor - Routing guide — the URLPattern language that a
meta.routepath declares
License
MIT — see LICENSE.
Classes
Interfaces
- BasePathOptions
- BrowserHistory
- BrowserWindow
- ConnectRouterOptions
- PlayRouteEvent
- RoutableActor
- RouteLookupContract
- RouteMapOptions
- RouteMapping
- RouterBridge
- RouterConnection
- VanillaRouter