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

4.3 KiB

status, last_updated
status last_updated
stable 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:

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.

// 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 separationkey is metadata for the reconciler, not data for components. No "strip before passing" step needed.
  • Schema-compatiblekey is outside props, so component schemas don't need to declare it.
  • Type-safekey?: 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