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/json-render-dom

v0.21.0-xm.7

Published

Framework-free DOM renderer for json-render — pure Spec to DOM, no virtual DOM, no framework runtime

Downloads

2,926

Readme

@xmachines/json-render-dom

Framework-free DOM renderer for json-render: a Spec becomes DOM nodes, with no virtual DOM and no framework runtime.

A component of dom is a function that returns a node. The renderer calls it, places the node, and calls it again when something that it reads changes. Each call gives a new node. The rules below follow from that model and from the plain DOM platform: the browser fires its events on the listeners of a node, and queueMicrotask runs when the microtask queue reaches it.

The element contract

dom implements the element contract of the other json-render renderers:

  • The renderer resolves props through resolveElementProps of @xmachines/json-render-core: $state, $item, $index, $bindState, $bindItem, $cond, $computed, $template, and custom directives.
  • The renderer evaluates visible through evaluateVisibility.
  • defineRegistry gives catalog-typed components and actions. The children and each named slot of a component render when the component reads them. A child that the component never reads does not mount. A parameter that destructures children reads them at once.
  • onRenderError(error, name, info) gets each failure of the code that the renderer runs, one time (see "Errors" below). Give it to renderSpec, to the incremental renderer, or to defineRegistry(catalog, { onRenderError }). The option takes the place of the handler of the registry.
  • fallback renders an element whose type the registry does not hold.
  • navigate runs for onSuccess: { navigate }, and validationFunctions give custom checks to the fields.
  • loading marks a spec that still streams: components read ctx.loading.
  • The built-in actions setState, pushState, removeState, validateForm, push and pop need no handler, and the prompt of the schema names all six.
  • A binding with confirm waits for the user. The default confirm dialog shows it, a component can answer it through ctx.confirmation, and an onConfirm option decides it in the host instead. confirmDialog: false turns the dialog off.
  • The render context gives the capabilities of the hooks of the framework renderers: ctx.action(binding) (useAction: execute, isLoading), ctx.confirmation (useActions: pending, confirm, cancel) and ctx.field(path, config) (useFieldValidation: errors, isValid, validate), ctx.bound(prop) (useBoundProp: value, path, set) and ctx.isVisible(condition) (useIsVisible). A component subscribes to a loading state or to a confirmation and changes its own nodes. A call of ctx.isVisible in the render makes the element a reader of each state path that the condition reads.
  • createUIStream and createChatUI are useUIStream and useChatUI with no framework: a plain object with getters, subscribe and dispose. getJsonRenderMessage(parts) is useJsonRenderMessage.
  • flatToTree, buildSpecFromParts and getTextFromParts (from core) build a spec of a flat element list or of the parts of an AI SDK message.
  • The schema is also at @xmachines/json-render-dom/schema, which loads core only. elementTreeSchema is its deprecated alias, as in the framework renderers. The package exports the types Spec, StateStore, StateModel, Components, OnRenderError, RenderErrorInfo and RenderErrorKind, as the framework renderers do.

Errors

dom follows the error contract of every renderer of the fork:

  • Each failure in code that the renderer runs reaches onRenderError one time, with info = { kind, key, type, action }. The kinds are element, props, visible, root, action and watch. Without a handler, the failure gives one console line, [json-render] <kind> error in <Type> "<key>":, and then the error.
  • A prop or a visible condition that throws fails the boundary of the parent. info.key and info.type name the element whose expression threw.
  • The root has no boundary above it. A failure of its component, a prop or its visible condition has the kind root: the renderer shows nothing and throws nothing, also without a handler, so a root that a stream gives half does not stop the host. A new version of the root renders again.
  • A handler that throws gives its error to reportError one time, and no other element fails.
  • An unknown type with no fallback, and a root key that names no element, give one warning for each renderer.
  • onUncaughtError of the incremental renderer gets only what has no other path: an effect cleanup that throws when its instance goes, and a pass at a commit point that throws outside the render of an element.

Two renderers

  • renderSpec(spec, store, registry, options) renders a spec one time and returns the root node. The host places the node, and the host owns it.
  • createIncrementalRenderer(spec, store, registry, container, options) renders into a container and keeps the tree live. update() renders the elements that a store write changes, setSpec() renders a new spec in place, and dispose() takes the tree out.

createRenderer(catalog, components) is createRenderer of the framework renderers. A dom renderer has no component and no provider tree, so it gives a function that mounts the incremental renderer of the catalog into a container:

const Dashboard = createRenderer(catalog, { Card, Metric });
const view = Dashboard(container, {
  spec,
  state: { user: "Ada" },
  onAction: (name, params) => console.log(name, params),
});
view.setSpec(nextSpec, { loading: false });
view.dispose();
  • One options object takes the place of the props of the renderer and of the providers: spec, store, or state and onStateChange, onAction, handlers, and the options of renderSpec.
  • With no store, the mount makes one from state, and onStateChange gets each write that changes it. onAction gets each action that handlers does not hold. The built-in actions do not call it.
  • The mount subscribes the renderer to the store, runs the watch bindings, and holds one field registry for its life. setSpec(null) takes the tree out, and view.store is the store of the mount.

The rules of the rest of this page are the rules of the incremental renderer, unless they name renderSpec.

The commit point

The incremental renderer shows the store writes of one task at one commit point, in one render. update() renders nothing itself. The commit point is the microtask that the first write queues, or the end of a pass that the host marks with startPass. render() also shows the writes that wait before it returns, and it renders the whole tree again.

To read the DOM right after a write, mark a pass of the host around the write:

const validation = createValidationRegistry(store);
const renderer = createIncrementalRenderer(spec, store, registry, container, {
  validation,
});
const end = validation.startPass?.();
store.set("/count", 1);
end?.(); // the DOM shows the write
  • setSpec renders the new spec and commits its fields before it returns. A call after setSpec, such as focus() on a new input or validateAll(), sees the new spec. A store write of the same handler still waits for its commit point.
  • The writes that wait are one list for the page. The end of a pass of the host on any registry of this package, and each commit point, show the waiting writes of every incremental renderer.
  • A failure of the root at the commit point goes to onRenderError, as each failure does. A pass at the commit point that throws all the same, for example from a store of the host that throws, reaches no caller of update. The renderer empties the container and gives the error to onUncaughtError, or to reportError. An error that onUncaughtError throws goes to reportError, and the other renderers of the page still show their writes.
  • A handler after an await starts a new commit.

Calls between a write and its commit point

A touch, validate, clear or validateAll after a store write of the same task, and before its commit point, sees the fields of the last commit: a field that the write hides, the config of before a new config, and the state of before a fresh start. A write of a field state there renders its holders at the commit point, in the one render of the store writes. So a busy flag that the handler sets and clears around a validateAll shows the value of the end of the handler. With no store write that waits, a write of a field state renders its holders at once.

Passes of the host

The end of a pass of the host shows the writes that wait, commits them, runs the effects of the commit and the checks of the watches, and commits their writes, before it returns. A pass of the host inside another pass commits when it ends too.

A call of the host inside its pass validates with the fields of now, as the screen shows them. A write of the host inside its pass, after a store write that the end of the pass renders, is no write of an owner: a fresh start at the end of the pass drops it.

After the first startPass on a registry, dropReleased does nothing on it, and the registry warns one time. Mark the passes of every caller of one registry in the same way.

Effects

ctx.effect(run) runs run after the commit point of the render. Call it in the render of the component.

  • The effects of a commit run in the order of the tree: a child before its parent, and a sibling before the next one. They run before the checks of the watches of the commit. The end of a pass of the host runs the effects that wait. The components of renderSpec run their effects in a microtask, after the host placed the node.
  • An effect runs one time for each render that gives it, while that render is on the screen. An effect does not run when its instance went or failed, or when a later render of the instance replaced its render before the effects ran: for example a touch and then a store write of one handler.
  • run can return a cleanup function. The renderer runs the cleanups of a render before the first effect of a later render of the same instance. When the instance goes, fails, or a condition hides it, its cleanups run with the effects of the commit that took it out. dispose() runs every cleanup. A renderSpec host gets each cleanup through onEffectCleanup, and it calls the cleanups when it discards the tree.
  • To run an effect only for a change, compare the values in the render with the values of the last effect that ran, give the effect only for a change, and record the values in the effect. Do not record them in the render: a later render can replace that render before its effect runs.
  • An effect that throws takes its element out: the element shows nothing, its fields go, and onRenderError gets the error with the type of the element and the kind element. No later effect of that element runs. A cleanup that throws before an effect does the same. A cleanup that throws when its instance goes reaches onUncaughtError.
  • A chain of 52 nested effects, such as an effect that writes the store at each render of its component, fails its element with "Maximum update depth exceeded", so the loop does not hang the page.
  • A registration that an effect makes through ctx.validation stays when the instance renders again, and it goes when the instance goes.
  • The renderer patches no global. A microtask or a promise that a component queues itself runs in its own time, also after the end of a pass of the host.

Watches

The renderer wires watch bindings only when the options include onWatchSetup. The watches of one change run in one microtask, after the effects of the commit, in the order of their elements on the screen. A watch compares the store of its commit, from before the effects. When an effect writes a watched path, the watch sees that change at the commit point of the write. So a validateForm of a watch validates the fields that the screen shows.

Field validation

ctx.validation is the field registry of the tree, and validateForm validates each field that it holds.

  • The state of a field belongs to the element that registers it. When another element takes over the path, the field starts fresh. A new config from the only holder of a path starts the field fresh too.
  • A holder renders one time at its mount. Its config is a part of the render: a new config of its own field renders nothing again.
  • A holder renders again when the state of its field changes, and so does an element that read the state through ctx.validation.fieldStates. A holder or a reader that calls validate in its render with a new result each time fails after 52 renders with "Maximum update depth exceeded", so the loop does not hang the page.
  • When two holders give one path a config in one commit, the config of the call that registered last holds.
  • A write of a field state for a path that no field holds is dropped while the renderer takes nodes out, and while the cleanups of an instance that goes run.

ctx.store

In the incremental renderer, ctx.store is a view of the store. It gives each member of the store, with the store as this.

  • A component that reads a path through the view while it renders renders again when the value at that path changes. Inside a repeat, a read of the item path, or of a path below it, makes the component a reader of its item. A read of ctx.repeatIndex renders it again when its row moves to another index.
  • A read counts for the component that renders now. A renderer that a component mounts on its ctx.store reads for its own components.
  • A subscription through the view while the component renders belongs to the instance. The renderer stops it when the instance renders again or goes. A subscription in an effect lives until the cleanup of the effect stops it.
  • The view is not the store object of the host, so a compare with that object finds no match. A component that needs the store object gets it from the host.

Focus, removal and events

  • A render gives a new node, so the renderer carries the focus and the text selection to the new node. It finds the element at the same path. When a render adds or drops an element before the focused one, it finds the element that looks the same: the same tag and attributes at the same place among such elements, or else the same tag at the same place among the elements of that tag.
  • The focus goes with the instance that held it. When a new spec moves a focused child and renders it again, the focus stays with the child. When no instance of the focused element renders, no element gets the focus.
  • A keyed repeat moves the fewest rows: it keeps a longest increasing run of rows in place. A focused row that moves gets the focus back, also in an open shadow root.
  • A component that blurs the focused element in its render keeps that blur.
  • A focused element that two-way binds a path shows the value that the user typed. An external write to that path shows when the focus leaves the element.
  • The browser fires blur, focusout, change and focus on the nodes that the renderer removes or replaces. Their listeners run, and each emit runs its binding. A focused element that renders again in place gets a new node, so its blur and focus handlers run at that render. Write a value such as a trim of the text in the input handler, and not in the blur handler.
  • dispose() empties the container and releases every watch subscription, field registration and effect cleanup. A dispose() from the host is a pass of the host.

Differences from @xmachines/json-render-react

dom follows the plain DOM platform and the rules above. A reader who knows the react renderer can expect these differences:

| Case | react | dom | | ------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | setSpec in a handler | renders the new spec after the handler | renders and commits it before setSpec returns | | A holder at its mount | renders two times (its layout effect registers) | renders one time | | A config that changes at each render | fails at the limit of nested updates | settles at once | | Two holders of one path in one commit | the holder whose layout effect runs last wins | the call that registered last wins, so a new row that renders before the changed rows can give another config | | The focused input goes and one that looks the same shows | the focus goes to the body | the input that looks the same gets the focus | | A keyed move in an open shadow root | the focus goes | the focused row keeps the focus | | The native blur of the focused row in a keyed move | react moves other rows | dom keeps a longest increasing run of rows in place | | A blur in the render of a repeat shell whose portal took out its old host | stays | the new shell gets the focus back | | Handlers of a node that the renderer removes | do not run | run for each event that the browser fires | | An external write to a focused bound input | shows at once | shows when the focus leaves | | A touch from a cleanup while the last holder goes | stays | is dropped | | validate in a render with a new result each time | never stops | fails after 52 renders | | A microtask that a component queues itself | runs as useEffect before flushSync returns | runs in its own time; use ctx.effect | | A child that a component read and does not place now | unmounts | stays mounted while the component lives | | The controls of a repeat shell that renders again | keep their nodes | get new nodes; the renderer carries their state by tag and attributes | | A repeat container whose props change | keeps its node | renders again with a new node, and the rows keep theirs |

The table of the renderer differences in XMACHINES.md, and the page /docs/renderer-differences of the docs site, compare all five renderers and name the test that holds each difference.

Usage

import { defineCatalog } from "@xmachines/json-render-core";
import {
  createStateStore,
  defineRegistry,
  renderSpec,
  schema,
} from "@xmachines/json-render-dom";
import type { ComponentFn } from "@xmachines/json-render-dom";
import { z } from "zod";

const catalog = defineCatalog(schema, {
  components: {
    Card: { props: z.object({ title: z.string() }) },
  },
  actions: {},
});

const Card: ComponentFn<typeof catalog, "Card"> = ({ props, children }) => {
  const el = document.createElement("div");
  el.textContent = props.title;
  el.append(...children);
  return el;
};

const { registry } = defineRegistry(catalog, { components: { Card } });
const store = createStateStore(spec.state ?? {});
const node = renderSpec(spec, store, registry);
if (node) container.appendChild(node);

This package is maintained in the XMachines fork of json-render (upstream has no DOM package) and originated as the json-render layer of @xmachines/play-dom. See XMACHINES.md at the repository root.