11 KiB
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
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
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.
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
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:
- Removes — remove old instances first (reverse order to preserve indices)
- Inserts — create new instances and append
- Moves — use
insertBeforeto reposition - Updates — call
commitUpdateon changed instances
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.Hashon 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
keyfield onUElement(Phase 0 / schema change)- TypeBox
Value.Equalfor prop comparison (optimization) - TypeBox
Value.Hashfor subtree identity (optimization, not correctness)
Open Questions
-
Should
keybe the only identity mechanism? React also usestypeas part of identity (same key, different type = destroy + create). Should ujsx do the same, or should key alone determine identity? -
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?
-
Should the fiber tree store the previous props separately? For
commitUpdate, the host receives bothprevPropsandnextProps. Currently Phase 1'sreconcilePropsoverwritesfiber.propsbefore committing. Need to storeprevPropsseparately.
Test Cases
- Same children, same order, same props → no mutations
- Same children, same order, different props → update mutations only
- Same children, different order → move mutations (LIS determines which)
- Children added at end → insert mutations
- Children added at beginning → insert + move mutations
- Children removed from middle → remove mutations
- Children removed from end → remove mutations
- Mixed: add + remove + reorder → correct combination
- Unkeyed children → positional matching
- Mixed keyed/unkeyed children → keyed matched by key, unkeyed by position
- Duplicate keys → last-wins, warn
- Tag change with same key → remove old + insert new