Skip to content

@xmachines/play-dom-router-demo

Examples / @xmachines/play-dom-router-demo

Pure TypeScript integration demo for @xmachines/play-dom-router — actor-authoritative routing with the Browser History API and no framework.

What This Demonstrates

  • Shared auth machine reused without framework-specific business logic
  • connectRouter + createBrowserHistory wiring actor ↔ browser History API
  • Shell-driven DOM rendering via connectRenderer with actor-authoritative navigation
  • Manual TC39 Signal watcher lifecycle — the mechanism that framework adapters abstract
  • Non-browser invariant tests plus browser E2E coverage

Running the Demo

From the repository root:

Terminal window
npm install
npm run dev -w @xmachines/play-dom-router-demo

Then open http://localhost:5174.

Step-by-Step Code Flow

Use this order to understand the implementation:

  1. src/main.ts looks up the host element (throws if absent), calls initApp(app) from src/runtime.ts, and registers unload cleanup.
  2. src/runtime.ts creates the actor from the shared machine via definePlayer, starts it, initializes router sync via initRouter(actor), mounts the shared DOM shell via initShell(actor, host), and returns one cleanup function.
  3. initRouter(actor) from src/router.ts extracts route metadata, creates a browser history wrapper, instantiates a router, and calls connectRouter to wire bidirectional actor ↔ URL sync.
  4. initShell(actor, host) is imported from @xmachines/play-dom-demo, so the router demo reuses the shared DOM shell instead of duplicating the scaffold and renderer wiring.
  5. Navigation buttons and links dispatch play.route, auth.login, and auth.logout events directly to the actor — no URL mutations from the shell.
  6. Browser tests in test/browser/ validate startup rendering and the full auth route flow.
// src/main.ts (shape)
const app = document.getElementById("app");
if (!app) throw new Error("Root element not found");
const cleanup = initApp(app);
window.addEventListener("beforeunload", () => {
cleanup();
});
// src/router.ts (shape)
export function initRouter(actor) {
const routeTree = extractMachineRoutes(authMachine);
const routeMap = createRouteMap(authMachine);
const history = createBrowserHistory({ window });
const router = createRouter({ routeTree, history });
const disconnect = connectRouter({ actor, router, routeMap });
return () => {
disconnect();
router.destroy();
};
}
// src/runtime.ts (shape)
export function initApp(host: HTMLElement): () => void {
const createPlayer = definePlayer({ machine: authMachine });
const actor = createPlayer();
actor.start();
const cleanupRouter = initRouter(actor);
const cleanupShell = initShell(actor, host);
return () => {
cleanupShell();
cleanupRouter();
actor.stop();
};
}

Key Files

  • src/main.ts - host lookup, app bootstrap, and unload cleanup
  • src/runtime.ts - actor startup, router initialization, and shared shell wiring
  • src/router.ts - createBrowserHistory, createRouter, and connectRouter wiring
  • @xmachines/play-dom-demo - shared DOM shell, NavBar, DebugPanel, and renderer wiring reused by this router demo
  • test/library-pattern.test.ts - architecture boundary and invariant assertions
  • test/browser/shared-demo.browser.test.ts - startup rendering and full auth route browser flow

State Machine & Architecture Details

The demo utilizes XMachines architectural invariants at the lowest level:

  1. Actor Authority: Intercepting browser popstate events or navigation button clicks dispatches a play.route event to the actor. The actor processes the request against the current state, ensuring any auth logic is correctly applied.
  2. Passive Infrastructure: The router simply updates window.history and the DOM solely renders HTML based on what actor.currentView yields. Neither holds business state.
  3. Signal-Only Reactivity: The integration registers watchSignal observers that trigger DOM reconciliation, illustrating the underlying mechanism that framework adapters abstract.

Watcher Lifecycle and Cleanup Contract

Even in the vanilla demo, watcher handling follows the canonical lifecycle:

  1. notify
  2. queueMicrotask
  3. getPending()
  4. Read actor signals and project DOM state
  5. Re-arm with watch()/watch(...signals)

Watcher notifications are one-shot, so re-arm is mandatory. Router/shell teardown must explicitly call cleanup (disconnect/unwatch) and not rely on GC-only behavior. In vanilla TypeScript, this means retaining cleanup functions and calling them on beforeunload or during hot-module replacement.

Adapter Boundaries

connectRouter and browser-history wiring (from @xmachines/play-dom-router) are passive infrastructure. Actor logic remains authoritative for route validity, and shared synchronization policy stays in RouterBridgeBase for framework bridges. This demo proves frameworks are not required for Play Architecture to work.

Available Scripts

These commands are defined in package.json:

CommandDescription
npm run dev -w @xmachines/play-dom-router-demoStart Vite dev server
npm run build -w @xmachines/play-dom-router-demoBuild production bundle
npm run preview -w @xmachines/play-dom-router-demoPreview built bundle
npm run test -w @xmachines/play-dom-router-demoRun Vitest test suite
npm run test:browser -w @xmachines/play-dom-router-demoRun browser-focused Vitest suite

Verification

Use these checks to validate README claims against the current demo implementation:

Terminal window
npm run test -w @xmachines/play-dom-router-demo
npm run test:browser -w @xmachines/play-dom-router-demo

Expected result: tests pass, including startup rendering and the full browser auth flow in test/browser/.

Learn More