# 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`: ```typescript export type UElement = { type: string; props: UniversalProps; children: UNode[]; key?: string; }; ``` ### TypeBox Schema ```typescript 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: ```typescript 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:** `` — 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: ```typescript 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.