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.
This commit is contained in:
156
docs/architecture/element-factory.md
Normal file
156
docs/architecture/element-factory.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
status: draft
|
||||
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 Phase 2 research in `docs/research/reconciler/02-key-based-children-reconciliation.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 (`docs/architecture/schema.md` — Known Gaps: `key` field on `UElement`) and the reconciler key design (`docs/research/reconciler/00-KEY-FIELD-DESIGN.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 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.
|
||||
- **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.
|
||||
- **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 design: `docs/research/reconciler/00-KEY-FIELD-DESIGN.md`
|
||||
- Key-based reconciliation: `docs/research/reconciler/02-key-based-children-reconciliation.md`
|
||||
Reference in New Issue
Block a user