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
76 lines
4.3 KiB
Markdown
76 lines
4.3 KiB
Markdown
---
|
|
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) |