Critical fixes: - Restructure pointers.md: move setNode prop-key writes section under its own heading (was incorrectly nested under selectNode) - Add Context/Density/Direction/RenderContext documentation section to host-config.md (was only a brief constraint bullet) - Advance all 5 ADRs from Status: Proposed → Accepted and frontmatter from status: draft → status: stable (decisions are driving implementation) - Add error handling philosophy section to README Warning/suggestion fixes: - Add isUElement null check (node !== null) to schema.md discriminator table - Add UjsxEnvelope convenience type documentation to events.md - Add Direction Unicode arrow naming note to transforms.md - Standardize all cross-references from absolute docs/research/ paths to relative ../research/ paths across all architecture docs - Fix schema.md ADR references to use relative paths - Reduce redundancy between transforms.md and host-config.md Direction notes - Update all architecture doc frontmatter from draft → stable Deferred: - Performance model section (reconciler not yet built) - Concepts/glossary document (low ROI at current scale) - Line counts in source references (would date quickly)
18 KiB
status, last_updated
| status | last_updated |
|---|---|
| stable | 2026-05-18 |
HostConfig & createRoot
The HostConfig interface and the createRoot() function that together define UJSX's platform-agnostic rendering contract.
Overview
UJSX separates what to render (the UNode tree) from how to render it (the HostConfig). A HostConfig is a concrete adapter that tells UJSX how to create, organize, and update instances for a specific target — DOM, Three.js, graphology, markdown, or anything else. createRoot() ties a host to a container and exposes render() and unmount().
The current implementation is mount-only: render() walks the tree once, creates instances, and appends children. There is no re-render path, no diffing, and no update cycle. The unmount() method is a stub. This gap is the core motivation for the reconciler research.
HostConfig Interface
interface HostConfig<TTag extends string, Instance, RootCtx> {
name: string;
createRootContext(container: unknown, options?: Record<string, unknown>, context?: Context): RootCtx;
finalizeRoot?(ctx: RootCtx): void;
createInstance(tag: TTag, props: Record<string, unknown>, ctx: RootCtx, parent?: Instance): Instance;
createTextInstance(text: string, ctx: RootCtx, parent?: Instance): Instance;
appendChild(parent: Instance, child: Instance, ctx: RootCtx): void;
insertBefore?(parent: Instance, child: Instance, before: Instance, ctx: RootCtx): void;
removeChild?(parent: Instance, child: Instance, ctx: RootCtx): void;
prepareUpdate?(instance: Instance, tag: TTag, prevProps: Record<string, unknown>, nextProps: Record<string, unknown>, ctx: RootCtx): unknown | null;
commitUpdate?(instance: Instance, payload: unknown, tag: TTag, prevProps: Record<string, unknown>, nextProps: Record<string, unknown>, ctx: RootCtx): void;
emit?(type: string, id: string, payload: unknown): void;
}
Three type parameters
HostConfig is generic over three type parameters that make it platform-agnostic:
TTag— A string literal union constraining allowed element types. A DOM host might defineTTag = "div" | "span" | "input", a graph host might defineTTag = "node" | "edge". The type system prevents hosts from being asked to create instances for tags they don't support.Instance— The host-specific instance type. DOM:HTMLElement. Three.js:Object3D. Graphology:Graph. This is whatcreateInstanceandcreateTextInstancereturn, and whatappendChild/removeChildoperate on.RootCtx— The host-specific root context. Carries whatever the host needs for the lifetime of a root — canvas references, graph instances, renderer handles. Created once bycreateRootContext, passed to every host method.
Lifecycle methods
| Method | Phase | Required | Purpose |
|---|---|---|---|
createRootContext |
Root initialization | Yes | Produce the RootCtx that threads through all subsequent calls |
finalizeRoot |
Root teardown | No | Host cleanup after render or unmount |
createInstance |
Mount | Yes | Create a host instance for an intrinsic element |
createTextInstance |
Mount | Yes | Create a host instance for a primitive text node |
appendChild |
Mount | Yes | Attach a child instance to a parent instance |
insertBefore |
Reorder | No | Insert a child before a specific sibling; hosts that don't need ordering can fall back to appendChild |
removeChild |
Unmount/update | No | Detach a child from a parent |
prepareUpdate |
Update | No | Compare old and new props; return a payload if the instance needs updating, or null to skip |
commitUpdate |
Update | No | Apply the payload from prepareUpdate to the instance |
emit |
Observability | No | Emit telemetry events; hosts that don't need observability can omit this |
Why methods are optional
insertBefore, removeChild, prepareUpdate, commitUpdate, finalizeRoot, and emit are optional because not every host needs them. A markdown renderer has no concept of reordering or in-place updates — it renders once to a string. A graph host may never need insertBefore because edge order is irrelevant. Making these optional lets hosts implement only what they need without stub methods.
Root Interface
interface Root<TTag extends string, Instance, RootCtx> {
host: HostConfig<TTag, Instance, RootCtx>;
ctx: RootCtx;
container: unknown;
context: Context;
render(node: UNode): void;
unmount(): void;
}
Root is the product of createRoot(). It carries:
host— TheHostConfigthat governs all instance operations.ctx— TheRootCtxproduced byhost.createRootContext().container— The opaque container the host renders into (a DOM element, a canvas, a graph instance).context— AContext(fromsrc/core/context.ts) wrapping a signal-basedContextValuewithdensity,target, andmetadatafields. Hosts use this to adapt rendering based on target density or other metadata. If noContextis provided,createRoot()creates a default one.render(node)— Mount the tree (see Mount Pipeline).unmount()— Teardown (see Known Gaps).
The Context connection is intentional: UJSX wants hosts to make density-aware and target-aware decisions without the host needing to know about signal internals. Context provides a reactive ContextValue that hosts can read during createInstance or commitUpdate.
Context, Density, Direction & RenderContext
The context.ts module exports a set of related types and a class that support adaptive rendering and directional transforms:
Context (class)
class Context {
constructor(initial?: Partial<ContextValue>)
get(): ContextValue
get signal(): ReadonlySignal<ContextValue>
set(partial: Partial<ContextValue>): void
subscribe(fn: (value: ContextValue) => void): () => void
fork(overrides: Partial<ContextValue>): Context
}
Context wraps a Preact signal<ContextValue> and provides reactive access to context data. Hosts read context during createInstance or commitUpdate to adapt rendering (e.g., switching layouts based on density). The signal getter exposes the underlying ReadonlySignal for composition with other reactive primitives.
get()— returns the currentContextValue(non-reactive read).set(partial)— shallow-mergespartialinto the current value inside abatch(), triggering any subscriptions.subscribe(fn)— callsfnon every change viaeffect(). Returns a dispose function.fork(overrides)— creates a newContextwith the current values shallow-merged withoverrides. Forked contexts are independent — changes to the fork do not propagate to the parent.
ContextValue
interface ContextValue {
density: Density;
target: string;
metadata: Record<string, unknown>;
}
The shape of a context's value. Defaults:
density:"full"target:"markdown"metadata:{}
Density
type Density = "full" | "compact" | "minimal"
Controls rendering granularity for hosts that support adaptive output. full means render everything; compact and minimal are host-defined — UJSX passes the value through, it does not interpret it. A desktop UI host might use compact to hide labels and minimal to render only essential controls.
Direction
type Direction = "ujsx→mdast" | "mdast→ujsx" | "ujsx→jpath" | "jpath→ujsx" | "ujsx→hast" | "hast→ujsx"
Six directional strings pairing into three bi-directional channels (markdown, JSON path, HTML). Direction is defined in context.ts because it governs both transform rules and render context — it's not transform-specific.
The → character in direction strings is a Unicode right arrow (U+2192). This was chosen for readability over alternatives like ujsx-to-mdast or ujsx2mdast. Consumers should be aware of the non-ASCII characters in IDE autocompletion and linting contexts.
RenderContext
interface RenderContext extends ContextValue {
direction: Direction;
}
A convenience type that adds direction to ContextValue. Used primarily by the transform system to carry conversion direction alongside context data. The transform ctx factory function accepts a Direction and returns a TransformContext (which includes direction), not a RenderContext — RenderContext exists for consumers that want to type-narrow the full context shape.
Exports
All context types are available from the barrel export (@alkdev/ujsx) and the context sub-path:
import { Context } from "@alkdev/ujsx/context"; // class
import type { Density, Direction, RenderContext } from "@alkdev/ujsx/context"; // types
Context is a runtime export; Density, Direction, and RenderContext are type-only exports.
createRoot()
function createRoot<TTag extends string, Instance, RootCtx>(
host: HostConfig<TTag, Instance, RootCtx>,
container: unknown,
options?: Record<string, unknown>,
context?: Context,
): Root<TTag, Instance, RootCtx>
createRoot() wires everything together:
- Calls
host.createRootContext(container, options, context)to producectx. - Falls back to
new Context()if nocontextis provided — every root has aContext, neverundefined. - Defines
mountNodefor recursive tree walking. - Returns a
Rootobject withrender()andunmount().
The container is intentionally unknown — UJSX doesn't prescribe what a container is. DOM hosts pass an HTMLElement, graph hosts pass a Graph instance, test hosts might pass a plain object.
Mount Pipeline
render() calls mountNode recursively. The pipeline handles four node types:
1. null or false
Returned as undefined. These are "holes" in the tree — conditional rendering that chose not to render.
2. Primitives (string | number | boolean | null)
host.createTextInstance() converts the primitive to a string instance. If a parentInst is provided, host.appendChild() attaches it immediately.
3. URoot nodes
URoot is a transparent container — its children are mounted directly into the parent. The root node itself produces no host instance.
4. UElement nodes (intrinsic or function component)
- Function component (
typeof el.type === "function"): The component function is called with{ ...el.props, children: el.children }. Its output is recursively mounted. Function components are transparent — they produce no host instance of their own. - Intrinsic element (
typeof el.type === "string"):host.createInstance()creates the instance. Children are mounted recursively. After children are mounted,host.appendChild(parentInst, inst, ctx)attaches the instance to its parent. This post-order append means parents receive fully-constructed children.
Why post-order append?
Children are appended to their parent after all descendants are created. This guarantees that when appendChild is called, the child is a complete subtree. Host implementations can rely on this ordering — a DOM host can set innerHTML on the child before appending it to the parent, and a graph host can finalize subgraph structure before connecting the parent edge.
emit events
During mount, two event types are emitted:
| Event | When | Payload |
|---|---|---|
instance.create |
After createInstance or createTextInstance |
{ kind: "element" | "text", tag?, props?, value? } |
component.invoke |
After calling a function component | { type: displayName | "anonymous" } |
root.render |
After the full tree is mounted | { childCount } |
These are debugging and observability hooks. The id fields use Date.now() suffixes, which is not collision-safe and will be replaced with proper identifiers when the reconciler introduces fiber nodes with stable identity.
Known Gaps
The Reconciler Gap
This is the critical gap in the current implementation.
HostConfig defines the update contract — prepareUpdate returns a payload describing what changed, commitUpdate applies that payload to the instance. But createRoot().render() is mount-only. It never calls prepareUpdate or commitUpdate because there is no re-render path:
render()has no access to the previously rendered tree. No fiber tree is stored. No diffing occurs.- Calling
render()a second time would create a completely new tree of instances, appending them alongside the first tree rather than updating the existing one. - The update methods exist on
HostConfigas a forward-compatible interface — hosts can implement them now, and the reconciler will call them when it exists.
Similarly, appendChild is the only mutation method called during mount. insertBefore and removeChild are never called because there is no re-render or unmount logic that would reorder or remove nodes.
unmount() is a Stub
unmount() {
host.finalizeRoot?.(ctx);
host.emit?.("root.unmount", `root_${Date.now()}`, {});
}
unmount() calls finalizeRoot and emits an event. It does not:
- Remove instances from their parents (
removeChildis never called) - Dispose signal subscriptions (the
Contextand any reactive effects continue to fire) - Tear down a fiber tree (no fiber tree exists)
This means calling unmount() followed by creating a new root on the same container will likely result in leaked instances and stale signal effects.
The reconciler architecture (reconciler.md) and lifecycle management (lifecycle.md) address both gaps. The research documents (../research/reconciler/01-reactive-host-bridge.md and ../research/reconciler/03-unmount-dispose-support.md) provide the detailed implementation plans.
Event IDs Use Date.now()
The id parameter in emit calls uses ${tag}_${Date.now()} and text_${Date.now()}. These are not stable or unique identifiers. When the reconciler introduces fiber nodes, each fiber will have a stable identity that replaces these placeholder IDs.
No Instance Tree Reference
After render() completes, the created instance tree is not stored anywhere. The Root object does not hold a reference to the root instances. This means:
- The tree is unreachable for updates — there's no way to find an instance to call
prepareUpdateon. unmount()cannot iterate instances because it doesn't have them.
The reconciler solves this by maintaining a fiber tree alongside the instance tree.
Constraints
HostConfigis the sole host integration point — all platform-specific logic lives behind this interface. UJSX never calls DOM APIs, Three.js APIs, or any other platform API directly.TTagconstrains element types — hosts declare the tags they support. Attempting to render an unsupported tag is a type error at compile time. At runtime, the host receives any string astagand must handle unknowns.render()is not idempotent — callingrender()twice on the same root creates two independent instance trees. It does not update the first tree.render()accepts anyUNode— if the node is aURoot, its children are mounted directly. If the node is any otherUNode(element or primitive), it is wrapped in an array and mounted as a single top-level element without a root container.- Function components are synchronous and transparent — they receive props and children, return a
UNode, and produce no host instance. The reconciler research discusses how to handle components that return different tree shapes across renders. Contextis always present —createRoot()guarantees aContextexists on theRoot. If none is provided, a default is created withdensity: "full",target: "markdown", and emptymetadata.RenderContextextendsContextValue— addsdirectionto the context value. See Context, Density, Direction & RenderContext for full documentation of these types.containeris opaque — UJSX passes it tocreateRootContextand stores it onRoot, but never inspects it. The host defines what it means.- Mount is depth-first, post-order — children are fully constructed before being appended to their parent. Hosts can rely on this ordering invariant.
Open Questions
-
Should
render()be renamed tomount()? The current name suggests it can be called multiple times (like React'srender), but it's mount-only. Renaming tomount()would make the semantics clearer and reserverender()for a future reconciler method that does support re-render. -
Should
unmount()callremoveChildfor all instances as a safety measure? Even without the reconciler,unmount()could walk the tree it just created and callremoveChildon each instance. This would at least clean up the host's instance tree, though it wouldn't solve the signal disposal problem. The reconciler research proposes a proper fiber-based disposal. -
Should
HostConfigincludefinalizeInstance? The reconciler architecture (lifecycle.md) proposes afinalizeInstance?(instance, ctx)method for per-instance cleanup (releasing GPU buffers, closing connections). This would let hosts perform targeted teardown when the reconciler removes individual instances. -
How should function component errors propagate? If a component function throws, the error bubbles up through
mountNodewith no host involvement. Should there be ahandleErrormethod onHostConfig? Or should components be wrapped in try/catch at themountNodelevel? -
Should
createRootvalidate that required host methods are present? Currently optional methods are called with?.chaining. A host that needsinsertBeforebut doesn't implement it silently falls back toappendChild. ShouldcreateRootdetect this and warn?
References
- Source:
src/host/config.ts - Schema:
docs/architecture/schema.md—UNode,UElement,URoot,UPrimitivetypes - Context:
src/core/context.ts—Contextclass with signal-based values - Reconciler architecture: reconciler.md
- Lifecycle management: lifecycle.md
- Reconciler research:
../research/reconciler/01-reactive-host-bridge.mdand../research/reconciler/03-unmount-dispose-support.md