Files
ujsx/docs/architecture/reconciler.md
glm-5.1 0d5b9d5ea8 stabilize architecture docs: address review findings and advance to stable
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)
2026-05-18 16:10:24 +00:00

266 lines
15 KiB
Markdown

---
status: stable
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 `../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.
- **`Value.Hash` is not re-entrant** — it uses a global mutable accumulator. It must not be called 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.
- **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: `../research/reconciler/README.md`
- Key field design: `../research/reconciler/00-KEY-FIELD-DESIGN.md`
- Reactive → Host bridge: `../research/reconciler/01-reactive-host-bridge.md`
- Children reconciliation: `../research/reconciler/02-key-based-children-reconciliation.md`
- TypeBox optimization layer: `../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)