--- status: draft 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`. ## 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 research (`docs/research/reconciler/01-reactive-host-bridge.md` and `03-unmount-dispose-support.md`) addresses both gaps comprehensively. ### 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. - **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`. - **`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 research (`03-unmount-dispose-support.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 - Reactive → Host bridge research: `docs/research/reconciler/01-reactive-host-bridge.md` - Unmount & dispose research: `docs/research/reconciler/03-unmount-dispose-support.md` - Key field design: `docs/research/reconciler/00-KEY-FIELD-DESIGN.md`