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:
76
docs/architecture/decisions/004-key-as-first-class-field.md
Normal file
76
docs/architecture/decisions/004-key-as-first-class-field.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# ADR-004: `key` as a First-Class Field on `UElement`
|
||||
|
||||
**Status**: Proposed
|
||||
|
||||
## Context
|
||||
|
||||
The reconciler needs an identity mechanism to match old children to new children across re-renders. Without identity, the reconciler can only do positional comparison, which produces incorrect results for reorderings (destroying and recreating instances instead of moving them).
|
||||
|
||||
React uses the `key` prop for this, but putting `key` inside `props` causes problems:
|
||||
|
||||
1. **`key` leaks into component props** — React strips `key` before passing props to components, but developers still try to read `this.props.key`, which is `undefined`. The mental model is "it's a prop but not really a prop."
|
||||
|
||||
2. **`key` doesn't validate with component schemas** — If a component's TypeBox schema is `Type.Object({ name: Type.String() })`, passing `key` alongside `name` would fail validation because `key` isn't in the schema.
|
||||
|
||||
3. **`key` is a reconciler concern, not a component concern** — Components should never need to know their own key. It's metadata for the reconciliation algorithm, not data for the component function.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **`key` in `props` (React pattern)**: Store key as a regular prop, strip it in `h()` before passing to components. Rejected because TypeBox schema validation would fail on elements that include `key` without declaring it, and the "strip before passing" pattern is error-prone.
|
||||
|
||||
- **Separate `keyMap` on `UElement`**: Store keys in a separate `Map<string, number>` mapping keys to child indices. Rejected because it adds complexity for no benefit over a simpler field — the key is a property of each child, not a property of the parent.
|
||||
|
||||
- **No key system (positional reconciliation only)**: Match children by position. Rejected because it produces incorrect results for reorderings — positional matching destroys and recreates instances instead of moving them.
|
||||
|
||||
## Decision
|
||||
|
||||
Add `key` as a first-class optional field on `UElement`:
|
||||
|
||||
```typescript
|
||||
export type UElement = {
|
||||
type: string;
|
||||
props: UniversalProps;
|
||||
children: UNode[];
|
||||
key?: string; // new field
|
||||
};
|
||||
```
|
||||
|
||||
The `h()` factory extracts `key` from the props argument and promotes it to the element level. `key` is never passed to component functions.
|
||||
|
||||
```typescript
|
||||
// h() extracts key:
|
||||
const { key, ...rest } = props ?? {};
|
||||
return { type, props: rest, children, key: key as string | undefined };
|
||||
```
|
||||
|
||||
`URoot` does not get a `key` field. Roots are unique per `createRoot()` call and are never children of another element. `URoot.props.id` serves the identification purpose for roots.
|
||||
|
||||
The TypeBox schema uses `Type.Optional(Type.String())` for `key`, so validation passes both with and without the field.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **Clean separation** — `key` is metadata for the reconciler, not data for components. No "strip before passing" step needed.
|
||||
- **Schema-compatible** — `key` is outside `props`, so component schemas don't need to declare it.
|
||||
- **Type-safe** — `key?: string` is explicit in the TypeScript type, not hidden in a union prop type.
|
||||
- **Backward compatible** — elements without `key` continue to work with positional matching. The field is optional in both TypeScript and TypeBox.
|
||||
|
||||
### Negative
|
||||
- **`UElement` shape changes** — adding `key` is a non-breaking addition (optional field), but any code that spreads `UElement` into object literals will pick up `key: undefined` on unkeyed elements.
|
||||
- **Serialization includes `key` when present** — JSON-serialized elements will include `"key": "..."` when provided, adding a field that wasn't there before. Consumers that validate serialized UElements against the old schema (without `key`) will need to update their schemas.
|
||||
|
||||
### Neutral
|
||||
- **`key` accepts `string` only, not `number`** — React accepts numbers and coerces to strings. For UJSX, `string` only is simpler and avoids implicit coercion. Users can wrap in `String()` if needed.
|
||||
- **Duplicate keys use last-wins** — if two siblings have the same key, the reconciler warns and uses the last occurrence. This matches React behavior.
|
||||
|
||||
## References
|
||||
|
||||
- Key field design research: `docs/research/reconciler/00-KEY-FIELD-DESIGN.md`
|
||||
- Schema architecture: [schema.md](../schema.md)
|
||||
- Element factory: [element-factory.md](../element-factory.md)
|
||||
- Reconciler algorithm: [reconciler.md](../reconciler.md)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user