Files
ujsx/docs/architecture/element-factory.md
glm-5.1 23659233ca address architecture review findings and add review document
Fixes from architecture review (4 critical, 10 warnings):

Critical:
- Fix selectNode/setNode docs to accurately describe prop-key
  navigation behavior including array support and prop-key writes
- Document RenderContext/Density exported types in host-config
- Resolve ADR dual status ambiguity with clarifying note in README
  (frontmatter status = editorial, body Status = decision)
- Effect types already addressed in prior commit

Warnings addressed:
- Add Fragment re-export note to jsx-runtime section in
  build-distribution
- Document childCtx/transformCtx helper functions in transforms.md
- Document render() accepting non-root UNode in host-config
- Add Value.Hash re-entrancy constraint to reconciler.md
- Add true-passthrough constraint and h('root') special case
  to element-factory constraints
- Add _idCounter bundling caveat note

Review document added at docs/reviews/architecture-review-2026-05-18.md
with full findings, source verification table, and recommendations.
2026-05-18 15:36:38 +00:00

9.8 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.
  • h("root", ...) is a special case — the string "root" is effectively a reserved type string. When type === "root", h() produces a URoot (discriminated union with type: "root"), not a UElement. This is not a host tag — no host will ever receive "root" as a createInstance tag.
  • 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. Note that true values are NOT filtered — {condition} where condition is true produces a true UPrimitive child. Hosts that want true to render as nothing should filter it in their createTextInstance.
  • 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. Bundle deduplication behavior determines whether copies share the counter.
  • 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