# 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 { 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(); 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(); 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