add reconciler implementation plan: 6-phase spec with dependency graph and parallelism analysis

This commit is contained in:
2026-05-18 13:48:14 +00:00
parent ba74afd0b3
commit e22598f4d1
8 changed files with 1644 additions and 0 deletions

View File

@@ -0,0 +1,302 @@
# Phase 2: Key-Based Children Reconciliation
## Status: Spec (Draft)
## Problem
Phase 1 handles property updates on unchanged tree structures. But when children are added, removed, or reordered, the reconciler must determine which old children correspond to which new children. Without key-based matching, the reconciler can only do positional comparison — which produces incorrect results for reorderings (destroying and recreating instances instead of moving them).
This is the same problem React's reconciler solves with the `key` prop and the longest-increasing-subsequence (LIS) algorithm.
## Why TypeBox's Value.Diff Is Not Sufficient
`Value.Diff` compares arrays **positionally by index**. This is the fundamental mismatch:
```
Current: [{ type: 'div', key: 'a' }, { type: 'div', key: 'b' }, { type: 'div', key: 'c' }]
Next: [{ type: 'div', key: 'b' }, { type: 'div', key: 'a' }, { type: 'div', key: 'c' }]
```
TypeBox `Diff` produces:
- `Update('/0/key', 'b')` — treats position 0 as "changed from a to b"
- `Update('/1/key', 'a')` — treats position 1 as "changed from b to a"
Correct reconciler interpretation:
- Element with `key='a'` **moved** from position 0 → position 1
- Element with `key='b'` **moved** from position 1 → position 0
- Element with `key='c'` **stayed** at position 2
The reconciler must track identity (via `key`), not position. `Value.Diff` does not understand identity.
## The Algorithm
The algorithm is adapted from React's children reconciliation (react-reconciler `ChildReconciler`). It runs in O(n) for most cases and O(n log n) worst case (for the LIS computation).
### Step 1: Build Key Maps
```typescript
interface ChildMapping {
key: string | null; // null for unkeyed children
index: number; // position in new children array
node: UElement;
}
function buildKeyMap(children: UNode[]): Map<string | null, { node: UElement; index: number }> {
const map = new Map();
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (!isUElement(child)) continue;
const key = child.key ?? null;
if (map.has(key)) {
// Duplicate key — warn in dev, use last occurrence
}
map.set(key, { node: child, index: i });
}
return map;
}
```
### Step 2: Match Old → New Children
```typescript
interface MatchResult {
matched: Array<{ oldFiber: Fiber; newChild: UElement; index: number }>;
added: Array<{ newChild: UElement; index: number }>;
removed: Fiber[];
}
function matchChildren(oldFibers: Fiber[], newChildren: UNode[]): MatchResult {
const oldKeyMap = new Map<string | null, Fiber>();
for (const fiber of oldFibers) {
oldKeyMap.set(fiber.key ?? null, fiber);
}
const matched: MatchResult["matched"] = [];
const added: MatchResult["added"] = [];
const removed: Fiber[] = [];
const newKeySet = new Set<string | null>();
for (let i = 0; i < newChildren.length; i++) {
const child = newChildren[i];
if (!isUElement(child)) continue;
const key = child.key ?? null;
newKeySet.add(key);
const oldFiber = oldKeyMap.get(key);
if (oldFiber) {
matched.push({ oldFiber, newChild: child, index: i });
} else {
added.push({ newChild: child, index: i });
}
}
// Old fibers not matched by any new child
for (const fiber of oldFibers) {
if (!newKeySet.has(fiber.key ?? null)) {
removed.push(fiber);
}
}
return { matched, added, removed };
}
```
### Step 3: Determine Moves Using LIS
For matched children, determine which ones need to move. The LIS of the matched children's old indices gives the longest sequence that's already in the correct relative order. Elements in the LIS stay; everything else moves.
```typescript
function longestIncreasingSubsequence(indices: number[]): number[] {
// Standard O(n log n) LIS algorithm
// Returns the indices (into the input array) of elements in the LIS
const piles: number[] = [];
const backpointers: number[] = [];
for (let i = 0; i < indices.length; i++) {
const val = indices[i];
let lo = 0, hi = piles.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (indices[piles[mid]] < val) lo = mid + 1;
else hi = mid;
}
if (lo > 0) backpointers[i] = piles[lo - 1];
else backpointers[i] = -1;
piles[lo] = i;
}
// Reconstruct
const result: number[] = [];
let k = piles[piles.length - 1];
while (k >= 0) {
result.unshift(k);
k = backpointers[k];
}
return result;
}
```
### Step 4: Generate Mutations
```typescript
interface ChildMutation {
type: "update" | "move" | "insert" | "remove";
fiber?: Fiber; // for update/move/remove
newChild?: UElement; // for insert
before?: Fiber | null; // for move/insert (insertBefore target)
payload?: unknown; // update payload from prepareUpdate
}
function reconcileChildren(
oldFibers: Fiber[],
newChildren: UNode[],
ctx: RootCtx,
): ChildMutation[] {
const { matched, added, removed } = matchChildren(oldFibers, newChildren);
const mutations: ChildMutation[] = [];
// 1. Remove deleted children
for (const fiber of removed) {
mutations.push({ type: "remove", fiber });
}
// 2. Compute LIS for matched children
const oldIndices = matched.map(m => oldFibers.indexOf(m.oldFiber));
const lisIndices = new Set(longestIncreasingSubsequence(oldIndices));
// 3. Process matched children
for (let i = 0; i < matched.length; i++) {
const { oldFiber, newChild } = matched[i];
const needsMove = !lisIndices.has(i);
// Props update
if (oldFiber.tag === newChild.type) {
const payload = host.prepareUpdate?.(oldFiber.instance, oldFiber.tag, oldFiber.props, newChild.props, ctx);
if (payload !== null) {
mutations.push({ type: "update", fiber: oldFiber, payload });
}
} else {
// Tag changed — treat as remove + insert
mutations.push({ type: "remove", fiber: oldFiber });
mutations.push({ type: "insert", newChild });
}
// Move
if (needsMove) {
mutations.push({ type: "move", fiber: oldFiber, before: null /* determined during commit */ });
}
}
// 4. Insert new children
for (const { newChild } of added) {
mutations.push({ type: "insert", newChild });
}
return mutations;
}
```
### Step 5: Commit Mutations
Mutations are committed in a specific order:
1. **Removes** — remove old instances first (reverse order to preserve indices)
2. **Inserts** — create new instances and append
3. **Moves** — use `insertBefore` to reposition
4. **Updates** — call `commitUpdate` on changed instances
```typescript
function commitChildMutations(mutations: ChildMutation[], parentInstance: I, ctx: RootCtx): void {
// 1. Removes (reverse order)
const removes = mutations.filter(m => m.type === "remove").reverse();
for (const m of removes) {
host.removeChild?.(parentInstance, m.fiber!.instance, ctx);
}
// 2. Inserts + Moves (left-to-right for correct insertBefore targets)
const insertsAndMoves = mutations.filter(m => m.type === "insert" || m.type === "move");
for (const m of insertsAndMoves) {
if (m.type === "insert") {
const instance = host.createInstance(m.newChild!.type, m.newChild!.props, ctx, parentInstance);
// Determine before target by current DOM state
host.appendChild(parentInstance, instance, ctx); // or insertBefore
} else if (m.type === "move") {
// Find the next sibling that's not also being moved
host.insertBefore?.(parentInstance, m.fiber!.instance, m.before?.instance ?? null, ctx);
}
}
// 3. Updates
const updates = mutations.filter(m => m.type === "update");
for (const m of updates) {
host.commitUpdate?.(m.fiber!.instance, m.payload, m.fiber!.tag, m.fiber!.props, m.fiber!.props, ctx);
}
}
```
## File Structure
```
src/host/
config.ts # HostConfig interface (unchanged)
reconcile.ts # NEW: reconcile(), matchChildren(), LIS, commitMutations()
fiber.ts # NEW: Fiber type, fiber tree operations
```
## Changes to Existing Files
| File | Change |
|------|--------|
| `src/host/config.ts` | `createRoot().render()` now calls `reconcile()` instead of mount-only walk. Second render diffs against stored fiber tree. |
| `src/core/schema.ts` | `UElement` gets `key?: string` field |
| `src/core/h.ts` | `h()` extracts `key` from props, does not pass it through to host's `createInstance` |
| `test/mod.test.ts` | New test section for reconciliation: key matching, reorder, add, remove, mixed operations |
## Edge Cases to Handle
| Case | Behavior |
|------|----------|
| No keys (all `null`) | Falls back to positional matching (same as current behavior) |
| Duplicate keys | Warn, use last occurrence (matches React behavior) |
| Mixed keyed/unkeyed | Keyed children matched by key, unkeyed matched by position among remaining unkeyed slots |
| Null children | Skip (already filtered by `h()`) |
| Fragment children | Flatten before reconciliation |
| Nested reconciliation | Each level of the tree reconciles its children independently |
## Performance Considerations
- **O(n) for the common case** — Most re-renders have the same children order. The LIS computation only matters for reorderings.
- **Hash-based quick bail-out** — Before running the full reconcile, compute `Value.Hash` on each child. If hashes match, skip that child entirely. This is an optimization, not a correctness requirement.
- **Batched updates** — Signal changes within `batch()` should only trigger one reconciliation pass.
## Dependencies
- Phase 1 (reactive → host bridge) must be complete
- `key` field on `UElement` (Phase 0 / schema change)
- TypeBox `Value.Equal` for prop comparison (optimization)
- TypeBox `Value.Hash` for subtree identity (optimization, not correctness)
## Open Questions
1. **Should `key` be the only identity mechanism?** React also uses `type` as part of identity (same key, different type = destroy + create). Should ujsx do the same, or should key alone determine identity?
2. **How fragile is LIS for small lists?** For lists of < 5 items, the overhead of LIS might exceed the cost of just moving everything. Should there be a threshold below which we skip LIS?
3. **Should the fiber tree store the previous props separately?** For `commitUpdate`, the host receives both `prevProps` and `nextProps`. Currently Phase 1's `reconcileProps` overwrites `fiber.props` before committing. Need to store `prevProps` separately.
## Test Cases
1. Same children, same order, same props → no mutations
2. Same children, same order, different props → update mutations only
3. Same children, different order → move mutations (LIS determines which)
4. Children added at end → insert mutations
5. Children added at beginning → insert + move mutations
6. Children removed from middle → remove mutations
7. Children removed from end → remove mutations
8. Mixed: add + remove + reorder → correct combination
9. Unkeyed children → positional matching
10. Mixed keyed/unkeyed children → keyed matched by key, unkeyed by position
11. Duplicate keys → last-wins, warn
12. Tag change with same key → remove old + insert new