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

132 lines
5.7 KiB
Markdown

# 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:** `<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:
```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.