Files
ujsx/docs/research/reconciler/00-KEY-FIELD-DESIGN.md

5.7 KiB

Phase 0: key Field on UElement

Status: Spec (Draft)

Problem

The reconciler needs an identity mechanism to match old children to new children across re-renders. React uses the key prop for this. Currently, UElement has no key field — it would be buried inside props, which creates ambiguity about whether it's a user-defined prop or a reconciler hint.

Proposed Design

Add key as a first-class optional field on UElement:

export type UElement = {
  type: string;
  props: UniversalProps;
  children: UNode[];
  key?: string;
};

TypeBox Schema

UElement: Type.Object({
  type: Type.String(),
  props: Type.Ref("UniversalProps"),
  children: Type.Array(Type.Ref("UNode")),
  key: Type.Optional(Type.String()),
}),

Why Not In props?

React puts key in props but treats it specially (strips it before passing to component functions). This has caused decades of confusion:

  1. key leaks into component props — Developers try to read this.props.key, but it's undefined. React strips it. The mental model is "it's a prop but not really a prop."

  2. key doesn't validate with props 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.

Making key a first-class field solves all three issues.

How h() Handles key

The h() factory extracts key from the props argument and promotes it to the element level:

export function h(
  type: UType,
  props?: UniversalProps | null,
  ...children: UNode[]
): UElement {
  const { key, ...rest } = props ?? {};
  return {
    type: typeof type === "string" ? type : type.displayName ?? type.name ?? "anonymous",
    props: rest,
    children: children.flat(Infinity).filter(c => c != null && c !== false) as UNode[],
    key: key as string | undefined,
  };
}

This means:

  • JSX syntax works naturally: <Operation key="classify" name="..." /> — the JSX transform passes key in the props object, and h() extracts it.
  • Component functions never receive key: It's stripped before being passed to the component.
  • The element has key at the top level: The reconciler reads element.key without inspecting props.

Serialization Implications

key is optional. When a UElement is serialized to JSON (for template storage, network transfer, etc.):

  • With key: { "type": "operation", "props": { "name": "classify" }, "children": [], "key": "classify" }
  • Without key: { "type": "operation", "props": { "name": "classify" }, "children": [] }

The key field is included in the TypeBox schema as Type.Optional(Type.String()), so validation passes either way. Serialized templates that include key can be round-tripped without loss.

key Semantics

Situation key value Reconciler behavior
Not provided undefined Positional matching (same as current behavior)
Provided, unique Any string Matched by key across re-renders
Provided, duplicate Same string as sibling Last-wins; warn in development
null Treated as undefined Positional matching

Keys are local to their parent — two elements in different parts of the tree can have the same key without conflict.

key and URoot

URoot does not get a key field. Roots are unique per createRoot() call and are never children of another element. There's no scenario where two roots need to be distinguished by the reconciler.

The existing id prop on URoot.props serves the identification purpose for roots:

export type URoot = {
  type: "root";
  props: UniversalProps;  // id lives here
  children: UNode[];
};

Changes to Existing Files

File Change
src/core/schema.ts Add key?: string to UElement type and TypeBox UElement schema
src/core/h.ts Extract key from props in h(), promote to element level. Strip from props before passing to component functions.
src/core/jsx-runtime.ts No change — re-exports h() which now handles key
test/mod.test.ts Add tests: h() extracts key, key not in props, key is optional, key in TypeBox schema validation

Backward Compatibility

This is a non-breaking change:

  • Elements without key continue to work identically (positional matching)
  • The key field is optional in both the TypeScript type and the TypeBox schema
  • Existing code that passes key in props will now have it correctly extracted instead of being silently left in props
  • The TypeBox UElement schema accepts objects without key (it's Type.Optional)

Dependencies

None — this is a prerequisite for Phase 2 (key-based children reconciliation) but has no dependencies itself.

Open Questions

  1. Should key accept numbers? React accepts key={0}, key={1} etc. and converts to string. Should ujsx do the same, or enforce string only? Recommendation: string only — simpler, no implicit coercion. Users can wrap in String() if needed.

  2. Should key appear in the UPrimitive union? No — primitives don't need identity. They're leaf nodes with no children to reconcile.

  3. What about the id prop convention? Some hosts use props.id for identification (graph node IDs, DOM IDs). key is different — it's for the reconciler's identity matching and is never passed to the host. props.id is for the host's identity and IS passed through. They serve different purposes and can coexist.