Files
ujsx/docs/architecture/element-factory.md
glm-5.1 da82b52b27 add reconciler architecture docs and update existing docs with cross-references
Phase 2: transitioning reconciler research into architecture documents.

New docs:
- reconciler.md: fiber tree, reconciliation algorithm (signal-driven
  props + key-based children), update scheduling, commit order,
  TypeBox optimization layer, file structure, consumer impact
- lifecycle.md: mount/update/dispose phases, fiber tree disposal,
  partial tree removal, ReactiveRoot.dispose(), finalizeInstance,
  idempotent disposal, computed vs effect cleanup
- ADR-004: key as first-class field on UElement (not a prop)
- ADR-005: signal-driven updates for props, reconciliation for
  structure (hybrid approach, not full tree diffing)

Updated docs:
- README.md: add reconciler.md, lifecycle.md, ADRs 004/005 to
  index; update reconciler roadmap with architecture doc links
- schema.md: add key?: string to UElement type with TODO comment;
  update known gaps to reference ADR-004 and reconciler.md;
  rephrase key constraint as temporary
- element-factory.md: update key extraction gap to reference
  ADR-004 and reconciler.md
- host-config.md: reference reconciler.md and lifecycle.md
  for the reconciler bridge and disposal gaps
- reactive-layer.md: reference reconciler.md and lifecycle.md
  for the signal-host bridge and disposal gaps
- events.md: reference lifecycle.md for unmount/dispose gap
2026-05-18 15:15:13 +00:00

9.2 KiB

status, last_updated
status last_updated
draft 2026-05-18

Element Factory

The element factory (h(), createRoot(), createComponent(), Fragment) and JSX runtime exports that construct the universal tree.

Overview

UJSX trees are built by factory functions, not by class instantiation or builder patterns. The tree is a plain-data structure — objects with type, props, and children — and the factories ensure that structure is consistent, normalized, and free of null/false sentinel values.

The factory layer is thin by design. It does not validate props against schemas, does not call any host, and does not maintain any tree-level state beyond a root ID counter. Its job is to produce UNode values that are safe for any host to consume.

h() Factory

h() is the universal element constructor. It mirrors the hyperscript signature (type, props, ...children) and produces either a UElement or a URoot depending on the type argument.

function h(type: UType, props?: UniversalProps | null, ...children: UNode[]): UElement | URoot

Branch on type

  • type === "root" — returns a URoot with type: "root". This is the only way the "root" literal enters the tree. No other type string produces a URoot.
  • Any other string or ComponentFn — returns a UElement with that type. Component functions are rendered by hosts; h() does not call them.

Child normalization

Children are processed in two steps:

  1. Flattenchildren.flat(Infinity) recursively flattens nested arrays. This means [Fragment({ children: [...] }), [...nested]] all collapse into a single flat list. The cast to 1 (instead of Infinity) is a TypeScript type-level concession; at runtime, Infinity flattens fully.
  2. Filterc != null && c !== false removes null, undefined, and false values from the child list. This matches JSX conventions: {condition && <elem/>} produces false when the condition fails, and conditional rendering should silently drop these values rather than rendering them.

true is not filtered — it passes through as a UPrimitive. This is consistent with how React treats true in JSX (it renders nothing in DOM but is not stripped at the factory level).

Props handling

props is shallow-copied ({ ...props }) if provided, or defaulted to an empty object. This means h() does not mutate the caller's props object, but it also does not deep-clone — nested objects and arrays are shared references.

If props is null or undefined, resolvedProps is {}. This prevents downstream code from needing null-checks on element.props.

createRoot()

function createRoot(id: string | undefined, ...children: UNode[]): URoot

createRoot() exists because h("root", { id }) obscures the purpose. A root is a container — the entry point for a host to mount a tree — and createRoot() makes that explicit.

Auto-generated IDs

When id is undefined, createRoot() generates one from _idCounter — e.g., "root_1", "root_2". This ensures every root has a unique identifier for the host layer to track, without requiring the consumer to invent one.

The counter is a module-level variable (let _idCounter = 0). It is:

  • Not reactive — incrementing it does not trigger signal updates.
  • Not thread-safe — JavaScript is single-threaded, and root creation is not a concurrent operation. If UJSX is used in a worker or multi-context environment, each context gets its own module instance with its own counter.

These trade-offs are acceptable because root creation happens once at mount time, not in render loops.

Child normalization

Same as h(): flat(Infinity) then filter null/undefined/false.

createComponent()

function createComponent<P extends UniversalProps>(
  name: string,
  render: (props: P) => UNode,
  targets?: string[],
): UComponent<P>

createComponent() wraps a render function and attaches metadata. It does not create an element — it returns a UComponent, which is a callable function with displayName and targets properties.

displayName

displayName is for debugging and error messages. It is analogous to React's displayName on function components. When a host logs or inspects a component, displayName provides a human-readable label instead of "anonymous".

targets

targets is a string array hinting which hosts should render this component. For example, ["dom", "ssr"] means the component is relevant to DOM and SSR hosts. A host that does not appear in targets can skip the component.

This is a hint, not a gate. A host is free to render any component regardless of targets. The field exists for host-level optimization and filtering, not for access control.

Type coercion

The implementation casts render to UComponent<P> via unknown. This is safe because UComponent<P> extends the render function's signature with optional properties (displayName, targets), and those properties are assigned immediately after the cast. The cast exists because TypeScript cannot infer that adding properties to a function makes it conform to the interface.

Fragment

function Fragment(props: { children?: UNode[] }): UNode[]

Fragment returns a flat array of UNode values. It does not wrap children in a containing element. This is the defining difference from h("fragment", {}, ...children), which would produce a UElement node in the tree.

Fragment is not a tree node — it is a grouping mechanism that dissolves during construction. Its return type is UNode[], not UNode, reflecting that it expands into the parent's child list rather than occupying its own position.

Child normalization is the same as h(): flatten and filter.

JSX Runtime

export const jsx = h;
export const jsxs = h;
export const jsxDEV = h;

These three exports enable UJSX to serve as a JSX runtime. When a consumer configures their TypeScript/babel/astro JSX transform with:

{ "jsxImportSource": "@alkdev/ujsx" }

The transform emits calls to jsx, jsxs, and jsxDEV from @alkdev/ujsx/jsx-runtime (or the appropriate entry point). All three are aliases for h() because UJSX does not distinguish between static children (jsxs), dynamic children (jsx), and dev-mode calls (jsxDEV) at the factory level. The distinction exists in React for dev warnings and children array vs. single-child optimizations; UJSX normalizes children uniformly, so the aliases are identical.

Consumers who prefer hyperscript-style code can call h() directly. The JSX runtime exports exist solely for toolchain compatibility.

Known Gaps

key prop not extracted

h() currently passes all props through to the element, including key if provided. The reconciler requires key as a first-class field on UElement for identity-based children matching (see reconciler.md and ADR-004).

When key extraction is implemented, h() should:

  1. Remove key from resolvedProps before constructing the element.
  2. Promote key to element.key as a top-level field.
  3. Ensure component functions never receive key in their props.

This is documented in the schema architecture (schema.md — Known Gaps: key field on UElement) and the reconciler key field ADR (decisions/004-key-as-first-class-field.md).

No prop validation

h() does not validate props against the TypeBox schema. It shallow-copies whatever is passed. Runtime validation is the consumer's or host's responsibility. This keeps the factory fast and dependency-free — h() never imports from schema.ts at runtime.

Constraints

  • h() is pure — no side effects beyond the _idCounter increment in createRoot(). It does not call hosts, subscribe to signals, or mutate external state.
  • Children are always flatflat(Infinity) + filter means consumers never receive nested arrays or null/false children from factory output. Hosts and transforms can assume element.children is a flat UNode[] with no null slots.
  • Props are not deep-clonedh() spreads props shallowly. Nested objects are shared references. Consumers must not mutate element.props and expect isolation.
  • Fragment produces arrays, not elements — hosts and transforms must handle UNode[] return values from component renders. A Fragment does not appear in the tree.
  • _idCounter is module-scoped — each module instance has its own counter. If multiple copies of UJSX are loaded (e.g., different package versions), roots from different copies may collide on id values.
  • JSX aliases are identicaljsx, jsxs, and jsxDEV are the same function. UJSX does not differentiate between them. Dev-mode only features (e.g., source location) are not currently supported.

References

  • Source: src/core/h.ts
  • Schema types: src/core/schema.ts
  • Schema architecture: docs/architecture/schema.md
  • Key field ADR: decisions/004-key-as-first-class-field.md
  • Reconciler architecture: reconciler.md
  • Key-based reconciliation research: docs/research/reconciler/02-key-based-children-reconciliation.md