Files
ujsx/docs/research/reconciler/02-key-based-children-reconciliation.md

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:

  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
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