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

14 KiB

status, last_updated
status last_updated
draft 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 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

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()

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 contractprepareUpdate 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

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 presentcreateRoot() 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.mdUNode, UElement, URoot, UPrimitive types
  • Context: src/core/context.tsContext 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