Skip to main content

Overview

The reconciler implements keyed child diffing to minimize native widget churn when updating JSX trees. It reuses existing instances when keys match, unmounts removed children, and fixes native child ordering with minimal FFI calls.

Key Concepts

VNode (Virtual Node)

A lightweight descriptor produced by the JSX factory:
interface VNode {
  type: string | typeof Fragment | ComponentFunction;
  props: Record<string, unknown>;
  key: string | number | null;
  children: VNode[];
}
  • type: "Box", "Text", etc. for intrinsic elements; Fragment for child-only grouping; function for components
  • props: Props object (excluding children and key)
  • key: Optional identifier for reconciliation (defaults to positional index if null)
  • children: Normalized child VNodes (always an array)

Instance (Mounted State)

Bookkeeping for a mounted VNode:
interface Instance {
  widget: Widget;                      // Native widget handle (null for Fragment)
  vnode: VNode;                        // Original VNode descriptor
  children: Instance[];                // Mounted child instances
  cleanups: (() => void)[];            // Signal effect disposers
  key: string | number | null;        // Key for reconciliation
  parent: Instance | null;             // Parent instance reference
  eventHandlers: Map<string, EventHandler>; // JSX event handlers
}
  • widget: The native Widget instance (or null for Fragments)
  • cleanups: Functions to dispose signal effects (called during unmount)
  • eventHandlers: JSX event callbacks (onClick, onSubmit, etc.)

reconcileChildren()

Reconcile old children against new VNodes using keyed diffing.
parentInstance
Instance
required
The parent instance whose children are being reconciled.
newVNodes
VNode[]
required
The new array of child VNodes.
Returns: void (mutates parentInstance.children in place)

Algorithm

  1. Build key map: Create Map<key, Instance> from old children (key defaults to index if null)
  2. Walk new VNodes: For each new VNode:
    • If a keyed match exists: reuse instance, update props, remove from map
    • If no match: mount new instance, append to parent widget
  3. Unmount remaining: Destroy instances left in the map (removed children)
  4. Fix ordering: Use insertChild() to reorder native children to match new order
import { signal, render } from "kraken-tui";

const items = signal([
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" },
  { id: 3, name: "Charlie" },
]);

render(
  <Box flexDirection="column">
    {items.value.map(item => (
      <Text key={item.id} content={item.name} />
    ))}
  </Box>,
  app
);

// Reorder without destroying widgets:
items.value = [
  { id: 3, name: "Charlie" },
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" },
];
// Only native child order is updated (no unmount/remount)

Keyed vs. Positional Reconciliation

Without Keys (Positional Matching)

Children are matched by index:
// Before:
<Box>
  <Text content="A" />
  <Text content="B" />
  <Text content="C" />
</Box>

// After:
<Box>
  <Text content="C" />
  <Text content="A" />
  <Text content="B" />
</Box>

// Result:
// - Index 0: update content "A" → "C"
// - Index 1: update content "B" → "A"
// - Index 2: update content "C" → "B"
// (All three widgets are reused but props are updated)

With Keys (Identity Matching)

Children are matched by key:
// Before:
<Box>
  <Text key="a" content="A" />
  <Text key="b" content="B" />
  <Text key="c" content="C" />
</Box>

// After:
<Box>
  <Text key="c" content="C" />
  <Text key="a" content="A" />
  <Text key="b" content="B" />
</Box>

// Result:
// - Key "c": reused, moved to index 0
// - Key "a": reused, moved to index 1
// - Key "b": reused, moved to index 2
// (Widgets are reordered without prop updates)
Use keys when:
  • Reordering items (drag-and-drop, sorting)
  • Adding/removing items from lists
  • Items have stable identifiers (IDs, UUIDs)
Avoid keys when:
  • List order is always static
  • Items are truly positional (e.g., “first 10 results”)

Fragment Reconciliation

Fragments have no native widget — children are mounted into the nearest widget-bearing ancestor.
function List({ items }: { items: string[] }) {
  return (
    <>
      {items.map((item, i) => (
        <Text key={i} content={item} />
      ))}
    </>
  );
}

// Usage:
<Box flexDirection="column">
  <List items={["A", "B", "C"]} />
</Box>
The Text widgets become direct children of Box in the native tree.

Fragment Update Logic

When a Fragment updates:
  1. Build key map from old children
  2. Walk new VNodes, reusing keyed matches or mounting new instances
  3. Find ancestor widget for append/insert operations (Fragments themselves have no widget)
  4. Unmount removed children

Component Function Updates

When a component function’s props change:
  1. Re-invoke the function with new props
  2. Resolve nested component functions until an intrinsic element or Fragment is reached
  3. Dispose old signal effects
  4. Re-apply props from the resolved VNode to the existing widget
  5. Reconcile children
function Greeting({ name }: { name: string }) {
  return <Text content={`Hello, ${name}!`} />;
}

const name = signal("Alice");

render(<Greeting name={name.value} />, app);

name.value = "Bob"; // Component re-invoked, Text widget content updated
Component functions do not have lifecycle hooks. Use effect() for side effects:
function Logger({ message }: { message: string }) {
  effect(() => console.log(message));
  return <Text content={message} />;
}

Prop Update Lifecycle

When an instance updates:
  1. Dispose old effects: Call all cleanup functions in instance.cleanups
  2. Clear event handlers: Remove from global event registry
  3. Re-apply props: Parse and apply all props via FFI
    • Static props: applied once
    • Signal props: wrapped in new effect(), cleanup added to instance.cleanups
  4. Reconcile children: Call reconcileChildren() if children changed

Static vs. Signal Props

// Static prop: applied once at mount
<Text content="Hello" />

// Signal prop: wrapped in effect, updates live
const text = signal("Hello");
<Text content={text} />

// Implementation:
if (isSignal(value)) {
  const dispose = effect(() => {
    applyStaticProp(handle, type, prop, value.value);
  });
  instance.cleanups.push(dispose);
} else {
  applyStaticProp(handle, type, prop, value);
}

Ordering Algorithm

After reconciliation, native child order may not match new VNode order. The reconciler fixes this with minimal FFI calls:
for (let i = 0; i < newChildren.length; i++) {
  const child = newChildren[i];
  if (child.widget) {
    const currentHandle = ffi.tui_get_child_at(parentWidget.handle, i);
    if (currentHandle !== child.widget.handle) {
      parentWidget.insertChild(child.widget, i);
    }
  }
}
  • tui_get_child_at: Query current child at index i
  • insertChild: Move child to index i (detaches from old position, inserts at new position)
  • Only called when position is wrong

Event Handler Registry

JSX event handlers (onClick, onSubmit, etc.) are stored in a global registry:
const eventRegistry = new Map<number, Map<string, EventHandler>>();
  • Key: Widget handle
  • Value: Map of event name → handler function
The event loop calls getEventHandlers(handle) to dispatch events:
import { getEventHandlers } from "kraken-tui";

for (const event of app.drainEvents()) {
  const handlers = getEventHandlers(event.target);
  if (handlers) {
    const handler = handlers.get(`on${event.type}`);
    handler?.(event);
  }
}
Handlers are automatically cleared on unmount.

Performance Characteristics

Time Complexity

  • Keyed reconciliation: O(n) where n = max(old children, new children)
  • Ordering fix: O(n) with O(moves) FFI calls where moves = number of children in wrong position
  • Unmount: O(subtree size) for effects, O(1) FFI per widget-bearing node (via destroySubtree)

Space Complexity

  • Key map: O(old children)
  • Instance tree: O(total mounted nodes)

FFI Call Budget

For reconciling n children:
  • Best case: 0 FFI calls (no changes)
  • Worst case: O(n) insert calls + O(removed) destroy calls
  • Typical case: O(added + removed + moves) calls

Limitations

No Diffing Within Props

Props are re-applied wholesale on update. There is no granular prop diffing:
// Even if only `fg` changed, both props are re-applied:
<Text fg={fgColor} bg={bgColor} />
Use signal props for individual properties that change frequently.

No Async Components

Component functions must return a VNode synchronously. No async/await support.

No Refs to Component Instances

Component functions have no instance object. Use refs on child widgets:
function MyComponent() {
  let textWidget: Widget | null = null;
  return <Text ref={w => textWidget = w} content="Hello" />;
}

See Also

Build docs developers (and LLMs) love