Critical fixes: - Restructure pointers.md: move setNode prop-key writes section under its own heading (was incorrectly nested under selectNode) - Add Context/Density/Direction/RenderContext documentation section to host-config.md (was only a brief constraint bullet) - Advance all 5 ADRs from Status: Proposed → Accepted and frontmatter from status: draft → status: stable (decisions are driving implementation) - Add error handling philosophy section to README Warning/suggestion fixes: - Add isUElement null check (node !== null) to schema.md discriminator table - Add UjsxEnvelope convenience type documentation to events.md - Add Direction Unicode arrow naming note to transforms.md - Standardize all cross-references from absolute docs/research/ paths to relative ../research/ paths across all architecture docs - Fix schema.md ADR references to use relative paths - Reduce redundancy between transforms.md and host-config.md Direction notes - Update all architecture doc frontmatter from draft → stable Deferred: - Performance model section (reconciler not yet built) - Concepts/glossary document (low ROI at current scale) - Line counts in source references (would date quickly)
158 lines
9.8 KiB
Markdown
158 lines
9.8 KiB
Markdown
---
|
|
status: stable
|
|
last_updated: 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.
|
|
|
|
```typescript
|
|
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. **Flatten** — `children.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. **Filter** — `c != 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()
|
|
|
|
```typescript
|
|
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()
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```json
|
|
{ "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](reconciler.md) and [ADR-004](decisions/004-key-as-first-class-field.md)).
|
|
|
|
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](schema.md) — Known Gaps: `key` field on `UElement`) and the reconciler key field ADR ([decisions/004-key-as-first-class-field.md](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 flat** — `flat(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-cloned** — `h()` 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 identical** — `jsx`, `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](decisions/004-key-as-first-class-field.md)
|
|
- Reconciler architecture: [reconciler.md](reconciler.md)
|
|
- Key-based reconciliation research: `../research/reconciler/02-key-based-children-reconciliation.md` |