Files
ujsx/docs/architecture/host-config.md
glm-5.1 09f32f0c64 add architecture docs synced to current source and sdd process
Phase 1 of SDD process: syncing docs/architecture/ to reflect the
existing source code. Eight component documents describe WHAT and WHY
(not HOW) for each module: schema, element factory, reactive layer,
host config, transforms, events, pointers, and build distribution.
Three ADRs capture key decisions (HTML-agnostic core, TypeBox Module
as type registry, Preact signals-core for reactivity). Each doc
documents known reconciler gaps and references the research in
docs/research/reconciler/.

Also adds docs/sdd_process.md (process reference shared across
alkdev projects) matching the taskgraph_ts pattern.
2026-05-18 15:00:33 +00:00

217 lines
14 KiB
Markdown

---
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<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`.
## 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 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`