Fixes from architecture review (4 critical, 10 warnings):
Critical:
- Fix selectNode/setNode docs to accurately describe prop-key
navigation behavior including array support and prop-key writes
- Document RenderContext/Density exported types in host-config
- Resolve ADR dual status ambiguity with clarifying note in README
(frontmatter status = editorial, body Status = decision)
- Effect types already addressed in prior commit
Warnings addressed:
- Add Fragment re-export note to jsx-runtime section in
build-distribution
- Document childCtx/transformCtx helper functions in transforms.md
- Document render() accepting non-root UNode in host-config
- Add Value.Hash re-entrancy constraint to reconciler.md
- Add true-passthrough constraint and h('root') special case
to element-factory constraints
- Add _idCounter bundling caveat note
Review document added at docs/reviews/architecture-review-2026-05-18.md
with full findings, source verification table, and recommendations.
266 lines
15 KiB
Markdown
266 lines
15 KiB
Markdown
---
|
|
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.
|
|
- **`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) |