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:
2026-05-18 15:15:13 +00:00
parent 09f32f0c64
commit da82b52b27
10 changed files with 631 additions and 34 deletions

View File

@@ -0,0 +1,65 @@
---
status: draft
last_updated: 2026-05-18
---
# ADR-005: Signal-Driven Updates Over Tree Diffing
**Status**: Proposed
## Context
When an element tree changes, the reconciler must determine which host instances need updating. Two broad approaches exist:
1. **Tree diffing** — compare the entire previous tree snapshot to the next tree snapshot, walk both trees in lockstep, and generate a diff (add/remove/update/move operations). This is React's approach with its virtual DOM.
2. **Signal-driven updates** — subscribe to the specific signals that drive each element's props. When a signal changes, only the elements that depend on that signal update. No tree-level diffing for property changes.
## Alternatives Considered
- **Full tree diffing (React-style)**: Re-render the entire tree on every change, diff old vs new, generate a patch list. Rejected because (a) signals already provide fine-grained reactivity — tree diffing would re-derive information that signals already computed, and (b) UJSX's tree structures are often small (10-200 elements for workflow configs), but the diffing overhead scales with tree size regardless.
- **Incremental DOM (Glimmer-style)**: Reconstruct the tree eagerly during render, but skip creation for unchanged nodes. Rejected because it still requires walking the entire tree structure on every update, even when only one prop changed.
- **Fine-grained reactive only (Solid-style)**: No reconciliation at all — every prop is a signal, every element subscribes individually. Rejected because it doesn't handle structural changes (add/remove/reorder children) — signals can't express "this child was removed" without a reconciliation step.
## Decision
Use signals for property updates and reconciliation for structural changes. This hybrid approach recognizes that the two types of changes have fundamentally different characteristics:
- **Property changes** (color, status, text content) — signals already know which properties changed. Walking the tree to rediscover this is wasted work.
- **Structural changes** (add/remove/reorder children) — signals can't express these. You need identity-based reconciliation (key matching, LIS for moves).
The reconciler is **signal-driven for props, reconciliation-driven for children**. When a `ReactiveNode`'s signal fires:
1. The `computed` that depends on the signal recomputes → produces a new `UElement`
2. The reconciler compares the fiber's current props to the new element's props via `prepareUpdate`
3. If `prepareUpdate` returns a non-null payload, queue an "update" effect
4. Structural changes (different children, different keys) are reconciled using key-based matching
This means 90% of updates (property changes) bypass tree diffing entirely. The reconciliation algorithm is only invoked for structural changes.
## Consequences
### Positive
- **Signal changes are O(1) for property updates** — no tree walk, no diffing. The `effect` fires, `prepareUpdate` runs on exactly one fiber, `commitUpdate` applies the change.
- **No wasted work on unchanged subtrees** — signals that don't fire mean no reconciliation for their consumers. A color change in glyph #47 doesn't touch glyphs #1-46.
- **Natural batching** — `@preact/signals-core` already batches signal writes. Multiple prop changes in one batch → one reconciliation pass.
- **Simpler mental model** — "signals for data, reconciliation for structure" is easier to reason about than "diff the whole tree on every change."
### Negative
- **Two update paths** — developers must understand when to use signals vs. reconciliation. Property changes are signal-driven; structural changes require reconciliation. This is documented but adds conceptual surface.
- **Signal graph must be correct** — if a signal is not subscribed (e.g., a `computed` that doesn't track all its dependencies), property updates will be missed. Preact's signal system handles this automatically, but custom signal wiring could introduce bugs.
- **Reconciliation is still needed** — this approach doesn't eliminate reconciliation. It just reduces its scope to structural changes. The full key-based matching algorithm is still needed for add/remove/reorder.
### Neutral
- **Fiber tree is the reconciler's internal state** — consumers and hosts never access fibers directly. The fiber tree is an implementation detail that bridges signals to host instances.
- **`key` is required for correct reconciliation of lists** — without keys, children are matched by position, which is wrong for reorderings. See [ADR-004](004-key-as-first-class-field.md).
## References
- Reactive → Host bridge research: `docs/research/reconciler/01-reactive-host-bridge.md`
- Key-based children reconciliation: `docs/research/reconciler/02-key-based-children-reconciliation.md`
- Reconciler architecture: [reconciler.md](../reconciler.md)
- Reactive layer: [reactive-layer.md](../reactive-layer.md)
- HostConfig interface: [host-config.md](../host-config.md)