npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@xmachines/play-dom

v3.0.0

Published

Vanilla DOM renderer for XMachines Play architecture with signal-driven rendering

Readme

@xmachines/play-dom

Vanilla DOM renderer for XMachines Play architecture with signal-driven rendering.

License: MIT Version

Installation

pnpm add @xmachines/play-dom

Peer dependencies:

pnpm add xstate @xstate/store @xmachines/json-render-core @xmachines/json-render-dom @xmachines/json-render-xstate

Quick Start

import { createRenderer, schema } from "@xmachines/play-dom";
import { definePlayer } from "@xmachines/play-xstate";
import { defineCatalog } from "@xmachines/json-render-core";
import { createMachine } from "xstate";
import { z } from "zod";
import type { ComponentFn } from "@xmachines/play-dom";

// 1. Define a catalog
const 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 components
const 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. Create and start an actor — states carry meta.view specs naming catalog components
const machine = createMachine({
	initial: "home",
	states: {
		home: {
			on: { "goto.login": "login" },
			meta: {
				view: {
					root: "root",
					elements: { root: { type: "Home", props: { title: "Home" }, children: [] } },
				},
			},
		},
		login: {
			meta: {
				view: {
					root: "root",
					elements: { root: { type: "Login", props: { title: "Login" }, children: [] } },
				},
			},
		},
	},
});
const actor = definePlayer({ machine })();
actor.start();

// 5. Mount when actor and container are ready
const disconnect = mount(actor, document.getElementById("app")!);

// 6. Cleanup on teardown
disconnect();

Usage

createRenderer — one-call factory (recommended)

createRenderer is the simplest integration path. Call it one time at module scope, with your catalog and your component map. Then call the mount function that it returns, one time for each pair of an actor and a container.

import { createRenderer, schema } from "@xmachines/play-dom";
import { defineCatalog } from "@xmachines/json-render-core";

const catalog = defineCatalog(schema, {/* ... */});

const mount = createRenderer(catalog, { MyComponent });

// actor from the Quick Start
const disconnect = mount(actor, document.getElementById("app")!);
// Returns a cleanup function — call it to stop rendering and clear the container.
disconnect();

createPlayUI — the complete factory with every option

Use createPlayUI when you need a render error handler, a fallback element, a navigation integration, a computed function, or a custom check. Use it also when your code needs the registryResult value, for example for executeAction.

The factory holds the factory options (functions, validationFunctions, navigate, onRenderError, onError, and fallback) from the moment of its creation, and it applies them on every mount() call. Give the mount options (store and loading) to mount() itself.

import { defineRegistry, createPlayUI, schema } from "@xmachines/play-dom";
import { defineCatalog } from "@xmachines/json-render-core";

const catalog = defineCatalog(schema, {/* ... */});

// Home, Login, actor from the Quick Start
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 the explicit connect() and disconnect() control, or when you integrate the renderer into a system that controls its life itself.

import { PlayRenderer, defineRegistry, schema } from "@xmachines/play-dom";

// catalog, actor from the Quick Start; components/actions as in the createPlayUI example
const registryResult = defineRegistry(catalog, { components, actions });

// container: your mount element, e.g. document.getElementById("app")!
const renderer = new PlayRenderer(container, actor, registryResult.registry, { registryResult });

renderer.connect();
// Later:
renderer.disconnect();

Controlled store mode. Give the renderer an external StateStore. The renderer then shares the state with the other parts of your application:

import { createAtom } from "@xstate/store";
import { xstateStoreStateStore } from "@xmachines/json-render-xstate";

const atom = createAtom<Record<string, unknown>>({ username: "" });
const store = xstateStoreStateStore({ atom });

// container, actor, registryResult from the previous example
const renderer = new PlayRenderer(container, actor, registryResult.registry, {
	registryResult,
	store,
});
renderer.connect();

Provider Options

Every entry point (createPlayUI and PlayRenderer) accepts the same UI-provider options, through UIProviderOptions. Each render pass puts the options into DomRenderContext. A component implementation therefore reads them at ctx.ctx.*.

functions — named compute functions for $computed prop expressions

This option permits a dynamic prop value of the form { "$computed": "name", "args": {...} } in a spec. Each function receives the resolved args object, and it 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, every $computed expression resolves to undefined. Nothing throws, and the old behavior stays.

validationFunctions — custom field validation

This option gives the named check functions for a field check inside a component. Each function receives (value, args?). It returns true for a valid value, or false for an invalid value.

The DOM renderer has no ValidationProvider tree, and in this it is different from the framework renderers. Each component must call the check itself, with runValidationCheck or runValidation from @xmachines/json-render-core. Give ctx.ctx.validationFunctions to the call as customFunctions.

import { runValidationCheck } from "@xmachines/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 (catalog from the Quick Start):
const Login: ComponentFn<typeof catalog, "Login"> = ({ ctx }) => {
	const someValue = 42; // the value to validate, e.g. read from an input
	const result = runValidationCheck(
		{ type: "isEven", message: "must be even" },
		{ value: someValue, stateModel: {}, customFunctions: ctx.ctx.validationFunctions },
	);
	// result.valid, result.message
	return null;
};

navigate — programmatic navigation from action bindings

The renderer calls this callback when an action binding resolves with onSuccess: { navigate: "/path" }. The callback receives the resolved path string as its only argument. Use it with every 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" }
		}
	}
}

submitForm completes without an error, and the renderer then calls navigate("/dashboard").

A component implementation can also read the function at ctx.ctx.navigate. Use this when a navigation must start outside an action binding.

onRenderError — unified error handler

The handler receives (error, name) for three different classes of error:

  • A component render error — a ComponentFn throws synchronously during renderSpec. name is then the catalog component name, for example "Home".
  • An action handler rejection on the emit path — an ActionFn throws, or it returns a rejected promise, during emit(). name is then the catalog action name, for example "submitForm".
  • An action handler rejection on the watch path — an ActionFn rejects during a watch binding callback. name is then the catalog action name.
const mount = createPlayUI(registryResult, {
	onRenderError: (err, name) => {
		// Route to your application's error tracking
		Sentry.captureException(err, { extra: { name } });
	},
});

The order of the arguments is the same as in the RenderErrorHandler type of @xmachines/json-render-core: the error first, the name second. The framework renderers (@xmachines/json-render-solid and @xmachines/json-render-react) use the same order.

Without onRenderError, the renderer writes all three types of error to console.error, then stops them. No exception goes to the caller, and no promise rejection stays unhandled.

The renderer contains a failed rebuild — a change of behaviour in 2.3.0

Read this if you catch what mount() or connect() throws.

In 2.2.0 the renderer had no containment. A view that failed the rebuild threw out of mount() and out of the callback of the signal watcher, so a try of the host, an error boundary of the framework around it, or the global handler of the page received it.

In 2.3.0 the renderer contains such a failure ALWAYS. No option turns the containment on, and no option turns it off. The renderer clears the container, it shows the fallback, and mount() returns normally. A host that shows its own error page from a catch around mount() sees that catch never again.

The four framework renderers contain a failed render always, because an error boundary of a framework is not an option that a caller turns off. This change puts play-dom on the same rule.

To escalate a failure, raise it from a task of your own:

const mount = createPlayUI(registryResult, {
	onError: (err) => {
		reportToSentry(err);
		queueMicrotask(() => {
			throw err; // the page keeps its own global handler
		});
	},
});

A throw that leaves the handler itself reaches no caller. The renderer contains it and writes it to console.error, exactly as the four framework renderers do. The five hold this one rule, so a host learns it one time and writes the same handler for each of them.

onError — the failure of a complete rebuild

onRenderError covers one element: the inner renderer contains a render error of a component, and a rejection of an action handler, per element, and the rebuild continues. Some failures escape that boundary — a $computed function that throws during the resolution of a prop, for example — and they abort the complete rebuild.

onError receives such a failure. The renderer contains it with this option and without it: it resets the state of the failed rebuild, so that the next emission makes a complete render again, and it clears the container. connect() renders the first view synchronously, so a bad initial view makes connect() throw no more.

A write of the store takes the same path. The renderer resolves the props of every element that the write touched, so a $computed function that throws on the new state aborts that render too. The renderer contains such a failure as well, and the action handler that wrote the state receives no exception.

The second parameter is the reset, for a retry that the host starts. It renders the view that the actor holds at the moment of the call, so a retry cannot rewind the screen to the view that failed. A reset that the host calls from inside the handler does nothing, because no input changed between the two attempts, and a reset after the provider goes away does nothing. The five renderers hold the same three rules.

A reset of PlayRenderer belongs to ONE connection. connect() starts a connection, and the reset of a report of an older connection does nothing. A host that calls connect() again from inside the handler abandons the connection that reported. The "Retry" button of that report must not render into the connection that took its place. Call the reset of the newest report.

A fourth rule holds in all five: each of them CONTAINS a handler that throws, and writes the throw to console.error. A host that must escalate raises the failure from a task of its own, as the section above shows.

const mount = createPlayUI(registryResult, {
	onError: (err) => reportToSentry(err),
	fallback: document.getElementById("crashed")!,
});

createPlayUI adds the parity with the framework providers: the renderer contains the failure, and it appends the fallback to the container that the failed rebuild left clear. A later view that renders clears the container again, which removes the fallback.

onError says WHERE the report goes, and fallback says what the empty container shows. Neither option turns the containment on. Without onError the renderer writes the contained error to console.error, in the same way as it does for a component that throws without an onRenderError handler. Give onError to send the failure to your own observability tool instead.

One fallback element belongs to one mount. appendChild moves a node. Two mounts of the same factory therefore take the element from each other on every null view, and the container of the first one goes empty without a notice. Build one element for each mount() call.

A fallback producer that throws is contained too — on a null view and after a failed rebuild alike — and it reports to console.error and NOT to onError. After a failed rebuild the renderer calls the producer from inside the containment of the view, so a second trip through onError would report the failure of your fallback as a failure of the view. The container stays empty, and the renderer renders the next view that works.

A component implementation can also read the handler at ctx.ctx.onRenderError. A component therefore sends its own internal errors through the same channel:

// catalog from the Quick Start; doSomethingRisky: your render logic that may throw
const Home: ComponentFn<typeof catalog, "Home"> = ({ ctx }) => {
	try {
		const el = doSomethingRisky();
		return el;
	} catch (err) {
		ctx.ctx.onRenderError?.(err, "Home");
		return null;
	}
};

API Summary

XMachines Layer

| Export | Kind | Description | | ---------------------------------------- | -------- | ------------------------------------------------------------------- | | createRenderer(catalog, components) | function | The one-call factory. Its mount hands a Cleanup back | | createPlayUI(registryResult, options?) | function | The complete factory. It returns a DisposablePlayUI | | PlayRenderer | class | The renderer class, with a connect() and disconnect() lifecycle | | defineRegistry(catalog, options) | function | Build a catalog-typed DomRegistry with typed handlers | | renderSpec(...) | function | The pure low-level Spec → DOM renderer | | schema | const | The @xmachines/json-render-dom schema — pass to defineCatalog() | | Cleanup | type | The release a mount returns, re-exported from @xmachines/play |

Key Types

| Type | Description | | ------------------------ | --------------------------------------------------------------------------------------------- | | ComponentFn<C, K> | Catalog-typed component function — returns HTMLElement \| Text \| null | | ComponentContext<C, K> | The context of each component: props, children, emit, on, bindings, and ctx | | ActionFn<C, K> | Catalog-typed action function — receives (params, setState, state) | | EventHandle | The handle that on(eventName) returns. It has emit(), shouldPreventDefault, and bound | | SetState | State updater: (prev => next) => void | | DefineRegistryResult | The result of defineRegistry. It has registry, handlers, and executeAction | | PlayDomOptions | Options for PlayRenderer — extends UIProviderOptions, adds fallback and onError | | CreatePlayUIOptions | Options for createPlayUI — extends UIProviderOptions, adds fallback and onError | | MountOptions | Per-mount options for MountFn: store, loading | | MountFn | The type of a mount that a consumer writes: (actor, container, options?) → disconnect | | DisposablePlayUI | The mount that createPlayUI builds. It hands a Cleanup back, so using releases it | | 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 | The type of a named compute function for the functions option | | Cleanup | The release of a mount, re-exported from @xmachines/play |

Rendering Behavior

  • The first render is synchronous — the renderer fills the container before connect() returns.
  • A signal-driven render waits for a microtaskwatchSignal puts each update on the next tick of the microtask queue.
  • A null view clears the container, and the renderer then shows the fallback element when you give one. It shows it for every null view, and not for the first mount only — the four framework providers hold the same rule for their placeholder content.
  • A second connect() is safe — a connect() call on a connected renderer disconnects it first.
  • disconnect() clears the container and cancels every signal watcher and store watcher.

Testing

# Run all tests (jsdom environment)
pnpm test

# Run with coverage
pnpm run test:coverage

The tests are in test/. They use Vitest in a jsdom environment. The coverage thresholds are 80% for lines, functions, branches, and statements.