Skip to content

@xmachines/play-dom-demo

Examples / @xmachines/play-dom-demo

Vanilla DOM renderer demo for @xmachines/play-dom — actor + createPlayUI without a router.

License: MIT

What This Demonstrates

  • Shared auth machine reused without framework-specific business logic
  • createPlayUI wiring PlayRenderer-equivalent DOM rendering without a framework
  • Auth machine states (home → login → dashboard) drive DOM updates via atom watchers
  • watchAtom for reactive nav visibility and debug panel updates
  • Non-browser invariant tests plus browser renderer coverage
  • Stately inspector attached at creation via options.inspect — the debug panel’s Show Inspector button opens the live machine in its own window
  • json-render 0.20 showcase pages driven purely by spec:
    • Overview — two data planes side by side: /context/tasks (machine context via the read-only /context projection; repeat + key, $item-filtered lists, per-row actions sending inspectable machine events) next to ephemeral store state (pushState/removeState/setState built-ins driving filter chips and toggles that survive context updates)
    • Stats — nested repeats (repeat.statePath: { $item: "members" }) over /context/teams
    • Settings — declarative field validation, the validateForm built-in, a confirm-gated resetForm host action, and onSuccess/onError chains
    • Contact — a chained named action: submit validates, then routes home on success or writes "$error.message" to state on failure

Running the Demo

From the repository root:

Terminal window
pnpm install
pnpm --filter @xmachines/play-dom-demo run dev

Then open http://localhost:3000.

Step-by-Step Code Flow

Use this order to understand the implementation:

  1. src/main.ts creates the page’s inspector with createDemoInspector(), calls definePlayer({ machine: authMachine, options: { inspect: inspector.inspect }, actor: compose(PlayerActor, withRouting, withView) }), and starts the actor.
  2. initShell(actor, app, inspector) from src/components/Shell.ts builds the HTML scaffold, mounts a reactive NavBar, wires the debug panel, and returns a disconnect function.
  3. defineRegistry(authCatalog, { components, actions }) builds the typed registryResult — real async action handlers dispatching to actor.send().
  4. createPlayUI(registryResult) produces a mount function; mount(actor, container) wires the registry to the actor — view switches happen automatically when actor.currentView changes.
  5. createNavBar(actor, headerEl) in src/components/NavBar.ts uses watchAtom(actor.state, ...) to update nav button visibility based on auth state.
  6. createDebugPanel(actor, shell, inspector) in src/components/DebugPanel.ts uses watchAtom(actor.state, ...) and watchAtom(actor.currentRoute, ...) to drive live debug panel updates, and renders the Show Inspector button that calls inspector.show().
  7. Browser tests in test/browser/ validate startup rendering and auth interactions.
// src/main.ts (shape)
const inspector = createDemoInspector();
const createPlayer = definePlayer({
machine: authMachine,
options: { inspect: inspector.inspect },
actor: compose(PlayerActor, withRouting, withView),
});
const actor = createPlayer();
actor.start();
const app = document.getElementById("app");
if (!app) throw new Error("Root element not found");
// The shell and the actor live as long as the page, so the demo never tears them down:
// the browser frees them with the page. `initShell` returns a disconnect function for a
// host that removes the shell before the page ends, and the browser tests call it.
initShell(actor, app, inspector);
// src/components/Shell.ts (shape)
const registryResult = defineRegistry(authCatalog, {
components: demoComponents, // one shared map — Home…NavBar plus the 0.20 Panel/Field/Button leaves
actions: createAuthActionHandlers(actor), // login/logout/route + task events + form actions
});
const disconnectNavBar = createNavBar(actor, headerEl);
const mount = createPlayUI(registryResult, { onConfirm: createConfirmDialog });
const disconnectRenderer = mount(actor, viewContent);
const disconnectDebugPanel = createDebugPanel(actor, shell, inspector);
return () => {
disconnectNavBar();
disconnectRenderer();
disconnectDebugPanel();
};

Key Files

  • src/main.ts - actor creation and shell bootstrap
  • src/components/Shell.ts - DOM scaffold, DomRegistry construction, createPlayUI wiring, createNavBar, and createDebugPanel
  • src/components/NavBar.ts - reactive nav bar using watchAtom(actor.state, ...) for auth-driven button visibility
  • src/components/DebugPanel.ts - live debug footer using watchAtom on both actor.state and actor.currentRoute, plus the Show Inspector button
  • test/library-pattern.test.ts - architecture boundary and invariant assertions
  • test/browser/renderer-demo.browser.test.ts - browser-mode renderer coverage

State Machine & Architecture Details

The demo utilizes XMachines architectural invariants:

  1. Actor Authority: Navigation buttons dispatch play.route events to the actor. The actor evaluates guards and transitions to the correct state — the DOM never decides which view to show.
  2. Passive Infrastructure: createPlayUI and watchAtom only react to actor atoms. They do not hold business state or make routing decisions.
  3. Atom-Only Reactivity: watchAtom (from @xmachines/play-atom) wraps the canonical atoms watcher lifecycle, keeping all reactivity in the actor atom layer rather than in ad-hoc DOM event listeners.

Watcher Lifecycle and Cleanup Contract

This demo follows the canonical watcher lifecycle:

  1. The atom notifies the subscription, synchronously, from the set that changed it
  2. watchAtom queues one microtask, and it drops each duplicate notification of that batch
  3. The microtask reads the actor atoms and projects DOM state

One subscription lives for the whole watch, and no caller arms a watcher again. Cleanup is explicit: the disconnect function that initShell returns calls disconnectNavBar(), disconnectRenderer(), and disconnectDebugPanel(). This demo does not call that function at the end of the page, because the browser frees the page. The browser tests call it, and then they call actor.stop(). Do not tear down on beforeunload: that event also fires when the page goes into the back/forward cache, and Back then shows a page that does not respond.

Adapter Boundaries

createPlayUI (from @xmachines/play-dom) is passive infrastructure. It translates actor view atoms into DOM mutations but holds no business logic. The DomRegistry maps catalog component keys to factory functions — the actor owns which key is active.

Available Scripts

These commands are defined in package.json:

CommandDescription
pnpm --filter @xmachines/play-dom-demo run devStart Vite dev server (http://localhost:3000/)
pnpm --filter @xmachines/play-dom-demo run buildBuild production bundle
pnpm --filter @xmachines/play-dom-demo run previewPreview built bundle
pnpm --filter @xmachines/play-dom-demo run testRun Vitest test suite
pnpm --filter @xmachines/play-dom-demo run test:browserRun browser-focused Vitest suite

Learn More

Type Aliases

Variables

Functions