Files
ujsx/docs/architecture/decisions/004-key-as-first-class-field.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

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)