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:
265
docs/architecture/reconciler.md
Normal file
265
docs/architecture/reconciler.md
Normal 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)
|
||||
Reference in New Issue
Block a user