Files
ujsx/docs/architecture/reconciler.md
glm-5.1 da82b52b27 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
2026-05-18 15:15:13 +00:00

15 KiB

status, last_updated
status last_updated
draft 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 optimizationsValue.Equal, Value.Hash, Value.Clone, Value.Diff as incremental performance layers

See ADR-005 for why signals handle 90% of updates and 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 hostsReactiveRoot.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 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

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.

Effect Types

The effect field on a Fiber node tracks pending mutations for the commit phase:

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 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 propkey is extracted by h() and never passed to components or hosts. See ADR-004.
  • 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
  • HostConfig interface: host-config.md
  • Reactive layer: reactive-layer.md