--- 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` 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 { instance: I; // Host instance (from createInstance/createTextInstance) tag: string; // Element type (from UElement.type) props: Record; // Current props (updated after commitUpdate) key: string | undefined; // Reconciliation key (from UElement.key, see ADR-004) children: Fiber[]; // Child fibers parent: Fiber | null; // Parent fiber effect: Effect | null; // Pending effect for commit phase signalDisposers: (() => void)[]; // Signal effect cleanup functions prevProps: Record | 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 | null } // New child — insert before target or append | { type: "move"; before: Fiber | 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 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. - **`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: `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)