add reconciler architecture docs and update existing docs with cross-references

Phase 2: transitioning reconciler research into architecture documents.

New docs:
- reconciler.md: fiber tree, reconciliation algorithm (signal-driven
  props + key-based children), update scheduling, commit order,
  TypeBox optimization layer, file structure, consumer impact
- lifecycle.md: mount/update/dispose phases, fiber tree disposal,
  partial tree removal, ReactiveRoot.dispose(), finalizeInstance,
  idempotent disposal, computed vs effect cleanup
- ADR-004: key as first-class field on UElement (not a prop)
- ADR-005: signal-driven updates for props, reconciliation for
  structure (hybrid approach, not full tree diffing)

Updated docs:
- README.md: add reconciler.md, lifecycle.md, ADRs 004/005 to
  index; update reconciler roadmap with architecture doc links
- schema.md: add key?: string to UElement type with TODO comment;
  update known gaps to reference ADR-004 and reconciler.md;
  rephrase key constraint as temporary
- element-factory.md: update key extraction gap to reference
  ADR-004 and reconciler.md
- host-config.md: reference reconciler.md and lifecycle.md
  for the reconciler bridge and disposal gaps
- reactive-layer.md: reference reconciler.md and lifecycle.md
  for the signal-host bridge and disposal gaps
- events.md: reference lifecycle.md for unmount/dispose gap
This commit is contained in:
2026-05-18 15:15:13 +00:00
parent 09f32f0c64
commit da82b52b27
10 changed files with 631 additions and 34 deletions

View File

@@ -34,12 +34,12 @@ UJSX is functional but incomplete. The core primitives exist and are tested:
- **Events** — `PubSubLike` and `EventEnvelope` for decoupled event emission - **Events** — `PubSubLike` and `EventEnvelope` for decoupled event emission
- **Pointers** — `ValuePointer`, `selectNode`, `setNode` for tree navigation and targeted mutation - **Pointers** — `ValuePointer`, `selectNode`, `setNode` for tree navigation and targeted mutation
**Known gaps** (to be addressed by the reconciler work documented in `docs/research/reconciler/`): **Known gaps** (documented in architecture docs, planned for reconciler implementation):
- `unmount()` is a stub — no fiber tree teardown, no instance removal, no signal disposal - `unmount()` is a stub — no fiber tree teardown, no instance removal, no signal disposal. See [host-config.md](host-config.md) and [lifecycle.md](lifecycle.md).
- `render()` is mount-only — no re-render, no diffing, no `prepareUpdate`/`commitUpdate` calls - `render()` is mount-only — no re-render, no diffing, no `prepareUpdate`/`commitUpdate` calls. See [reconciler.md](reconciler.md).
- `dispose` functions are no-ops — signal subscriptions leak - `dispose` functions are no-ops — signal subscriptions leak. See [lifecycle.md](lifecycle.md).
- No `key` field on `UElement` — positional matching only - No `key` field on `UElement` — positional matching only. See [ADR-004](decisions/004-key-as-first-class-field.md).
## Architecture Documents ## Architecture Documents
@@ -49,6 +49,8 @@ UJSX is functional but incomplete. The core primitives exist and are tested:
| [element-factory.md](element-factory.md) | h(), createRoot(), createComponent(), Fragment, JSX runtime | | [element-factory.md](element-factory.md) | h(), createRoot(), createComponent(), Fragment, JSX runtime |
| [reactive-layer.md](reactive-layer.md) | ReactiveRoot, reactiveComponent, reactiveElement, signals, disposal gaps | | [reactive-layer.md](reactive-layer.md) | ReactiveRoot, reactiveComponent, reactiveElement, signals, disposal gaps |
| [host-config.md](host-config.md) | HostConfig interface, createRoot(), mount-only rendering, reconciler gap | | [host-config.md](host-config.md) | HostConfig interface, createRoot(), mount-only rendering, reconciler gap |
| [reconciler.md](reconciler.md) | Fiber tree, reconciliation algorithm, update scheduling, TypeBox optimizations |
| [lifecycle.md](lifecycle.md) | Mount, update, unmount/dispose lifecycle, signal cleanup, partial tree teardown |
| [transforms.md](transforms.md) | TransformRegistry, TransformRule, TransformContext, bi-directional transforms | | [transforms.md](transforms.md) | TransformRegistry, TransformRule, TransformContext, bi-directional transforms |
| [events.md](events.md) | EventEnvelope, PubSubLike, UjsxEventMap | | [events.md](events.md) | EventEnvelope, PubSubLike, UjsxEventMap |
| [pointers.md](pointers.md) | ValuePointer, selectNode, setNode, tree navigation | | [pointers.md](pointers.md) | ValuePointer, selectNode, setNode, tree navigation |
@@ -61,6 +63,8 @@ UJSX is functional but incomplete. The core primitives exist and are tested:
| [001](decisions/001-html-agnostic-core.md) | HTML-agnostic core — no DOM-specific props | | [001](decisions/001-html-agnostic-core.md) | HTML-agnostic core — no DOM-specific props |
| [002](decisions/002-typebox-module-as-registry.md) | TypeBox Module IS the type registry | | [002](decisions/002-typebox-module-as-registry.md) | TypeBox Module IS the type registry |
| [003](decisions/003-preact-signals-for-reactivity.md) | Preact signals-core for reactivity | | [003](decisions/003-preact-signals-for-reactivity.md) | Preact signals-core for reactivity |
| [004](decisions/004-key-as-first-class-field.md) | `key` as a first-class field on UElement — not a prop |
| [005](decisions/005-signal-driven-updates-over-tree-diffing.md) | Signal-driven updates for props, reconciliation for structure |
## Consumer Context ## Consumer Context
@@ -84,18 +88,16 @@ An OpenCode plugin that provides UJSX-based template operations. Would use Trans
## Reconciler Roadmap ## Reconciler Roadmap
The reconciler research in `docs/research/reconciler/` documents a phased plan to close the current gaps: The reconciler bridges the reactive layer to the host layer, enabling signal-driven updates and key-based children reconciliation. Architecture docs define the WHAT and WHY; research docs contain the detailed implementation plans.
| Phase | Description | Status | | Phase | Description | Architecture | Research |
|-------|-------------|--------| |-------|-------------|-------------|----------|
| 0 | `key` field on `UElement` | Research complete | | 0 | `key` field on `UElement` | [ADR-004](decisions/004-key-as-first-class-field.md), [schema.md](schema.md) | [00-KEY-FIELD-DESIGN.md](../research/reconciler/00-KEY-FIELD-DESIGN.md) |
| 1 | Reactive → Host bridge (fiber tree, signal-driven updates) | Research complete | | 1 | Reactive → Host bridge (fiber tree, signal-driven updates) | [reconciler.md](reconciler.md), [ADR-005](decisions/005-signal-driven-updates-over-tree-diffing.md) | [01-reactive-host-bridge.md](../research/reconciler/01-reactive-host-bridge.md) |
| 2 | Key-based children reconciliation (LIS algorithm) | Research complete | | 2 | Key-based children reconciliation (LIS algorithm) | [reconciler.md](reconciler.md) | [02-key-based-children-reconciliation.md](../research/reconciler/02-key-based-children-reconciliation.md) |
| 3 | Unmount & dispose support | Research complete | | 3 | Unmount & dispose support | [lifecycle.md](lifecycle.md) | [03-unmount-dispose-support.md](../research/reconciler/03-unmount-dispose-support.md) |
| 4 | TypeBox value optimization layer | Research complete | | 4 | TypeBox value optimization layer | [reconciler.md](reconciler.md) (TypeBox Optimization Layer section) | [04-typebox-optimization-layer.md](../research/reconciler/04-typebox-optimization-layer.md) |
| 5 | Flowgraph HostConfig implementations | Research complete | | 5 | Flowgraph HostConfig implementations | Downstream consumer (`@alkdev/flowgraph`), not ujsx | [05-flowgraph-host-configs.md](../research/reconciler/05-flowgraph-host-configs.md) |
Research docs are in `docs/research/reconciler/`. Architecture docs for the reconciler will be created during the architecture phase of the SDD process, informed by this research.
## Document Lifecycle ## Document Lifecycle

View File

@@ -0,0 +1,76 @@
---
status: draft
last_updated: 2026-05-18
---
# ADR-004: `key` as a First-Class Field on `UElement`
**Status**: Proposed
## Context
The reconciler needs an identity mechanism to match old children to new children across re-renders. Without identity, the reconciler can only do positional comparison, which produces incorrect results for reorderings (destroying and recreating instances instead of moving them).
React uses the `key` prop for this, but putting `key` inside `props` causes problems:
1. **`key` leaks into component props** — React strips `key` before passing props to components, but developers still try to read `this.props.key`, which is `undefined`. The mental model is "it's a prop but not really a prop."
2. **`key` doesn't validate with component schemas** — If a component's TypeBox schema is `Type.Object({ name: Type.String() })`, passing `key` alongside `name` would fail validation because `key` isn't in the schema.
3. **`key` is a reconciler concern, not a component concern** — Components should never need to know their own key. It's metadata for the reconciliation algorithm, not data for the component function.
## Alternatives Considered
- **`key` in `props` (React pattern)**: Store key as a regular prop, strip it in `h()` before passing to components. Rejected because TypeBox schema validation would fail on elements that include `key` without declaring it, and the "strip before passing" pattern is error-prone.
- **Separate `keyMap` on `UElement`**: Store keys in a separate `Map<string, number>` mapping keys to child indices. Rejected because it adds complexity for no benefit over a simpler field — the key is a property of each child, not a property of the parent.
- **No key system (positional reconciliation only)**: Match children by position. Rejected because it produces incorrect results for reorderings — positional matching destroys and recreates instances instead of moving them.
## Decision
Add `key` as a first-class optional field on `UElement`:
```typescript
export type UElement = {
type: string;
props: UniversalProps;
children: UNode[];
key?: string; // new field
};
```
The `h()` factory extracts `key` from the props argument and promotes it to the element level. `key` is never passed to component functions.
```typescript
// h() extracts key:
const { key, ...rest } = props ?? {};
return { type, props: rest, children, key: key as string | undefined };
```
`URoot` does not get a `key` field. Roots are unique per `createRoot()` call and are never children of another element. `URoot.props.id` serves the identification purpose for roots.
The TypeBox schema uses `Type.Optional(Type.String())` for `key`, so validation passes both with and without the field.
## Consequences
### Positive
- **Clean separation** — `key` is metadata for the reconciler, not data for components. No "strip before passing" step needed.
- **Schema-compatible** — `key` is outside `props`, so component schemas don't need to declare it.
- **Type-safe** — `key?: string` is explicit in the TypeScript type, not hidden in a union prop type.
- **Backward compatible** — elements without `key` continue to work with positional matching. The field is optional in both TypeScript and TypeBox.
### Negative
- **`UElement` shape changes** — adding `key` is a non-breaking addition (optional field), but any code that spreads `UElement` into object literals will pick up `key: undefined` on unkeyed elements.
- **Serialization includes `key` when present** — JSON-serialized elements will include `"key": "..."` when provided, adding a field that wasn't there before. Consumers that validate serialized UElements against the old schema (without `key`) will need to update their schemas.
### Neutral
- **`key` accepts `string` only, not `number`** — React accepts numbers and coerces to strings. For UJSX, `string` only is simpler and avoids implicit coercion. Users can wrap in `String()` if needed.
- **Duplicate keys use last-wins** — if two siblings have the same key, the reconciler warns and uses the last occurrence. This matches React behavior.
## References
- Key field design research: `docs/research/reconciler/00-KEY-FIELD-DESIGN.md`
- Schema architecture: [schema.md](../schema.md)
- Element factory: [element-factory.md](../element-factory.md)
- Reconciler algorithm: [reconciler.md](../reconciler.md)

View File

@@ -0,0 +1,65 @@
---
status: draft
last_updated: 2026-05-18
---
# ADR-005: Signal-Driven Updates Over Tree Diffing
**Status**: Proposed
## Context
When an element tree changes, the reconciler must determine which host instances need updating. Two broad approaches exist:
1. **Tree diffing** — compare the entire previous tree snapshot to the next tree snapshot, walk both trees in lockstep, and generate a diff (add/remove/update/move operations). This is React's approach with its virtual DOM.
2. **Signal-driven updates** — subscribe to the specific signals that drive each element's props. When a signal changes, only the elements that depend on that signal update. No tree-level diffing for property changes.
## Alternatives Considered
- **Full tree diffing (React-style)**: Re-render the entire tree on every change, diff old vs new, generate a patch list. Rejected because (a) signals already provide fine-grained reactivity — tree diffing would re-derive information that signals already computed, and (b) UJSX's tree structures are often small (10-200 elements for workflow configs), but the diffing overhead scales with tree size regardless.
- **Incremental DOM (Glimmer-style)**: Reconstruct the tree eagerly during render, but skip creation for unchanged nodes. Rejected because it still requires walking the entire tree structure on every update, even when only one prop changed.
- **Fine-grained reactive only (Solid-style)**: No reconciliation at all — every prop is a signal, every element subscribes individually. Rejected because it doesn't handle structural changes (add/remove/reorder children) — signals can't express "this child was removed" without a reconciliation step.
## Decision
Use signals for property updates and reconciliation for structural changes. This hybrid approach recognizes that the two types of changes have fundamentally different characteristics:
- **Property changes** (color, status, text content) — signals already know which properties changed. Walking the tree to rediscover this is wasted work.
- **Structural changes** (add/remove/reorder children) — signals can't express these. You need identity-based reconciliation (key matching, LIS for moves).
The reconciler is **signal-driven for props, reconciliation-driven for children**. When a `ReactiveNode`'s signal fires:
1. The `computed` that depends on the signal recomputes → produces a new `UElement`
2. The reconciler compares the fiber's current props to the new element's props via `prepareUpdate`
3. If `prepareUpdate` returns a non-null payload, queue an "update" effect
4. Structural changes (different children, different keys) are reconciled using key-based matching
This means 90% of updates (property changes) bypass tree diffing entirely. The reconciliation algorithm is only invoked for structural changes.
## Consequences
### Positive
- **Signal changes are O(1) for property updates** — no tree walk, no diffing. The `effect` fires, `prepareUpdate` runs on exactly one fiber, `commitUpdate` applies the change.
- **No wasted work on unchanged subtrees** — signals that don't fire mean no reconciliation for their consumers. A color change in glyph #47 doesn't touch glyphs #1-46.
- **Natural batching** — `@preact/signals-core` already batches signal writes. Multiple prop changes in one batch → one reconciliation pass.
- **Simpler mental model** — "signals for data, reconciliation for structure" is easier to reason about than "diff the whole tree on every change."
### Negative
- **Two update paths** — developers must understand when to use signals vs. reconciliation. Property changes are signal-driven; structural changes require reconciliation. This is documented but adds conceptual surface.
- **Signal graph must be correct** — if a signal is not subscribed (e.g., a `computed` that doesn't track all its dependencies), property updates will be missed. Preact's signal system handles this automatically, but custom signal wiring could introduce bugs.
- **Reconciliation is still needed** — this approach doesn't eliminate reconciliation. It just reduces its scope to structural changes. The full key-based matching algorithm is still needed for add/remove/reorder.
### Neutral
- **Fiber tree is the reconciler's internal state** — consumers and hosts never access fibers directly. The fiber tree is an implementation detail that bridges signals to host instances.
- **`key` is required for correct reconciliation of lists** — without keys, children are matched by position, which is wrong for reorderings. See [ADR-004](004-key-as-first-class-field.md).
## References
- Reactive → Host bridge research: `docs/research/reconciler/01-reactive-host-bridge.md`
- Key-based children reconciliation: `docs/research/reconciler/02-key-based-children-reconciliation.md`
- Reconciler architecture: [reconciler.md](../reconciler.md)
- Reactive layer: [reactive-layer.md](../reactive-layer.md)
- HostConfig interface: [host-config.md](../host-config.md)

View File

@@ -124,7 +124,7 @@ Consumers who prefer hyperscript-style code can call `h()` directly. The JSX run
### `key` prop not extracted ### `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`). `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: When `key` extraction is implemented, `h()` should:
@@ -132,7 +132,7 @@ When `key` extraction is implemented, `h()` should:
2. Promote `key` to `element.key` as a top-level field. 2. Promote `key` to `element.key` as a top-level field.
3. Ensure component functions never receive `key` in their props. 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`). 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 ### No prop validation
@@ -152,5 +152,6 @@ This is documented in the schema architecture (`docs/architecture/schema.md` —
- Source: `src/core/h.ts` - Source: `src/core/h.ts`
- Schema types: `src/core/schema.ts` - Schema types: `src/core/schema.ts`
- Schema architecture: `docs/architecture/schema.md` - Schema architecture: `docs/architecture/schema.md`
- Key field design: `docs/research/reconciler/00-KEY-FIELD-DESIGN.md` - Key field ADR: [decisions/004-key-as-first-class-field.md](decisions/004-key-as-first-class-field.md)
- Key-based reconciliation: `docs/research/reconciler/02-key-based-children-reconciliation.md` - Reconciler architecture: [reconciler.md](reconciler.md)
- Key-based reconciliation research: `docs/research/reconciler/02-key-based-children-reconciliation.md`

View File

@@ -136,7 +136,7 @@ The event map does not include error events (e.g., `instance.error`, `render.err
### No cleanup on unmount ### No cleanup on unmount
The current `unmount()` implementation in `HostConfig` does not tear down event subscriptions. If a pubsub emitter was wired to the host's `emit()` during render, unmount does not unsubscribe. This gap is shared with the broader reconciler disposal gap — see [reactive-layer.md](reactive-layer.md) (no-op `dispose` functions) and the unmount & dispose research ([03-unmount-dispose-support.md](../../research/reconciler/03-unmount-dispose-support.md)). The current `unmount()` implementation in `HostConfig` does not tear down event subscriptions. If a pubsub emitter was wired to the host's `emit()` during render, unmount does not unsubscribe. This gap is shared with the broader reconciler disposal gap — see [lifecycle.md](lifecycle.md) and the unmount & dispose research (`docs/research/reconciler/03-unmount-dispose-support.md`).
## Constraints ## Constraints

View File

@@ -170,7 +170,7 @@ unmount() {
This means calling `unmount()` followed by creating a new root on the same container will likely result in leaked instances and stale signal effects. This means calling `unmount()` followed by creating a new root on the same container will likely result in leaked instances and stale signal effects.
The reconciler research (`docs/research/reconciler/01-reactive-host-bridge.md` and `03-unmount-dispose-support.md`) addresses both gaps comprehensively. The reconciler architecture ([reconciler.md](reconciler.md)) and lifecycle management ([lifecycle.md](lifecycle.md)) address both gaps. The research documents (`docs/research/reconciler/01-reactive-host-bridge.md` and `03-unmount-dispose-support.md`) provide the detailed implementation plans.
### Event IDs Use `Date.now()` ### Event IDs Use `Date.now()`
@@ -201,7 +201,7 @@ The reconciler solves this by maintaining a fiber tree alongside the instance tr
2. **Should `unmount()` call `removeChild` for all instances as a safety measure?** Even without the reconciler, `unmount()` could walk the tree it just created and call `removeChild` on each instance. This would at least clean up the host's instance tree, though it wouldn't solve the signal disposal problem. The reconciler research proposes a proper fiber-based disposal. 2. **Should `unmount()` call `removeChild` for all instances as a safety measure?** Even without the reconciler, `unmount()` could walk the tree it just created and call `removeChild` on each instance. This would at least clean up the host's instance tree, though it wouldn't solve the signal disposal problem. The reconciler research proposes a proper fiber-based disposal.
3. **Should `HostConfig` include `finalizeInstance`?** The reconciler research (`03-unmount-dispose-support.md`) proposes a `finalizeInstance?(instance, ctx)` method for per-instance cleanup (releasing GPU buffers, closing connections). This would let hosts perform targeted teardown when the reconciler removes individual instances. 3. **Should `HostConfig` include `finalizeInstance`?** The reconciler architecture ([lifecycle.md](lifecycle.md)) proposes a `finalizeInstance?(instance, ctx)` method for per-instance cleanup (releasing GPU buffers, closing connections). This would let hosts perform targeted teardown when the reconciler removes individual instances.
4. **How should function component errors propagate?** If a component function throws, the error bubbles up through `mountNode` with no host involvement. Should there be a `handleError` method on `HostConfig`? Or should components be wrapped in try/catch at the `mountNode` level? 4. **How should function component errors propagate?** If a component function throws, the error bubbles up through `mountNode` with no host involvement. Should there be a `handleError` method on `HostConfig`? Or should components be wrapped in try/catch at the `mountNode` level?
@@ -212,6 +212,6 @@ The reconciler solves this by maintaining a fiber tree alongside the instance tr
- Source: `src/host/config.ts` - Source: `src/host/config.ts`
- Schema: `docs/architecture/schema.md``UNode`, `UElement`, `URoot`, `UPrimitive` types - Schema: `docs/architecture/schema.md``UNode`, `UElement`, `URoot`, `UPrimitive` types
- Context: `src/core/context.ts``Context` class with signal-based values - Context: `src/core/context.ts``Context` class with signal-based values
- Reactive → Host bridge research: `docs/research/reconciler/01-reactive-host-bridge.md` - Reconciler architecture: [reconciler.md](reconciler.md)
- Unmount & dispose research: `docs/research/reconciler/03-unmount-dispose-support.md` - Lifecycle management: [lifecycle.md](lifecycle.md)
- Key field design: `docs/research/reconciler/00-KEY-FIELD-DESIGN.md` - Reconciler research: `docs/research/reconciler/01-reactive-host-bridge.md` and `03-unmount-dispose-support.md`

View File

@@ -0,0 +1,183 @@
---
status: draft
last_updated: 2026-05-18
---
# Lifecycle Management
How UJSX handles mounting, updating, and disposing element trees and their associated resources.
## Overview
Lifecycle management in UJSX covers three phases:
1. **Mount** — creating host instances and building the fiber tree (implemented, currently mount-only)
2. **Update** — propagating signal changes through `prepareUpdate`/`commitUpdate` (planned, see [reconciler.md](reconciler.md))
3. **Unmount/Dispose** — tearing down host instances, cleaning up signal subscriptions, removing fibers (planned, this document)
The current implementation handles mount correctly but treats update as no-op and unmount as a stub. This document describes the target state for the dispose phase, with references to the reconciler research.
## The Disposal Problem
Without proper disposal:
- **Re-rendering a root leaks the old fiber tree** — instances, signal subscriptions, and fiber nodes are never cleaned up
- **Signal subscriptions from unmounted components continue to fire** — `effect()` return values are discarded, leaving zombie computations
- **Conditional rendering is impossible** — adding/removing a subtree (e.g., a workflow branch) accumulates orphaned subscriptions
- **`unmount()` is a no-op for resources** — it calls `finalizeRoot()` and emits an event, but does not tear down instances or subscriptions
The disposal system closes these gaps by providing full teardown for:
- **Root unmount** — dispose the entire fiber tree and all signal subscriptions
- **Partial tree removal** — dispose a child fiber and its subtree when reconciliation removes it
- **Individual signal cleanup** — each `effect()` created during mounting tracks its disposer for cleanup
## Current State (Gaps)
### `unmount()` in `src/host/config.ts`
```typescript
unmount(): void {
this.host.finalizeRoot?.(this.ctx);
this.host.emit?.("root.unmount", `root_${Date.now()}`, {});
// No fiber tree teardown
// No signal disposal
// No instance removal
}
```
### `dispose` in `src/core/reactive.ts`
```typescript
// reactiveComponent and reactiveElement
dispose: () => {} // no-op
```
### `ReactiveRoot` in `src/core/reactive.ts`
- `render()` creates an `effect` but doesn't auto-dispose it on unmount
- `subscribe()` returns a disposer but `ReactiveRoot` doesn't track subscribers
- No `destroy()` or `dispose()` method
## Target Architecture
### Fiber Tree Disposal
Each `Fiber` node tracks its lifecycle resources:
```typescript
interface Fiber<I> {
instance: I;
tag: string;
props: Record<string, unknown>;
key: string | undefined;
children: Fiber<I>[];
parent: Fiber<I> | null;
effect: Effect | null;
signalDisposers: (() => void)[]; // effect cleanup functions
prevProps: Record<string, unknown> | null;
}
```
The `signalDisposers` array stores every `effect()` return value created during this fiber's mounting. When the fiber is disposed, all disposers are called.
### Root Unmount Flow
```
root.unmount()
→ disposeFiber(rootFiber, ctx)
→ for each child (bottom-up, children before parent):
disposeFiber(child, ctx)
host.removeChild(parent.instance, child.instance, ctx)
→ host.finalizeInstance?.(child.instance, ctx) // per-instance cleanup
→ call each disposer in fiber.signalDisposers
→ clear fiber state (children = [], parent = null, effect = null)
→ host.finalizeRoot(ctx)
```
Bottom-up disposal ensures children are removed before their parents, which is the correct order for most host environments (DOM, graphology, Three.js scene graphs).
### Partial Tree Disposal
During reconciliation, when children are removed:
```
reconcileChildren identifies removed fibers
→ for each removed fiber:
disposeFiber(removedFiber, ctx)
// removeChild is called during commitMutations, not here
```
This supports conditional rendering: when a component's output changes from `{ type: "panel", children: [...] }` to `{ type: "panel", children: [] }`, the removed children's fiber trees are fully disposed.
### Host Notification
`HostConfig` gains an optional `finalizeInstance` method for per-instance cleanup:
```typescript
export interface HostConfig<TTag extends string, Instance, RootCtx> {
// ... existing methods ...
finalizeInstance?(instance: Instance, ctx: RootCtx): void;
}
```
This allows hosts to perform cleanup when an instance is removed (e.g., releasing GPU buffer slots, closing database connections, removing graphology nodes).
### ReactiveRoot Disposal
`ReactiveRoot` gains a `dispose()` method that:
- Calls any render effect disposer and clears it
- Iterates all tracked subscriber disposers and calls each one
- Clears internal tracking state
The `subscribe()` method is updated to track disposer returns: each `effect()` created by `subscribe()` has its disposer stored in an internal list, and the returned unsubscribe function both calls the disposer and removes it from tracking.
Both `dispose()` and the returned unsubscribe functions are **idempotent** — calling them multiple times is safe.
### Disposal Idempotency
All disposal operations must be idempotent. Calling `dispose()` twice must not error. This is achieved by:
- Setting `fiber.signalDisposers = []` after disposal (no double-calling)
- Setting `fiber.parent = null` after disposal (no double-remove from parent)
- `ReactiveRoot.dispose()` clearing `renderDisposer` and `subscriberDisposers`
### `computed` vs `effect` Cleanup
`computed()` signals in Preact's implementation do not need explicit disposal — they're garbage collected when nothing references them. Only `effect()` return values need to be called for cleanup. This distinction is important:
- `reactiveComponent` creates a `computed` for the component output → no cleanup needed
- `reactiveElement` creates a `computed` for the element → no cleanup needed
- Signal subscriptions created during mounting use `effect()` → must be tracked and disposed
## Changes to Existing Files
| File | Change |
|------|--------|
| `src/host/config.ts` | `unmount()` calls `disposeFiber()`. Add `finalizeInstance` to `HostConfig`. |
| `src/core/reactive.ts` | `ReactiveRoot.dispose()` method. `reactiveComponent`/`reactiveElement` track and call real disposers. `subscribe()` tracks disposer returns. |
| `src/host/fiber.ts` | (New) `Fiber` type with `signalDisposers`, `disposeFiber()` function |
| `src/host/reconcile.ts` | (New) Partial tree disposal during `reconcileChildren` |
## Constraints
- **Bottom-up disposal** — children are removed before their parents. This is the correct order for hierarchical instance systems (DOM, scene graphs, DAGs).
- **No double-dispose** — all disposal operations are idempotent. Calling `dispose()` on a fiber or `ReactiveRoot` twice is safe.
- **`computed` signals don't need disposal** — only `effect()` subscriptions need cleanup. `computed` signals are garbage collected when their references are cleared.
- **`finalizeInstance` is optional** — hosts that don't need per-instance cleanup can leave it undefined. This is backward compatible with existing HostConfig implementations.
- **Hosts own instance removal** — `disposeFiber()` disposes resources (signal subscriptions, fiber state) but does NOT call `host.removeChild()`. Instance removal happens during the commit phase, in a specific order, as part of the reconciliation algorithm. This separation ensures correct mutation ordering.
## Open Questions
1. **Should `disposeFiber` handle both resource disposal and instance removal?** Option A: `disposeFiber` does everything (dispose signals, remove from parent). Option B: `disposeFiber` only disposes resources, and `commitMutations` handles removal ordering. Option B is better for batched mutations.
2. **Should `ReactiveRoot` auto-dispose on `root.unmount()`?** If they're connected (the root owns the `ReactiveRoot`), then `unmount``dispose` is natural. But they might not be 1:1 — a `ReactiveRoot` could drive multiple roots. Decoupling is safer.
3. **What about async disposal?** Some host instances might have async cleanup (closing network connections, waiting for GPU commands). `finalizeInstance` is synchronous. Should there be an async variant `asyncFinalizeInstance`? Not in the initial implementation — hosts with async cleanup should handle it internally and resolve when ready.
## References
- Unmount & dispose research: `docs/research/reconciler/03-unmount-dispose-support.md`
- Reconciler architecture: [reconciler.md](reconciler.md)
- HostConfig interface: [host-config.md](host-config.md)
- Reactive layer: [reactive-layer.md](reactive-layer.md)
- ADR-005 (signal-driven updates): [decisions/005-signal-driven-updates-over-tree-diffing.md](decisions/005-signal-driven-updates-over-tree-diffing.md)

View File

@@ -125,11 +125,11 @@ Calling `render()` a second time replaces `this.renderDisposer` without disposin
### No connection to HostConfig reconciler ### No connection to HostConfig reconciler
`ReactiveRoot.render()` emits events, but nothing consumes those events to call `HostConfig.prepareUpdate`/`commitUpdate`. The signal layer and the host layer are two separate islands. The reconciler research ([01-reactive-host-bridge.md](../../research/reconciler/01-reactive-host-bridge.md)) proposes a fiber-based bridge: `ReactiveRoot` signal changes trigger a reconciliation pass that diffs props and calls `HostConfig` update methods. `ReactiveRoot.render()` emits events, but nothing consumes those events to call `HostConfig.prepareUpdate`/`commitUpdate`. The signal layer and the host layer are two separate islands. The reconciler architecture ([reconciler.md](reconciler.md)) proposes a fiber-based bridge: `ReactiveRoot` signal changes trigger a reconciliation pass that diffs props and calls `HostConfig` update methods.
### No auto-dispose on unmount ### No auto-dispose on unmount
`ReactiveRoot` has no `unmount()` or `destroy()` method. Effects created by `subscribe()` and `render()` are never automatically torn down. The reconciler research ([03-unmount-dispose-support.md](../../research/reconciler/03-unmount-dispose-support.md)) addresses this. `ReactiveRoot` has no `unmount()` or `destroy()` method. Effects created by `subscribe()` and `render()` are never automatically torn down. The lifecycle management architecture ([lifecycle.md](lifecycle.md)) addresses this, with `ReactiveRoot.dispose()` tracking and cleaning up all subscriber and render effect disposers.
## Constraints ## Constraints
@@ -143,13 +143,15 @@ Calling `render()` a second time replaces `this.renderDisposer` without disposin
1. **Should `ReactiveNode.dispose` be implemented using `effect` cleanup or stored-disposer patterns?** The current `computed` signals have no public dispose API in `@preact/signals-core`. Disposal requires either switching to an effect-based approach (where each `computed` is tracked by an `effect` that can be disposed) or maintaining an explicit disposer list. 1. **Should `ReactiveNode.dispose` be implemented using `effect` cleanup or stored-disposer patterns?** The current `computed` signals have no public dispose API in `@preact/signals-core`. Disposal requires either switching to an effect-based approach (where each `computed` is tracked by an `effect` that can be disposed) or maintaining an explicit disposer list.
2. **Should `ReactiveRoot` track all subscription disposers?** Adding an internal `Set<() => void>` for active subscribers would allow `ReactiveRoot` to clean up on unmount. This creates a lifecycle coupling — `ReactiveRoot` would need a `destroy()` method. 2. **Should `ReactiveRoot` track all subscription disposers?** Adding an internal `Set<() => void>` for active subscribers would allow `ReactiveRoot` to clean up on unmount. This creates a lifecycle coupling — `ReactiveRoot` would need a `destroy()` method.
3. **How should `ReactiveRoot` connect to the reconciler?** Options: (a) `ReactiveRoot` emits events that a reconciler subscribes to, (b) `createReactiveRoot(host, container)` bridges both layers, (c) consumer code wires them manually. See [01-reactive-host-bridge.md](../../research/reconciler/01-reactive-host-bridge.md) for analysis. 3. **How should `ReactiveRoot` connect to the reconciler?** Options: (a) `ReactiveRoot` emits events that a reconciler subscribes to, (b) `createReactiveRoot(host, container)` bridges both layers, (c) consumer code wires them manually. See [reconciler.md](reconciler.md) for the fiber-based bridge approach.
4. **Should `render()` support multiple concurrent subscribers?** The current overwriting design suggests single-subscriber usage. If multiple hosts need to render the same reactive tree, they should each call `subscribe()` directly rather than `render()`. 4. **Should `render()` support multiple concurrent subscribers?** The current overwriting design suggests single-subscriber usage. If multiple hosts need to render the same reactive tree, they should each call `subscribe()` directly rather than `render()`.
## References ## References
- Source: `src/core/reactive.ts` - Source: `src/core/reactive.ts`
- ADR-003: `docs/architecture/decisions/003-preact-signals-for-reactivity.md` - ADR-003: `docs/architecture/decisions/003-preact-signals-for-reactivity.md`
- Reconciler architecture: [reconciler.md](reconciler.md)
- Lifecycle management: [lifecycle.md](lifecycle.md)
- Reactive → Host bridge research: `docs/research/reconciler/01-reactive-host-bridge.md` - Reactive → Host bridge research: `docs/research/reconciler/01-reactive-host-bridge.md`
- Unmount & dispose research: `docs/research/reconciler/03-unmount-dispose-support.md` - Unmount & dispose research: `docs/research/reconciler/03-unmount-dispose-support.md`
- Preact signals-core: `@preact/signals-core` - Preact signals-core: `@preact/signals-core`

View File

@@ -0,0 +1,265 @@
---
status: draft
last_updated: 2026-05-18
---
# Reconciler
The fiber-based reconciler that connects the reactive layer to host instances, enabling signal-driven updates and key-based children reconciliation.
## Overview
The reconciler bridges two currently disconnected layers:
1. **The reactive layer** (`ReactiveRoot`, `reactiveComponent`, `reactiveElement`) — holds a `Signal<UNode>` tree that updates when signals change
2. **The host layer** (`HostConfig`, `createRoot().render()`) — creates, updates, and removes host instances
Today, `render()` is mount-only and `unmount()` is a stub. The reconciler adds:
- A **fiber tree** that maps `UElement` positions to host instances across renders
- **Signal-driven property updates** — when a signal changes, `prepareUpdate`/`commitUpdate` flow through the fiber tree without tree diffing
- **Key-based children reconciliation** — matching old children to new children by key, adding LIS-based move detection
- **TypeBox value optimizations** — `Value.Equal`, `Value.Hash`, `Value.Clone`, `Value.Diff` as incremental performance layers
See [ADR-005](decisions/005-signal-driven-updates-over-tree-diffing.md) for why signals handle 90% of updates and [lifecycle.md](lifecycle.md) for disposal semantics.
## Why a Reconciler
The current `HostConfig` defines `prepareUpdate` and `commitUpdate` methods, but nothing calls them. The host layer only mounts. Without a reconciler:
- **Property changes require full remounts** — changing a glyph's color means destroying and recreating the entire subtree
- **Children changes are impossible** — adding, removing, or reordering children is not supported at all
- **Signal updates are invisible to hosts** — `ReactiveRoot.subscribe()` fires on every change but no code translates that into HostConfig calls
The reconciler solves all three by maintaining state between renders.
## Why a Fiber Tree (Not Virtual DOM)
> See [ADR-005](decisions/005-signal-driven-updates-over-tree-diffing.md) for the full decision record.
A fiber tree is a parallel tree of lightweight nodes that track per-element lifecycle state: current props, signal subscriptions, effect queues, and references to host instances. This is different from a virtual DOM approach that compares two complete tree snapshots.
**Why fiber over vdom:**
- **Signals eliminate most diffing** — when a signal changes, the `computed` that depends on it recomputes automatically. The reconciler only needs to propagate the new value to the host, not diff the entire tree.
- **Fibers are cheap to update** — a fiber is ~6 fields. Updating props on a fiber is a field assignment, not a tree traversal.
- **Fibers support partial tree disposal** — removing a child fiber doesn't require walking the entire tree. You dispose the fiber, remove its host instance, and clean up its subscriptions.
- **Fibers decouple scheduling from committing** — updates can be batched and committed in tree order, which is required for correct `insertBefore`/`removeChild` ordering.
## Fiber Node
```typescript
interface Fiber<I> {
instance: I; // Host instance (from createInstance/createTextInstance)
tag: string; // Element type (from UElement.type)
props: Record<string, unknown>; // Current props (updated after commitUpdate)
key: string | undefined; // Reconciliation key (from UElement.key, see ADR-004)
children: Fiber<I>[]; // Child fibers
parent: Fiber<I> | null; // Parent fiber
effect: Effect | null; // Pending effect for commit phase
signalDisposers: (() => void)[]; // Signal effect cleanup functions
prevProps: Record<string, unknown> | null; // Snapshot before reconciliation (for commitUpdate)
}
```
The `I` type parameter matches `HostConfig`'s `Instance` type, so each fiber carries a reference to its host instance. This is the bridge: fiber → host instance → host-specific state (DOM node, graphology node, Three.js object).
> The `key` field is added by the reconciler and extracted by `h()`. See [ADR-004](decisions/004-key-as-first-class-field.md).
## Effect Types
The `effect` field on a `Fiber` node tracks pending mutations for the commit phase:
```typescript
type Effect =
| { type: "update"; payload: unknown } // Props changed — apply via commitUpdate
| { type: "insert"; before: Fiber<I> | null } // New child — insert before target or append
| { type: "move"; before: Fiber<I> | null } // Existing child reordered — insertBefore target
| { type: "remove" } // Child removed — dispose fiber and remove instance
```
Effects are queued during reconciliation and committed top-down (parent before child). The commit order ensures parent state is consistent when child updates fire.
- **`update`**: Carries the payload from `host.prepareUpdate()`. Applied via `host.commitUpdate()`.
- **`insert`**: A new child that needs placement. Applied via `host.appendChild()` or `host.insertBefore()`.
- **`move`**: An existing child that changed position. Applied via `host.insertBefore()`.
- **`remove`**: A child that was removed from the tree. Applied via `host.removeChild()`.
## Reconciliation Algorithm
### Step 1: Signal Change → Schedule Update
When a signal changes, the `computed` that depends on it recomputes. For `ReactiveNode`-backed elements, this triggers `scheduleUpdate`:
```
Signal value changes
→ computed recomputes (ReactiveNode.signal)
→ effect fires
→ scheduleUpdate(fiber, nextNode)
→ batch pending updates
→ queueMicrotask(flushUpdates)
```
Batching is automatic because `@preact/signals-core` already batches signal writes within `batch()` calls. Multiple signal changes → one reconciliation pass.
### Step 2: Reconcile Props (Phase 1 — Same Structure)
For the initial reconciler (before key-based children reconciliation), the algorithm handles property-only updates:
```
For each fiber:
if fiber.tag !== nextNode.type:
→ structural change, defer to Phase 2
else:
payload = host.prepareUpdate(fiber.instance, fiber.tag, fiber.props, nextNode.props, ctx)
if payload !== null:
fiber.effect = { type: "update", payload }
fiber.prevProps = fiber.props
fiber.props = nextNode.props
// Recurse on children (positional matching, same structure assumed)
for i in 0..min(fiber.children.length, nextNode.children.length):
reconcileProps(fiber.children[i], nextNode.children[i], ctx)
```
This covers the dominant case: a signal changes a prop, `prepareUpdate` computes a payload, `commitUpdate` applies it. No tree diffing needed.
### Step 3: Reconcile Children (Phase 2 — Key-Based)
When the tree structure changes (children added, removed, reordered), key-based reconciliation matches old children to new children:
```
Build key maps:
oldKeyMap = Map<key | null, Fiber> from old children
new children → matched, added, removed
For matched children:
If same type: reconcile props (same as Step 2)
If type changed: remove old + insert new
For matched children, determine moves using LIS:
oldIndices = matched.map(m => oldFibers.indexOf(m.oldFiber))
lisIndices = longestIncreasingSubsequence(oldIndices)
Elements NOT in LIS need to be moved (insertBefore)
Mutations committed in order:
1. Removes (reverse order)
2. Inserts + Moves (left-to-right, using insertBefore)
3. Updates (commitUpdate with prevProps)
```
See [ADR-004](decisions/004-key-as-first-class-field.md) for why `key` is a first-class field on `UElement`, not a prop.
### Step 4: Commit Effects
Effects are committed top-down (parent before child):
```
commitEffects(fiber, ctx):
if fiber.effect?.type === "update":
host.commitUpdate(fiber.instance, fiber.effect.payload, fiber.tag, fiber.prevProps!, fiber.props, ctx)
for child in fiber.children:
commitEffects(child, ctx)
fiber.effect = null
```
This ordering is important because `commitUpdate` on a parent may read child instance state. Parent before child ensures parent state is consistent when children update.
## Function Components
Function components (`UComponent`) produce a `UNode` but have no host instance. In the fiber tree, they are transparent:
```
mountNode(node) where typeof node.type === "function":
call component(props) → get output UNode
mountNode(output, parentFiber) // recurse, no fiber for the component itself
```
This means function components don't get their own fiber — they're unwound during mounting and reconciliation. The fiber maps to the intrinsic element the component returns.
> **Open question**: Should function components get a "virtual fiber" with no instance? This would enable component-level effects and state in the future, but adds complexity. The current approach (transparent) is simpler and covers all current use cases.
## TypeBox Optimization Layer
TypeBox value primitives are layered onto the reconciler as incremental performance optimizations. They are **not** required for correctness.
| Primitive | Use | Impact |
|-----------|-----|--------|
| `Value.Equal` | Bail out of reconciliation when subtree is unchanged | High — skips entire subtrees |
| `Value.Hash` | O(1) change detection instead of walking the tree | Medium — great for large unchanged subtrees |
| `Value.Clone` | Snapshot `prevProps` before mutation | Medium — enables correct `commitUpdate` contract |
| `Value.Mutate` | Update props in-place (preserve reference identity) | Low-Medium — important for hosts tracking by reference |
| `Value.Diff` | Property-level diff payloads for `commitUpdate` | Low — nice-to-have for hosts wanting granular updates |
### `Value.Hash` Constraint
`Value.Hash` uses a global mutable accumulator (FNV-1a state). It is **not re-entrant** — you cannot call `Value.Hash` from within a `computed` or `effect` that is itself triggered by a hash comparison. Hashes must be computed outside reactive computations, during the commit phase.
### `Value.Diff` on Functions
`Value.Diff` throws `ValueDiffError` on function values. Since `PropValue` includes functions, `Value.Diff` must either strip function props before diffing or catch the error and fall back to full replacement.
### Optimization Strategy
Optimizations are applied in order:
1. **Add `Value.Equal` bail-out** — skip reconciliation when fiber's cached node equals the next node
2. **Add `Value.Hash`** — O(1) check before `Value.Equal` (after confirming the global accumulator constraint is manageable)
3. **Add `Value.Clone` for `prevProps`** — enables correct `commitUpdate(prevProps, nextProps)` contract
4. **Add `Value.Mutate`** (if function values are handled correctly) — preserve reference identity
5. **Add `Value.Diff` for prop payloads** (optional, catch errors) — granular diff payloads for hosts that want them
> See the research in `docs/research/reconciler/04-typebox-optimization-layer.md` for detailed analysis.
## File Structure
```
src/host/
config.ts # HostConfig interface (updated: render() becomes re-renderable)
fiber.ts # NEW: Fiber type, mountElement, mountReactiveElement
reconcile.ts # NEW: reconciliation algorithm, key matching, LIS, commitEffects
```
The reconciler code lives under `src/host/` because it operates on host instances via the `HostConfig` interface. The reactive layer (`src/core/reactive.ts`) doesn't know about fibers — it only provides signal subscriptions and computed nodes.
## Changes to Existing Files
| File | Change |
|------|--------|
| `src/core/schema.ts` | Add `key?: string` to `UElement` type and TypeBox schema |
| `src/core/h.ts` | Extract `key` from props in `h()`, promote to element level, strip from `props` |
| `src/host/config.ts` | `render()` becomes re-renderable (diffs against stored fiber tree), `unmount()` tears down fiber tree |
| `src/core/reactive.ts` | `ReactiveRoot.dispose()`, real `dispose` functions on `ReactiveNode`, signal subscription tracking |
## Consumer Impact
The reconciler is internal to `@alkdev/ujsx`. `HostConfig` implementations don't change their interface — they only gain callers for their existing optional methods (`prepareUpdate`, `commitUpdate`, `removeChild`, `insertBefore`, `finalizeInstance`).
Host implementations that currently only support mount-only rendering will start receiving `prepareUpdate`/`commitUpdate` calls when the reconciler is active. This is backward compatible: those methods are already optional on `HostConfig`, and mount-only hosts can leave them as no-ops.
## Constraints
- **Signals handle 90% of updates** — tree diffing is only needed for structural changes (add/remove/reorder children). Property changes flow through signals directly.
- **Fiber tree is the reconciler's internal state** — consumers and hosts do not access fibers directly. The fiber tree is an implementation detail.
- **`key` is a reconciler concern, not a prop** — `key` is extracted by `h()` and never passed to components or hosts. See [ADR-004](decisions/004-key-as-first-class-field.md).
- **Commit order is parent → child, top-down** — this ensures parent state is consistent when child updates fire.
- **TypeBox optimizations are incremental** — the reconciler is correct without them. Each optimization layers on top.
- **Phase 5 (flowgraph host configs) belongs in `@alkdev/flowgraph`** — ujsx stays generic. Flowgraph is a consumer, not a modification.
## Open Questions
1. **Should function components get virtual fibers?** Current approach is transparent (no fiber for components). Virtual fibers would enable component-level effects and state but add complexity.
2. **Should updates be automatically batched across microtasks?** `@preact/signals-core` batches within `batch()` calls. Should the reconciler also batch across multiple microtask flushes?
3. **Should `Value.Hash` be used given the global accumulator constraint?** If the reconciler is never called from within a `computed`, the constraint is satisfied. Need to verify this assumption holds for all consumers.
4. **Minimum LIS threshold?** For lists shorter than 5 items, the LIS overhead may exceed the cost of just moving everything. Should there be a threshold below which positional matching is used?
## References
- Reconciler research: `docs/research/reconciler/README.md`
- Key field design: `docs/research/reconciler/00-KEY-FIELD-DESIGN.md`
- Reactive → Host bridge: `docs/research/reconciler/01-reactive-host-bridge.md`
- Children reconciliation: `docs/research/reconciler/02-key-based-children-reconciliation.md`
- TypeBox optimization layer: `docs/research/reconciler/04-typebox-optimization-layer.md`
- Lifecycle management: [lifecycle.md](lifecycle.md)
- HostConfig interface: [host-config.md](host-config.md)
- Reactive layer: [reactive-layer.md](reactive-layer.md)

View File

@@ -82,6 +82,7 @@ export type UElement = {
type: string; type: string;
props: UniversalProps; props: UniversalProps;
children: UNode[]; children: UNode[];
key?: string; // TODO: Not yet implemented. See ADR-004 and reconciler.md
}; };
export type URoot = { export type URoot = {
type: "root"; type: "root";
@@ -135,28 +136,30 @@ The `isUElement` guard excludes `URoot` by checking `type !== "root"`. Without t
`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. `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 research (`docs/research/reconciler/00-KEY-FIELD-DESIGN.md`) proposes adding `key?: string` to `UElement` as a first-class field. `h()` would extract `key` from props and promote 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. 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**: Research complete, not yet implemented. **Status**: Architecture specified, not yet implemented.
## Constraints ## 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. - **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. - **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. - **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.
- **No `key` on `UElement`** — see Known Gaps above. Positional reconciliation only until `key` is added. - **`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. - **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. - **Type guards are mutually exclusive** — `isUElement`, `isURoot`, and `isUPrimitive` partition the `UNode` space. Every `UNode` matches exactly one guard.
## Open Questions ## 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 [00-KEY-FIELD-DESIGN.md](../../research/reconciler/00-KEY-FIELD-DESIGN.md). 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. 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. 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 ## References
- Source: `src/core/schema.ts` - Source: `src/core/schema.ts`
- Key field design: `docs/research/reconciler/00-KEY-FIELD-DESIGN.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 field design research: `docs/research/reconciler/00-KEY-FIELD-DESIGN.md`
- TypeBox Module as type registry: `docs/architecture/decisions/002-typebox-module-as-registry.md` - TypeBox Module as type registry: `docs/architecture/decisions/002-typebox-module-as-registry.md`
- HTML-agnostic core: `docs/architecture/decisions/001-html-agnostic-core.md` - HTML-agnostic core: `docs/architecture/decisions/001-html-agnostic-core.md`