@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
Maintainers
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
resolveElementPropsof@xmachines/json-render-core:$state,$item,$index,$bindState,$bindItem,$cond,$computed,$template, and custom directives. - The renderer evaluates
visiblethroughevaluateVisibility. defineRegistrygives 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 destructureschildrenreads them at once.onRenderError(error, name, info)gets each failure of the code that the renderer runs, one time (see "Errors" below). Give it torenderSpec, to the incremental renderer, or todefineRegistry(catalog, { onRenderError }). The option takes the place of the handler of the registry.fallbackrenders an element whose type the registry does not hold.navigateruns foronSuccess: { navigate }, andvalidationFunctionsgive custom checks to the fields.loadingmarks a spec that still streams: components readctx.loading.- The built-in actions
setState,pushState,removeState,validateForm,pushandpopneed no handler, and the prompt of the schema names all six. - A binding with
confirmwaits for the user. The default confirm dialog shows it, a component can answer it throughctx.confirmation, and anonConfirmoption decides it in the host instead.confirmDialog: falseturns 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) andctx.field(path, config)(useFieldValidation:errors,isValid,validate),ctx.bound(prop)(useBoundProp:value,path,set) andctx.isVisible(condition)(useIsVisible). A component subscribes to a loading state or to a confirmation and changes its own nodes. A call ofctx.isVisiblein the render makes the element a reader of each state path that the condition reads. createUIStreamandcreateChatUIareuseUIStreamanduseChatUIwith no framework: a plain object with getters,subscribeanddispose.getJsonRenderMessage(parts)isuseJsonRenderMessage.flatToTree,buildSpecFromPartsandgetTextFromParts(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.elementTreeSchemais its deprecated alias, as in the framework renderers. The package exports the typesSpec,StateStore,StateModel,Components,OnRenderError,RenderErrorInfoandRenderErrorKind, 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
onRenderErrorone time, withinfo = { kind, key, type, action }. The kinds areelement,props,visible,root,actionandwatch. Without a handler, the failure gives one console line,[json-render] <kind> error in <Type> "<key>":, and then the error. - A prop or a
visiblecondition that throws fails the boundary of the parent.info.keyandinfo.typename the element whose expression threw. - The root has no boundary above it. A failure of its component, a prop or its
visiblecondition has the kindroot: 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
reportErrorone 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. onUncaughtErrorof 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, anddispose()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, orstateandonStateChange,onAction,handlers, and the options ofrenderSpec. - With no
store, the mount makes one fromstate, andonStateChangegets each write that changes it.onActiongets each action thathandlersdoes not hold. The built-in actions do not call it. - The mount subscribes the renderer to the store, runs the
watchbindings, and holds one field registry for its life.setSpec(null)takes the tree out, andview.storeis 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 writesetSpecrenders the new spec and commits its fields before it returns. A call aftersetSpec, such asfocus()on a new input orvalidateAll(), 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 ofupdate. The renderer empties the container and gives the error toonUncaughtError, or toreportError. An error thatonUncaughtErrorthrows goes toreportError, and the other renderers of the page still show their writes. - A handler after an
awaitstarts 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
renderSpecrun 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.
runcan 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. ArenderSpechost gets each cleanup throughonEffectCleanup, 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
onRenderErrorgets the error with the type of the element and the kindelement. 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 reachesonUncaughtError. - 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.validationstays 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 callsvalidatein 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.repeatIndexrenders 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.storereads 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,changeandfocuson the nodes that the renderer removes or replaces. Their listeners run, and eachemitruns its binding. A focused element that renders again in place gets a new node, so itsblurandfocushandlers run at that render. Write a value such as a trim of the text in theinputhandler, and not in theblurhandler. dispose()empties the container and releases everywatchsubscription, field registration and effect cleanup. Adispose()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.
