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.

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 TC39 Signal watchers
  • watchSignal for reactive nav visibility and debug panel updates
  • Non-browser invariant tests plus browser renderer coverage

Running the Demo

From the repository root:

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

Then open http://localhost:5173.

Step-by-Step Code Flow

Use this order to understand the implementation:

  1. src/main.ts calls definePlayer({ machine: authMachine }) and starts the actor.
  2. initShell(actor, app) 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 watchSignal(actor.state, ...) to update nav button visibility based on auth state.
  6. createDebugPanel(actor, shell) in src/components/DebugPanel.ts uses watchSignal(actor.state, ...) and watchSignal(actor.currentRoute, ...) to drive live debug panel updates.
  7. Browser tests in test/browser/ validate startup rendering and auth interactions.
// src/main.ts (shape)
const createPlayer = definePlayer({ machine: authMachine });
const actor = createPlayer();
actor.start();
const app = document.getElementById("app");
if (!app) throw new Error("Root element not found");
initShell(actor, app);
// src/components/Shell.ts (shape)
const registryResult = defineRegistry(authCatalog, {
components: {
Home,
About,
Contact,
Login,
Dashboard,
Overview,
Stats,
Profile,
Settings,
Navigation,
NavBar: NavBarView,
},
actions: {
login: async (args) =>
actor.send({
type: "auth.login",
username: assertNonNullable(args, "args").username,
}),
logout: async () => actor.send({ type: "auth.logout" }),
route: async (args) => {
const { to, params } = assertNonNullable(args, "args");
actor.send({
type: "play.route",
to,
...(params != null && { params }),
});
},
},
});
const disconnectNavBar = createNavBar(actor, headerEl);
const mount = createPlayUI(registryResult);
const disconnectRenderer = mount(actor, viewContent);
const disconnectDebugPanel = createDebugPanel(actor, shell);
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 watchSignal(actor.state, ...) for auth-driven button visibility
  • src/components/DebugPanel.ts - live debug footer using watchSignal on both actor.state and actor.currentRoute
  • 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 watchSignal only react to actor signals. They do not hold business state or make routing decisions.
  3. Signal-Only Reactivity: watchSignal (from @xmachines/play-signals) wraps the canonical TC39 Signals watcher lifecycle, keeping all reactivity in the actor signal layer rather than in ad-hoc DOM event listeners.

Watcher Lifecycle and Cleanup Contract

This demo follows the canonical watcher 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. Cleanup is explicit: disconnectNavBar(), disconnectRenderer(), and disconnectDebugPanel() are all called from the disconnect function returned by initShell. In this demo, cleanup is called on beforeunload via actor.stop().

Adapter Boundaries

createPlayUI (from @xmachines/play-dom) is passive infrastructure. It translates actor view signals 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
npm run dev -w @xmachines/play-dom-demoStart Vite dev server
npm run build -w @xmachines/play-dom-demoBuild production bundle
npm run preview -w @xmachines/play-dom-demoPreview built bundle
npm run test -w @xmachines/play-dom-demoRun Vitest test suite
npm run test:browser -w @xmachines/play-dom-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-demo
npm run test:browser -w @xmachines/play-dom-demo

Expected result: library-pattern invariant tests pass and the browser renderer suite completes.

Learn More

Type Aliases

Variables

Functions