--- status: stable last_updated: 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 ```typescript interface HostConfig { name: string; createRootContext(container: unknown, options?: Record, context?: Context): RootCtx; finalizeRoot?(ctx: RootCtx): void; createInstance(tag: TTag, props: Record, 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, nextProps: Record, ctx: RootCtx): unknown | null; commitUpdate?(instance: Instance, payload: unknown, tag: TTag, prevProps: Record, nextProps: Record, 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 define `TTag = "div" | "span" | "input"`, a graph host might define `TTag = "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 what `createInstance` and `createTextInstance` return, and what `appendChild`/`removeChild` operate 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 by `createRootContext`, 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 ```typescript interface Root { host: HostConfig; ctx: RootCtx; container: unknown; context: Context; render(node: UNode): void; unmount(): void; } ``` `Root` is the product of `createRoot()`. It carries: - **`host`** — The `HostConfig` that governs all instance operations. - **`ctx`** — The `RootCtx` produced by `host.createRootContext()`. - **`container`** — The opaque container the host renders into (a DOM element, a canvas, a graph instance). - **`context`** — A `Context` (from `src/core/context.ts`) wrapping a signal-based `ContextValue` with `density`, `target`, and `metadata` fields. Hosts use this to adapt rendering based on target density or other metadata. If no `Context` is 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) ```typescript class Context { constructor(initial?: Partial) get(): ContextValue get signal(): ReadonlySignal set(partial: Partial): void subscribe(fn: (value: ContextValue) => void): () => void fork(overrides: Partial): Context } ``` `Context` wraps a Preact `signal` 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 current `ContextValue` (non-reactive read). - **`set(partial)`** — shallow-merges `partial` into the current value inside a `batch()`, triggering any subscriptions. - **`subscribe(fn)`** — calls `fn` on every change via `effect()`. Returns a dispose function. - **`fork(overrides)`** — creates a new `Context` with the current values shallow-merged with `overrides`. Forked contexts are independent — changes to the fork do not propagate to the parent. ### ContextValue ```typescript interface ContextValue { density: Density; target: string; metadata: Record; } ``` The shape of a context's value. Defaults: - `density`: `"full"` - `target`: `"markdown"` - `metadata`: `{}` ### Density ```typescript 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 ```typescript 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 ```typescript 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: ```typescript 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() ```typescript function createRoot( host: HostConfig, container: unknown, options?: Record, context?: Context, ): Root ``` `createRoot()` wires everything together: 1. Calls `host.createRootContext(container, options, context)` to produce `ctx`. 2. Falls back to `new Context()` if no `context` is provided — every root has a `Context`, never `undefined`. 3. Defines `mountNode` for recursive tree walking. 4. Returns a `Root` object with `render()` and `unmount()`. 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 `HostConfig` as 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 ```typescript 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 (`removeChild` is never called) - Dispose signal subscriptions (the `Context` and 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](reconciler.md)) and lifecycle management ([lifecycle.md](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 `prepareUpdate` on. - `unmount()` cannot iterate instances because it doesn't have them. The reconciler solves this by maintaining a fiber tree alongside the instance tree. ## Constraints - **`HostConfig` is 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. - **`TTag` constrains 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 as `tag` and must handle unknowns. - **`render()` is not idempotent** — calling `render()` twice on the same root creates two independent instance trees. It does not update the first tree. - **`render()` accepts any `UNode`** — if the node is a `URoot`, its children are mounted directly. If the node is any other `UNode` (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. - **`Context` is always present** — `createRoot()` guarantees a `Context` exists on the `Root`. If none is provided, a default is created with `density: "full"`, `target: "markdown"`, and empty `metadata`. - **`RenderContext` extends `ContextValue`** — adds `direction` to the context value. See [Context, Density, Direction & RenderContext](#context-density-direction--rendercontext) for full documentation of these types. - **`container` is opaque** — UJSX passes it to `createRootContext` and stores it on `Root`, 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 1. **Should `render()` be renamed to `mount()`?** The current name suggests it can be called multiple times (like React's `render`), but it's mount-only. Renaming to `mount()` would make the semantics clearer and reserve `render()` for a future reconciler method that does support re-render. 2. **Should `unmount()` call `removeChild` for all instances as a safety measure?** Even without the reconciler, `unmount()` could walk the tree it just created and call `removeChild` on 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. 3. **Should `HostConfig` include `finalizeInstance`?** The reconciler architecture ([lifecycle.md](lifecycle.md)) proposes a `finalizeInstance?(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. 4. **How should function component errors propagate?** If a component function throws, the error bubbles up through `mountNode` with no host involvement. Should there be a `handleError` method on `HostConfig`? Or should components be wrapped in try/catch at the `mountNode` level? 5. **Should `createRoot` validate that required host methods are present?** Currently optional methods are called with `?.` chaining. A host that needs `insertBefore` but doesn't implement it silently falls back to `appendChild`. Should `createRoot` detect this and warn? ## References - Source: `src/host/config.ts` - Schema: `docs/architecture/schema.md` — `UNode`, `UElement`, `URoot`, `UPrimitive` types - Context: `src/core/context.ts` — `Context` class with signal-based values - Reconciler architecture: [reconciler.md](reconciler.md) - Lifecycle management: [lifecycle.md](lifecycle.md) - Reconciler research: `../research/reconciler/01-reactive-host-bridge.md` and `../research/reconciler/03-unmount-dispose-support.md`