@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
createPlayUIwiringPlayRenderer-equivalent DOM rendering without a framework- Auth machine states (home → login → dashboard) drive DOM updates via atom watchers
watchAtomfor 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/contextprojection;repeat+key,$item-filtered lists, per-row actions sending inspectable machine events) next to ephemeral store state (pushState/removeState/setStatebuilt-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
validateFormbuilt-in, a confirm-gatedresetFormhost action, andonSuccess/onErrorchains - Contact — a chained named action: submit validates, then routes home on success or writes
"$error.message"to state on failure
- Overview — two data planes side by side:
Running the Demo
From the repository root:
pnpm installpnpm --filter @xmachines/play-dom-demo run devThen open http://localhost:3000.
Step-by-Step Code Flow
Use this order to understand the implementation:
src/main.tscreates the page’s inspector withcreateDemoInspector(), callsdefinePlayer({ machine: authMachine, options: { inspect: inspector.inspect }, actor: compose(PlayerActor, withRouting, withView) }), and starts the actor.initShell(actor, app, inspector)fromsrc/components/Shell.tsbuilds the HTML scaffold, mounts a reactiveNavBar, wires the debug panel, and returns a disconnect function.defineRegistry(authCatalog, { components, actions })builds the typedregistryResult— real async action handlers dispatching toactor.send().createPlayUI(registryResult)produces amountfunction;mount(actor, container)wires the registry to the actor — view switches happen automatically whenactor.currentViewchanges.createNavBar(actor, headerEl)insrc/components/NavBar.tsuseswatchAtom(actor.state, ...)to update nav button visibility based on auth state.createDebugPanel(actor, shell, inspector)insrc/components/DebugPanel.tsuseswatchAtom(actor.state, ...)andwatchAtom(actor.currentRoute, ...)to drive live debug panel updates, and renders the Show Inspector button that callsinspector.show().- 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 bootstrapsrc/components/Shell.ts- DOM scaffold,DomRegistryconstruction,createPlayUIwiring,createNavBar, andcreateDebugPanelsrc/components/NavBar.ts- reactive nav bar usingwatchAtom(actor.state, ...)for auth-driven button visibilitysrc/components/DebugPanel.ts- live debug footer usingwatchAtomon bothactor.stateandactor.currentRoute, plus the Show Inspector buttontest/library-pattern.test.ts- architecture boundary and invariant assertionstest/browser/renderer-demo.browser.test.ts- browser-mode renderer coverage
State Machine & Architecture Details
The demo utilizes XMachines architectural invariants:
- Actor Authority: Navigation buttons dispatch
play.routeevents to the actor. The actor evaluates guards and transitions to the correct state — the DOM never decides which view to show. - Passive Infrastructure:
createPlayUIandwatchAtomonly react to actor atoms. They do not hold business state or make routing decisions. - 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:
- The atom notifies the subscription, synchronously, from the
setthat changed it watchAtomqueues one microtask, and it drops each duplicate notification of that batch- 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:
| Command | Description |
|---|---|
pnpm --filter @xmachines/play-dom-demo run dev | Start Vite dev server (http://localhost:3000/) |
pnpm --filter @xmachines/play-dom-demo run build | Build production bundle |
pnpm --filter @xmachines/play-dom-demo run preview | Preview built bundle |
pnpm --filter @xmachines/play-dom-demo run test | Run Vitest test suite |
pnpm --filter @xmachines/play-dom-demo run test:browser | Run browser-focused Vitest suite |
Learn More
Type Aliases
Variables
- About
- authCatalog
- Button
- Contact
- Dashboard
- demoComponents
- Field
- Home
- Login
- NavBarView
- Navigation
- Overview
- Panel
- Profile
- Settings
- Stats