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)
165 lines
9.7 KiB
Markdown
165 lines
9.7 KiB
Markdown
---
|
|
status: stable
|
|
last_updated: 2026-05-18
|
|
---
|
|
|
|
# Schema
|
|
|
|
TypeBox Module, TypeScript types, type guards, and the design decisions behind them.
|
|
|
|
## Overview
|
|
|
|
The UJSX schema defines the shape of the universal tree: `UNode`, `UElement`, `URoot`, and `UPrimitive`. It serves two purposes:
|
|
|
|
1. **Runtime validation and JSON Schema export** via a TypeBox `Module` — consumed by `Value.Check()` and exportable for consumers that need schema-based contracts.
|
|
2. **Static TypeScript types** for clean compiler inference — defined directly, not derived from the TypeBox schema, because `ComponentFn` and function-typed `PropValue` entries are runtime-only and not serializable.
|
|
|
|
This dual-source approach is intentional. The TypeBox Module handles validation and serialization. The TypeScript types handle ergonomics. They stay in sync through tests, not through derivation.
|
|
|
|
## TypeBox Module
|
|
|
|
The `UJSX` constant is a `Type.Module` that defines six interrelated schemas:
|
|
|
|
```typescript
|
|
export const UJSX = Type.Module({
|
|
UPrimitive: Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]),
|
|
PropValue: Type.Union([
|
|
Type.String(), Type.Number(), Type.Boolean(), Type.Null(),
|
|
Type.Array(Type.Unknown()),
|
|
Type.Ref("UNode"),
|
|
Type.Record(Type.String(), Type.Unknown()),
|
|
Type.Function([...Type.Rest(Type.Array(Type.Unknown()))], Type.Unknown()),
|
|
]),
|
|
UniversalProps: Type.Object(
|
|
{},
|
|
{ additionalProperties: Type.Union([Type.Ref("PropValue"), Type.Undefined()]) },
|
|
),
|
|
UElement: Type.Object({
|
|
type: Type.String(),
|
|
props: Type.Ref("UniversalProps"),
|
|
children: Type.Array(Type.Ref("UNode")),
|
|
}),
|
|
URoot: Type.Object({
|
|
type: Type.Literal("root"),
|
|
props: Type.Ref("UniversalProps"),
|
|
children: Type.Array(Type.Ref("UNode")),
|
|
}),
|
|
UNode: Type.Union([Type.Ref("UPrimitive"), Type.Ref("UElement"), Type.Ref("URoot")]),
|
|
});
|
|
```
|
|
|
|
### Design decisions in the Module
|
|
|
|
- **`UPrimitive`** is `string | number | boolean | null`. No `undefined` — `undefined` is JavaScript's "absent" and should not appear as a tree node value.
|
|
- **`PropValue`** includes `Type.Function(...)`. This means a `PropValue` can be a function (event handlers, component references). The trade-off: props are **not fully serializable**. This is by design — hosts that need serialization strip functions at their boundary.
|
|
- **`UniversalProps`** is an open object (`additionalProperties` allowed). Different hosts need different prop shapes. A DOM host needs `className`; a workflow host needs `operationId`. Constraining props to a closed schema would force hosts to extend UJSX's schema, inverting the dependency. The open schema lets hosts define their own prop contracts.
|
|
- **`UElement.type`** is `Type.String()`, not a union of known element types. Element types are host-defined — UJSX does not maintain a registry of valid type strings.
|
|
- **`URoot`** uses `Type.Literal("root")` as its `type` field. This makes `URoot` a discriminated union member, distinguishable from `UElement` at runtime and in TypeBox validation.
|
|
- **`UNode`** is the union `UPrimitive | UElement | URoot`. This is the fundamental tree node type — every value in a UJSX tree is a `UNode`.
|
|
|
|
### Re-export
|
|
|
|
```typescript
|
|
export { UJSX as schema };
|
|
```
|
|
|
|
The Module is re-exported as `schema` for consumer convenience. Call sites use `UJSX.Import("UElement")` etc. with `Value.Check` for runtime validation.
|
|
|
|
## TypeScript Types
|
|
|
|
The TypeScript types are defined directly, not via `Static<typeof UJSX>`, because `ComponentFn` is a runtime function type that doesn't serialize. Deriving from the TypeBox Module would either omit functions (breaking the type) or include non-serializable types in the schema (breaking validation).
|
|
|
|
```typescript
|
|
export type UPrimitive = string | number | boolean | null;
|
|
export type PropValue =
|
|
| string | number | boolean | null
|
|
| unknown[]
|
|
| UNode
|
|
| Record<string, unknown>
|
|
| ((...args: unknown[]) => unknown);
|
|
export type UniversalProps = Record<string, PropValue | undefined>;
|
|
export type UElement = {
|
|
type: string;
|
|
props: UniversalProps;
|
|
children: UNode[];
|
|
key?: string; // TODO: Not yet implemented. See ADR-004 and reconciler.md
|
|
};
|
|
export type URoot = {
|
|
type: "root";
|
|
props: UniversalProps;
|
|
children: UNode[];
|
|
};
|
|
export type UNode = UPrimitive | UElement | URoot;
|
|
```
|
|
|
|
### Component types
|
|
|
|
```typescript
|
|
export type ComponentFn = (props: UniversalProps & { children?: UNode[] }) => UNode;
|
|
export type UType = string | ComponentFn;
|
|
export interface UComponent<P extends UniversalProps = UniversalProps> {
|
|
(props: P & { children?: UNode[] }): UNode;
|
|
displayName?: string;
|
|
targets?: string[];
|
|
}
|
|
```
|
|
|
|
- **`ComponentFn`** is the type of a function that accepts props (with optional children) and returns a `UNode`. This is the universal component contract.
|
|
- **`UType`** is the union of string element types and component functions — used as the first argument to `h()`.
|
|
- **`UComponent`** adds optional `displayName` (for debugging) and `targets` (for host-specific component routing). It extends `ComponentFn` with metadata.
|
|
|
|
`ComponentFn` and `UType` have no TypeBox representation. They are TypeScript-only. This is why the types are hand-written rather than derived from the schema.
|
|
|
|
## Type Guards
|
|
|
|
Three type guards narrow `UNode` at runtime:
|
|
|
|
```typescript
|
|
function isUElement(node: UNode): node is UElement
|
|
function isURoot(node: UNode): node is URoot
|
|
function isUPrimitive(node: UNode): node is UPrimitive
|
|
```
|
|
|
|
### Discriminators
|
|
|
|
| Guard | Logic | Discriminator |
|
|
|-------|-------|---------------|
|
|
| `isUElement` | `typeof node === "object" && node !== null && "type" in node && "props" in node && "children" in node && node.type !== "root"` | Has `type`/`props`/`children` keys, is not null, and `type` is not `"root"` |
|
|
| `isURoot` | `typeof === "object" && "type" in node && node.type === "root"` | `type === "root"` |
|
|
| `isUPrimitive` | `typeof === "string" \|\| typeof === "number" \|\| typeof === "boolean" \|\| node === null` | Not an object |
|
|
|
|
The `isUElement` guard excludes `URoot` by checking `type !== "root"`. Without this exclusion, `URoot` nodes would match `isUElement` because they have the same structural fields (`type`, `props`, `children`). The `"root"` literal type discriminates them.
|
|
|
|
## Known Gaps
|
|
|
|
### `key` field on `UElement`
|
|
|
|
`UElement` currently has no `key` field. The reconciler needs an identity mechanism to match old children to new children across re-renders. Without `key`, reconciliation is positional-only — the Nth child of the old tree maps to the Nth child of the new tree, which breaks when children are reordered, inserted, or removed.
|
|
|
|
The reconciler architecture (see [reconciler.md](reconciler.md) and [ADR-004](decisions/004-key-as-first-class-field.md)) specifies adding `key?: string` to `UElement` as a first-class field. `h()` extracts `key` from props and promotes it to the element level, so component functions never receive it. `URoot` does not get `key` — roots are unique per `createRoot()` call and never need reconciliation identity.
|
|
|
|
**Status**: Architecture specified, not yet implemented.
|
|
|
|
## Constraints
|
|
|
|
- **Props are not fully serializable** — `PropValue` includes functions. Hosts that need serialization must strip function-valued props at their boundary. This is by design: event handlers and component references are first-class prop values.
|
|
- **UniversalProps is open** — `additionalProperties` allows any key. This prevents UJSX from being a prop gatekeeper and lets hosts define their own contracts without extending UJSX's schema.
|
|
- **TypeScript types are authoritative for type inference** — the TypeBox Module is for runtime validation and JSON Schema export only. Do not use `Static<typeof UJSX>` as the source of truth for TypeScript types; the hand-written types include `ComponentFn` and function-typed `PropValue` entries that the schema cannot express cleanly.
|
|
- **`key` not yet on `UElement`** — `key?: string` is planned (ADR-004) but not yet implemented. Until then, reconciliation is positional-only.
|
|
- **No `key` on `URoot`** — roots are identified by `props.id`, not a `key` field. This is by design; roots are never children of another element.
|
|
- **Type guards are mutually exclusive** — `isUElement`, `isURoot`, and `isUPrimitive` partition the `UNode` space. Every `UNode` matches exactly one guard.
|
|
|
|
## Open Questions
|
|
|
|
1. **Should `key` accept numbers?** React coerces number keys to strings. The current proposal enforces `string` only — simpler, no implicit coercion. Users can wrap in `String()`. See [ADR-004](decisions/004-key-as-first-class-field.md) and research: [00-KEY-FIELD-DESIGN.md](../research/reconciler/00-KEY-FIELD-DESIGN.md).
|
|
2. **Should `UPrimitive` include `undefined`?** Currently `null` represents an explicitly empty value. `undefined` means "absent" and should not appear as a tree node. This is consistent with how JSX treats `undefined` children (rendered to nothing), but some hosts might benefit from an explicit "missing" sentinel. No current use case justifies this.
|
|
3. **Should `UniversalProps` constrain value types per-host?** The open schema allows any `PropValue` for any key. A host that wants stricter prop contracts (e.g., `onClick` must be a function, `className` must be a string) must validate at its own boundary. A future host-typed props system could be layered on top without changing the base schema.
|
|
|
|
## References
|
|
|
|
- Source: `src/core/schema.ts`
|
|
- 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 field design research: `../research/reconciler/00-KEY-FIELD-DESIGN.md`
|
|
- TypeBox Module as type registry: [decisions/002-typebox-module-as-registry.md](decisions/002-typebox-module-as-registry.md)
|
|
- HTML-agnostic core: [decisions/001-html-agnostic-core.md](decisions/001-html-agnostic-core.md) |