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:
-
keyleaks into component props — Developers try to readthis.props.key, but it's undefined. React strips it. The mental model is "it's a prop but not really a prop." -
keydoesn't validate with props schemas — If a component's TypeBox schema isType.Object({ name: Type.String() }), passingkeyalongsidenamewould fail validation becausekeyisn't in the schema. -
keyis 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 passeskeyin the props object, andh()extracts it. - Component functions never receive
key: It's stripped before being passed to the component. - The element has
keyat the top level: The reconciler readselement.keywithout inspectingprops.
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
keycontinue to work identically (positional matching) - The
keyfield is optional in both the TypeScript type and the TypeBox schema - Existing code that passes
keyin props will now have it correctly extracted instead of being silently left inprops - The TypeBox
UElementschema accepts objects withoutkey(it'sType.Optional)
Dependencies
None — this is a prerequisite for Phase 2 (key-based children reconciliation) but has no dependencies itself.
Open Questions
-
Should
keyaccept numbers? React acceptskey={0},key={1}etc. and converts to string. Should ujsx do the same, or enforcestringonly? Recommendation:stringonly — simpler, no implicit coercion. Users can wrap inString()if needed. -
Should
keyappear in theUPrimitiveunion? No — primitives don't need identity. They're leaf nodes with no children to reconcile. -
What about the
idprop convention? Some hosts useprops.idfor identification (graph node IDs, DOM IDs).keyis different — it's for the reconciler's identity matching and is never passed to the host.props.idis for the host's identity and IS passed through. They serve different purposes and can coexist.