Files
ujsx/docs/architecture/host-config.md
glm-5.1 0d5b9d5ea8 stabilize architecture docs: address review findings and advance to stable
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)
2026-05-18 16:10:24 +00:00

298 lines
18 KiB
Markdown

---
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<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 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<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`** — 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<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 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<string, unknown>;
}
```
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<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:
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`