Files
ujsx/docs/architecture/decisions/005-signal-driven-updates-over-tree-diffing.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

4.9 KiB

status, last_updated
status last_updated
draft 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.

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
  • Reactive layer: reactive-layer.md
  • HostConfig interface: host-config.md