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.
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 aURootwithtype: "root". This is the only way the"root"literal enters the tree. No other type string produces aURoot.- Any other string or
ComponentFn— returns aUElementwith that type. Component functions are rendered by hosts;h()does not call them.
Child normalization
Children are processed in two steps:
- Flatten —
children.flat(Infinity)recursively flattens nested arrays. This means[Fragment({ children: [...] }), [...nested]]all collapse into a single flat list. The cast to1(instead ofInfinity) is a TypeScript type-level concession; at runtime,Infinityflattens fully. - Filter —
c != null && c !== falseremovesnull,undefined, andfalsevalues from the child list. This matches JSX conventions:{condition && <elem/>}producesfalsewhen 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:
- Remove
keyfromresolvedPropsbefore constructing the element. - Promote
keytoelement.keyas a top-level field. - Ensure component functions never receive
keyin 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_idCounterincrement increateRoot(). 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. Whentype === "root",h()produces aURoot(discriminated union withtype: "root"), not aUElement. This is not a host tag — no host will ever receive"root"as acreateInstancetag.- Children are always flat —
flat(Infinity)+ filter means consumers never receive nested arrays or null/false children from factory output. Hosts and transforms can assumeelement.childrenis a flatUNode[]with no null slots. Note thattruevalues are NOT filtered —{condition}whereconditionistrueproduces atrueUPrimitivechild. Hosts that wanttrueto render as nothing should filter it in theircreateTextInstance. - Props are not deep-cloned —
h()spreads props shallowly. Nested objects are shared references. Consumers must not mutate element.props and expect isolation. Fragmentproduces arrays, not elements — hosts and transforms must handleUNode[]return values from component renders. A Fragment does not appear in the tree._idCounteris 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 onidvalues. Bundle deduplication behavior determines whether copies share the counter.- JSX aliases are identical —
jsx,jsxs, andjsxDEVare 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