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)
15 KiB
status, last_updated
| status | last_updated |
|---|---|
| stable | 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:
- The reactive layer (
ReactiveRoot,reactiveComponent,reactiveElement) — holds aSignal<UNode>tree that updates when signals change - 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
UElementpositions to host instances across renders - Signal-driven property updates — when a signal changes,
prepareUpdate/commitUpdateflow 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.Diffas 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 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 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
computedthat 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/removeChildordering.
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
keyfield is added by the reconciler and extracted byh(). 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 fromhost.prepareUpdate(). Applied viahost.commitUpdate().insert: A new child that needs placement. Applied viahost.appendChild()orhost.insertBefore().move: An existing child that changed position. Applied viahost.insertBefore().remove: A child that was removed from the tree. Applied viahost.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:
- Add
Value.Equalbail-out — skip reconciliation when fiber's cached node equals the next node - Add
Value.Hash— O(1) check beforeValue.Equal(after confirming the global accumulator constraint is manageable) - Add
Value.CloneforprevProps— enables correctcommitUpdate(prevProps, nextProps)contract - Add
Value.Mutate(if function values are handled correctly) — preserve reference identity - Add
Value.Difffor prop payloads (optional, catch errors) — granular diff payloads for hosts that want them
See the research in
../research/reconciler/04-typebox-optimization-layer.mdfor 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.
keyis a reconciler concern, not a prop —keyis extracted byh()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.
Value.Hashis not re-entrant — it uses a global mutable accumulator. It must not be called from within acomputedoreffectthat 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
- 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.
- Should updates be automatically batched across microtasks?
@preact/signals-corebatches withinbatch()calls. Should the reconciler also batch across multiple microtask flushes? - Should
Value.Hashbe used given the global accumulator constraint? If the reconciler is never called from within acomputed, the constraint is satisfied. Need to verify this assumption holds for all consumers. - 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
- HostConfig interface: host-config.md
- Reactive layer: reactive-layer.md