@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+createBrowserHistorywiring actor ↔ browser History API- Shell-driven DOM rendering via
connectRendererwith 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:
npm installnpm run dev -w @xmachines/play-dom-router-demoThen open http://localhost:5174.
Step-by-Step Code Flow
Use this order to understand the implementation:
src/main.tslooks up the host element (throws if absent), callsinitApp(app)fromsrc/runtime.ts, and registers unload cleanup.src/runtime.tscreates the actor from the shared machine viadefinePlayer, starts it, initializes router sync viainitRouter(actor), mounts the shared DOM shell viainitShell(actor, host), and returns one cleanup function.initRouter(actor)fromsrc/router.tsextracts route metadata, creates a browser history wrapper, instantiates a router, and callsconnectRouterto wire bidirectional actor ↔ URL sync.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.- Navigation buttons and links dispatch
play.route,auth.login, andauth.logoutevents directly to the actor — no URL mutations from the shell. - 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 cleanupsrc/runtime.ts- actor startup, router initialization, and shared shell wiringsrc/router.ts-createBrowserHistory,createRouter, andconnectRouterwiring@xmachines/play-dom-demo- shared DOM shell, NavBar, DebugPanel, and renderer wiring reused by this router demotest/library-pattern.test.ts- architecture boundary and invariant assertionstest/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:
- Actor Authority: Intercepting browser
popstateevents or navigation button clicks dispatches aplay.routeevent to the actor. The actor processes the request against the current state, ensuring any auth logic is correctly applied. - Passive Infrastructure: The router simply updates
window.historyand the DOM solely renders HTML based on whatactor.currentViewyields. Neither holds business state. - Signal-Only Reactivity: The integration registers
watchSignalobservers 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:
notifyqueueMicrotaskgetPending()- Read actor signals and project DOM state
- 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:
| Command | Description |
|---|---|
npm run dev -w @xmachines/play-dom-router-demo | Start Vite dev server |
npm run build -w @xmachines/play-dom-router-demo | Build production bundle |
npm run preview -w @xmachines/play-dom-router-demo | Preview built bundle |
npm run test -w @xmachines/play-dom-router-demo | Run Vitest test suite |
npm run test:browser -w @xmachines/play-dom-router-demo | Run browser-focused Vitest suite |
Verification
Use these checks to validate README claims against the current demo implementation:
npm run test -w @xmachines/play-dom-router-demonpm run test:browser -w @xmachines/play-dom-router-demoExpected result: tests pass, including startup rendering and the full browser auth flow in test/browser/.