132 lines
5.7 KiB
Markdown
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. |