Phase 2: transitioning reconciler research into architecture documents. New docs: - reconciler.md: fiber tree, reconciliation algorithm (signal-driven props + key-based children), update scheduling, commit order, TypeBox optimization layer, file structure, consumer impact - lifecycle.md: mount/update/dispose phases, fiber tree disposal, partial tree removal, ReactiveRoot.dispose(), finalizeInstance, idempotent disposal, computed vs effect cleanup - ADR-004: key as first-class field on UElement (not a prop) - ADR-005: signal-driven updates for props, reconciliation for structure (hybrid approach, not full tree diffing) Updated docs: - README.md: add reconciler.md, lifecycle.md, ADRs 004/005 to index; update reconciler roadmap with architecture doc links - schema.md: add key?: string to UElement type with TODO comment; update known gaps to reference ADR-004 and reconciler.md; rephrase key constraint as temporary - element-factory.md: update key extraction gap to reference ADR-004 and reconciler.md - host-config.md: reference reconciler.md and lifecycle.md for the reconciler bridge and disposal gaps - reactive-layer.md: reference reconciler.md and lifecycle.md for the signal-host bridge and disposal gaps - events.md: reference lifecycle.md for unmount/dispose gap
4.3 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-05-18 |
ADR-004: key as a First-Class Field on UElement
Status: Proposed
Context
The reconciler needs an identity mechanism to match old children to new children across re-renders. Without identity, the reconciler can only do positional comparison, which produces incorrect results for reorderings (destroying and recreating instances instead of moving them).
React uses the key prop for this, but putting key inside props causes problems:
-
keyleaks into component props — React stripskeybefore passing props to components, but developers still try to readthis.props.key, which isundefined. The mental model is "it's a prop but not really a prop." -
keydoesn't validate with component 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.
Alternatives Considered
-
keyinprops(React pattern): Store key as a regular prop, strip it inh()before passing to components. Rejected because TypeBox schema validation would fail on elements that includekeywithout declaring it, and the "strip before passing" pattern is error-prone. -
Separate
keyMaponUElement: Store keys in a separateMap<string, number>mapping keys to child indices. Rejected because it adds complexity for no benefit over a simpler field — the key is a property of each child, not a property of the parent. -
No key system (positional reconciliation only): Match children by position. Rejected because it produces incorrect results for reorderings — positional matching destroys and recreates instances instead of moving them.
Decision
Add key as a first-class optional field on UElement:
export type UElement = {
type: string;
props: UniversalProps;
children: UNode[];
key?: string; // new field
};
The h() factory extracts key from the props argument and promotes it to the element level. key is never passed to component functions.
// h() extracts key:
const { key, ...rest } = props ?? {};
return { type, props: rest, children, key: key as string | undefined };
URoot does not get a key field. Roots are unique per createRoot() call and are never children of another element. URoot.props.id serves the identification purpose for roots.
The TypeBox schema uses Type.Optional(Type.String()) for key, so validation passes both with and without the field.
Consequences
Positive
- Clean separation —
keyis metadata for the reconciler, not data for components. No "strip before passing" step needed. - Schema-compatible —
keyis outsideprops, so component schemas don't need to declare it. - Type-safe —
key?: stringis explicit in the TypeScript type, not hidden in a union prop type. - Backward compatible — elements without
keycontinue to work with positional matching. The field is optional in both TypeScript and TypeBox.
Negative
UElementshape changes — addingkeyis a non-breaking addition (optional field), but any code that spreadsUElementinto object literals will pick upkey: undefinedon unkeyed elements.- Serialization includes
keywhen present — JSON-serialized elements will include"key": "..."when provided, adding a field that wasn't there before. Consumers that validate serialized UElements against the old schema (withoutkey) will need to update their schemas.
Neutral
keyacceptsstringonly, notnumber— React accepts numbers and coerces to strings. For UJSX,stringonly is simpler and avoids implicit coercion. Users can wrap inString()if needed.- Duplicate keys use last-wins — if two siblings have the same key, the reconciler warns and uses the last occurrence. This matches React behavior.
References
- Key field design research:
docs/research/reconciler/00-KEY-FIELD-DESIGN.md - Schema architecture: schema.md
- Element factory: element-factory.md
- Reconciler algorithm: reconciler.md