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

v7.0.0

Published

Vanilla DOM bindings and framework-agnostic router integration for XMachines.

Readme

@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 atom 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-router @xmachines/core @xmachines/play @xmachines/play-atom

@xmachines/play-router names @xmachines/core, @xmachines/play and @xmachines/play-atom as peers, so the command installs them with this package. To release a history, a router or a connection with using, your code imports it itself, so declare it in your own manifest. The asDisposable of its utils subpath publishes each of them for a scope.

@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 atom (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

| Export | Description | | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | createBrowserHistory(options) | Wraps window.history. It returns a BrowserHistory | | createRouter(options) | Creates a VanillaRouter from a BrowserHistory and a RouteTree | | connectRouter(options) | Connects a history to a Routable actor — returns a RouterConnection | | 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 destroy() | | 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 |

Implementing BrowserHistory

A history that you inject for a test or for SSR is a plain object. subscribe returns a plain function:

import type { BrowserHistory } from "@xmachines/play-dom-router";

const history: BrowserHistory = {
	// ...
	subscribe(listener) {
		listeners.add(listener);
		return () => void listeners.delete(listener);
	},
	destroy() {
		listeners.clear();
	},
};

A spread copies location as a value, and the copy then shows a stale location. To wrap a history, build the wrapper as its own object.

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.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 — `connectRouter` needs the routing capability
const actor = definePlayer({ machine, actor: compose(PlayerActor, withRouting) })();
actor.start();

const disconnect = connectRouter({ actor, router, routeMap });

// Call teardown() when the app ends before the page does, for example when the component
// that holds it unmounts. At the end of the page, do nothing: the browser frees the page.
// A teardown on `beforeunload` also runs when the page goes into the back/forward cache,
// and Back then shows a page that does not respond.
function teardown(): void {
	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.

import { createBrowserHistory } from "@xmachines/play-dom-router";

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:

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

import { createBrowserHistory, createRouter } from "@xmachines/play-dom-router";
import { extractMachineRoutes } from "@xmachines/play-router/xstate";
import type { AnyStateMachine } from "xstate";

declare const machine: AnyStateMachine; // the machine of the Quick Start above

const routeTree = extractMachineRoutes(machine);
const history = createBrowserHistory({ window });
const 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. Publish the router with asDisposable of @xmachines/core/utils, and name destroy as the release:

import { asDisposable } from "@xmachines/core/utils";
import { createBrowserHistory, createRouter } from "@xmachines/play-dom-router";
import { extractMachineRoutes } from "@xmachines/play-router/xstate";
import type { AnyStateMachine } from "xstate";

declare const machine: AnyStateMachine; // the machine of the Quick Start above

const routeTree = extractMachineRoutes(machine);
const history = createBrowserHistory({ window });
const plain = createRouter({ routeTree, history });
using router = asDisposable(plain, () => plain.destroy());

destroy() is idempotent, so a caller that calls it before the scope ends is safe.

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.route event.
  • Returns a cleanup function that disconnects the bridge.
import {
	connectRouter,
	type RoutableActor,
	type RouteLookupContract,
	type VanillaRouter,
} from "@xmachines/play-dom-router";

declare const actor: RoutableActor;
declare const router: VanillaRouter;
declare const routeMap: RouteLookupContract;

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 { createMachine } from "xstate";
import { createRouteMap } from "@xmachines/play-router/xstate";

// A machine whose states carry meta.route, as in the Quick Start above
const machine = createMachine({
	id: "app",
	initial: "dashboard",
	states: {
		dashboard: { id: "dashboard", meta: { route: "/dashboard" } },
	},
});
const routeMap = createRouteMap(machine);
routeMap.getStateIdByPath("/dashboard"); // "dashboard"
routeMap.getPathByStateId("dashboard"); // "/dashboard"

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 URL of the machine, with the same path and the same query (a restore), the actor wins, and the bridge updates the history. A query of the browser at the initial path makes a deep link, and the router wins.
  3. Each actor route change, through the currentRoute Atom, 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 Atom change
    ▼
DomRouterBridge
    │  history.push(path)
    ▼
Browser URL

Related Packages

License

MIT — see LICENSE.