@xmachines/play-dom
API / @xmachines/play-dom
Vanilla DOM renderer for XMachines Play architecture with signal-driven rendering.
Part of the XMachines Play monorepo.
Installation
npm install @xmachines/play-domPeer dependencies:
npm install xstate @xstate/store @json-render/core @json-render/xstateQuick Start
import { createRenderer, schema } from "@xmachines/play-dom";import { defineCatalog } from "@json-render/core";import { z } from "zod";import type { ComponentFn } from "@xmachines/play-dom";
// 1. Define a catalogconst catalog = defineCatalog(schema, { components: { Home: { props: z.object({ title: z.string() }) }, Login: { props: z.object({ title: z.string(), username: z.string().optional() }) }, }, actions: { login: { params: z.object({ username: z.string() }) }, logout: {}, },});
// 2. Implement componentsconst Home: ComponentFn<typeof catalog, "Home"> = ({ props }) => { const el = document.createElement("section"); el.textContent = props.title; return el;};
const Login: ComponentFn<typeof catalog, "Login"> = ({ props, on }) => { const el = document.createElement("section"); const btn = document.createElement("button"); const submit = on("submit"); btn.addEventListener("click", () => submit.emit()); el.append(btn); return el;};
// 3. Build the factory once (module scope)const mount = createRenderer(catalog, { Home, Login });
// 4. Mount when actor and container are readyconst disconnect = mount(actor, document.getElementById("app")!);
// 5. Cleanup on teardowndisconnect();Usage
createRenderer — one-call factory (recommended)
createRenderer is the simplest integration path. Call it once at module scope with your catalog and component map, then call the returned mount function for each actor/container pair.
import { createRenderer, schema } from "@xmachines/play-dom";import { defineCatalog } from "@json-render/core";
const catalog = defineCatalog(schema, { /* ... */});
const mount = createRenderer(catalog, { MyComponent });
const disconnect = mount(actor, document.getElementById("app")!);// Returns a cleanup function — call it to stop rendering and clear the container.disconnect();createPlayUI — batteries-included factory with full options
Use createPlayUI when you need render error handling, a fallback element, navigation integration, computed functions, or custom validation — or when you need to reference registryResult programmatically (e.g. for executeAction).
Factory-level options (functions, validationFunctions, navigate, onRenderError, fallback) are closed over at creation time and applied on every mount() call. Per-mount options (store, loading) are passed to mount() itself.
import { defineRegistry, createPlayUI, schema } from "@xmachines/play-dom";import { defineCatalog } from "@json-render/core";
const catalog = defineCatalog(schema, { /* ... */});
const registryResult = defineRegistry(catalog, { components: { Home, Login }, actions: { login: async (params, setState) => { /* ... */ }, logout: async () => actor.send({ type: "auth.logout" }), },});
const mount = createPlayUI(registryResult, { onRenderError: console.error, fallback: document.getElementById("loading")!, navigate: (path) => myRouter.push(path), functions: { fullName: (args) => `${args.first} ${args.last}`, },});
const disconnect = mount(actor, document.getElementById("app")!);disconnect();PlayRenderer — class-based lifecycle control
Use PlayRenderer directly when you need explicit connect() / disconnect() lifecycle control, or when integrating into a system that manages the renderer’s lifetime externally.
import { PlayRenderer, defineRegistry, schema } from "@xmachines/play-dom";
const registryResult = defineRegistry(catalog, { components, actions });
const renderer = new PlayRenderer(container, actor, registryResult.registry, { registryResult });
renderer.connect();// Later:renderer.disconnect();Controlled store mode — supply an external StateStore so the renderer shares state with other parts of your app:
import { createAtom } from "@xstate/store";import { xstateStoreStateStore } from "@json-render/xstate";
const atom = createAtom({ username: "" });const store = xstateStoreStateStore({ atom });
const renderer = new PlayRenderer(container, actor, registryResult.registry, { registryResult, store,});renderer.connect();Provider Options
All entry points (createPlayUI, PlayRenderer) accept the same set of UI-provider options via UIProviderOptions. These are forwarded into DomRenderContext on every render pass, making them available to component implementations via ctx.ctx.*.
functions — named compute functions for $computed prop expressions
Enables { "$computed": "name", "args": {...} } dynamic prop values in specs. Each function receives the resolved args object and returns the computed value.
const mount = createPlayUI(registryResult, { functions: { fullName: (args) => `${args.first} ${args.last}`, formatDate: (args) => new Date(args.iso as string).toLocaleDateString(), },});Without functions, any $computed expression silently resolves to undefined (no throw, backward-compatible).
validationFunctions — custom field validation
Provides named validation functions for inline field validation within components. Functions receive (value, args?) and return true (valid) or false (invalid).
Unlike the framework renderers, the DOM renderer has no automatic ValidationProvider tree. Components must invoke validation explicitly using runValidationCheck / runValidation from @json-render/core, passing ctx.ctx.validationFunctions as customFunctions.
import { runValidationCheck } from "@json-render/core";
const mount = createPlayUI(registryResult, { validationFunctions: { isEven: (value) => typeof value === "number" && value % 2 === 0, phoneNumber: (value) => /^\+?[\d\s\-()]{7,}$/.test(String(value)), },});
// Inside a ComponentFn:const MyField: ComponentFn<typeof catalog, "MyField"> = ({ ctx }) => { const result = runValidationCheck( { type: "isEven", message: "must be even" }, { value: someValue, stateModel: {}, customFunctions: ctx.ctx.validationFunctions }, ); // result.valid, result.message};navigate — programmatic navigation from action bindings
A callback invoked when an action binding resolves with onSuccess: { navigate: "/path" }. The resolved path string is passed as the sole argument. Integrate with any router:
// React Router / TanStack Router / any push-based router:const mount = createPlayUI(registryResult, { navigate: (path) => myRouter.push(path),});With a spec binding:
{ "on": { "click": { "action": "submitForm", "onSuccess": { "navigate": "/dashboard" } } }}When submitForm completes successfully, navigate("/dashboard") is called automatically.
The function is also readable by component implementations directly via ctx.ctx.navigate for cases where navigation needs to be triggered outside of an action binding.
onRenderError — unified error handler
Receives (error, name) for three distinct error classes:
- Component render errors — when a
ComponentFnthrows synchronously duringrenderSpec.nameis the catalog component name (e.g."Home"). - Action handler rejections (emit path) — when an
ActionFnthrows or returns a rejected promise duringemit().nameis the catalog action name (e.g."submitForm"). - Action handler rejections (watch path) — when an
ActionFnrejects during awatchbinding callback.nameis the catalog action name.
const mount = createPlayUI(registryResult, { onRenderError: (err, name) => { // Route to your application's error tracking Sentry.captureException(err, { extra: { name } }); },});The argument order matches the upstream RenderErrorHandler type from @json-render/core: error first, name second. This is consistent with the framework renderers (@json-render/solid, @json-render/react).
Without onRenderError, all three error types are logged via console.error and swallowed. No exception propagates, and no unhandled promise rejection is created.
The handler is also available to component implementations via ctx.ctx.onRenderError, enabling components to route their own internal errors through the same channel:
const MyComponent: ComponentFn<typeof catalog, "MyComponent"> = ({ ctx }) => { try { const el = doSomethingRisky(); return el; } catch (err) { ctx.ctx.onRenderError?.(err, "MyComponent"); return null; }};API Summary
XMachines Layer
| Export | Kind | Description |
|---|---|---|
createRenderer(catalog, components) | function | One-call factory — returns mount(actor, container, options?) → disconnect |
createPlayUI(registryResult, options?) | function | Batteries-included factory — returns MountFn |
PlayRenderer | class | Class-based renderer with connect() / disconnect() lifecycle |
defineRegistry(catalog, options) | function | Build a catalog-typed DomRegistry with typed handlers |
renderSpec(...) | function | Pure Spec → DOM renderer (low-level) |
schema | const | The @json-render/dom schema — pass to defineCatalog() |
Key Types
| Type | Description |
|---|---|
ComponentFn<C, K> | Catalog-typed component function — returns HTMLElement | Text | null |
ComponentContext<C, K> | Context passed to each component: props, children, emit, on, bindings, ctx |
ActionFn<C, K> | Catalog-typed action function — receives (params, setState, state) |
EventHandle | Handle returned by on(eventName) — has emit(), shouldPreventDefault, bound |
SetState | State updater: (prev => next) => void |
DefineRegistryResult | Result from defineRegistry — has registry, handlers, executeAction |
PlayDomOptions | Options for PlayRenderer — extends UIProviderOptions |
CreatePlayUIOptions | Options for createPlayUI — extends UIProviderOptions, adds fallback |
MountOptions | Per-mount options for MountFn: store, loading |
MountFn | The mount function returned by createPlayUI: (actor, container, options?) → disconnect |
UIProviderOptions | Shared options: functions, validationFunctions, navigate, onRenderError |
BaseComponentProps<P> | Catalog-agnostic component props for shared component libraries |
DomRegistry | Raw registry type: Record<string, DomComponentRenderer> |
DomSchema | Type of the schema export |
ComputedFunction | Type for named compute functions used with the functions option |
Rendering Behaviour
- Initial render is synchronous — the container is populated before
connect()returns. - Signal-driven re-renders are microtask-deferred —
watchSignalschedules updates on the next microtask queue tick. - Null view clears the container. A
fallbackelement can be shown on initial mount when the view isnull; it is not re-appended if the view later transitions back tonullafter a non-null view. - Double
connect()is safe — callingconnect()on an already-connected renderer auto-disconnects first. disconnect()clears the container and unsubscribes all signal and store watchers.
Testing
# Run all tests (jsdom environment)npm test
# Run with coveragenpm run test:coverageTests live in test/ and use Vitest with a jsdom environment. Coverage thresholds: 80% lines, functions, branches, and statements.
@xmachines/play-dom — Vanilla DOM renderer for XMachines Play architecture.
Public API split into two layers:
XMachines layer (this package):
createRenderer()— one-call factory: returnsmount(actor, container, options?) → disconnectcreatePlayUI()— batteries-included factory with full options: returnsMountFnPlayRenderer— class-based renderer withconnect()/disconnect()lifecyclePlayDomOptions,CreatePlayUIOptions,MountFn,MountOptions
json-render layer (upstreamable to @json-render/dom):
defineRegistry— build a catalog-typed DomRegistryrenderSpec— pure Spec → DOM renderer (uses resolveElementProps from core)ComponentFn— catalog-typed component function typeComponentContext— catalog-typed render context (props, emit, on, children, bindings)ComponentRegistry— catalog-typed registry input typeDomComponentRenderer— raw element-level renderer typeDomRegistry— raw registry typeDomRenderContext— raw render contextEventHandle— event handle returned by on()SetState— state updater function passed to ActionFnCatalogHasActions— conditional type: true when catalog declares actionsBaseComponentProps— base props type for catalog component definitions
Classes
Interfaces
- ComponentContext
- CreatePlayUIOptions
- DefineRegistryResult
- DomRenderContext
- EventHandle
- MountOptions
- PlayDomOptions
- UIProviderOptions
Type Aliases
- ActionFn
- Actions
- BaseComponentProps
- CatalogHasActions
- ComponentFn
- ComponentRegistry
- DefineRegistryOptions
- DomComponentRenderer
- DomRegistry
- DomSchema
- MountFn
- RenderErrorHandler
- SetState
Variables
Functions
References
RenderErrorHandler
Re-exports RenderErrorHandler