Files
ujsx/docs/architecture/decisions/004-key-as-first-class-field.md
glm-5.1 0d5b9d5ea8 stabilize architecture docs: address review findings and advance to stable
Critical fixes:
- Restructure pointers.md: move setNode prop-key writes section under
  its own heading (was incorrectly nested under selectNode)
- Add Context/Density/Direction/RenderContext documentation section
  to host-config.md (was only a brief constraint bullet)
- Advance all 5 ADRs from Status: Proposed → Accepted and frontmatter
  from status: draft → status: stable (decisions are driving implementation)
- Add error handling philosophy section to README

Warning/suggestion fixes:
- Add isUElement null check (node !== null) to schema.md discriminator table
- Add UjsxEnvelope convenience type documentation to events.md
- Add Direction Unicode arrow naming note to transforms.md
- Standardize all cross-references from absolute docs/research/ paths
  to relative ../research/ paths across all architecture docs
- Fix schema.md ADR references to use relative paths
- Reduce redundancy between transforms.md and host-config.md Direction notes
- Update all architecture doc frontmatter from draft → stable

Deferred:
- Performance model section (reconciler not yet built)
- Concepts/glossary document (low ROI at current scale)
- Line counts in source references (would date quickly)
2026-05-18 16:10:24 +00:00

76 lines
4.3 KiB
Markdown

---
status: stable
last_updated: 2026-05-18
---
# ADR-004: `key` as a First-Class Field on `UElement`
**Status**: Accepted
## 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: `../../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)