Files
ujsx/docs/research/reconciler/01-reactive-host-bridge.md

9.1 KiB

Phase 1: Reactive → Host Bridge

Status: Spec (Draft)

Problem

The reactive layer (ReactiveRoot, reactiveComponent, reactiveElement) and the host layer (HostConfig, createRoot().render()) are completely disconnected. Signals update in isolation. The reconciler mounts a static tree. There is no mechanism for signal-driven prop changes to flow through HostConfig's prepareUpdate/commitUpdate.

This is the highest-ROI change: it unblocks both the desktop UI use case (spoke UI with instanced glyphs) and the flowgraph use case (reactive status propagation) without requiring tree-level diffing.

Current State

Reactive Layer (src/core/reactive.ts)

  • ReactiveRoot holds a Signal<UNode> — the root of the element tree
  • reactiveComponent(component, propsSignal) returns a ReactiveNode whose .signal is computed(() => component(propsSignal.value))
  • reactiveElement(type, propsSignal, childrenSignals) returns a ReactiveNode producing a computed UElement
  • ReactiveRoot.subscribe(listener) creates an effect that fires on every change
  • ReactiveRoot.render(emit) creates an effect that emits root.render events
  • All dispose functions are no-ops

Host Layer (src/host/config.ts)

  • HostConfig<TTag, Instance, RootCtx> declares prepareUpdate and commitUpdate as optional methods
  • createRoot().render(node) is mount-only — walks the tree, creates instances, never calls update methods
  • createRoot().unmount() is a stub — calls finalizeRoot() but doesn't tear down any instances
  • No instance tree is stored between renders; no fiber tree; no reference tracking

The Gap

When a ReactiveRoot's signal tree changes, there is no code that:

  1. Compares the old tree to the new tree
  2. Calls prepareUpdate on instances whose props changed
  3. Calls commitUpdate with the resulting payload
  4. Calls removeChild for removed nodes
  5. Calls insertBefore for reordered nodes

Proposed Architecture

Instance Tree (Fiber-like)

The reconciler needs to maintain a parallel tree of "fiber" nodes that map UElement positions to host instances. Each fiber stores:

interface Fiber<I> {
  instance: I;
  tag: string;
  props: Record<string, unknown>;
  key: string | undefined;
  children: Fiber<I>[];
  parent: Fiber<I> | null;
  effect: Effect | null;
}

type Effect =
  | { type: "update"; payload: unknown }
  | { type: "insert"; before: Fiber<I> | null }
  | { type: "remove" }

Bridge Flow

Signal change on ReactiveRoot
  → effect fires
  → reconciler.reconcile(prevTree, nextTree)
  → for each changed node:
      if props changed: host.prepareUpdate() → queue "update" effect
      if node added:    host.createInstance() + host.appendChild() → queue "insert" effect
      if node removed:  host.removeChild() → queue "remove" effect
  → commit effects in tree order (parent → child, top-down)
  → host.commitUpdate() for each update effect

Phase 1 Scope (Signal-Only Property Updates)

Phase 1 only handles the case where the tree structure is unchanged and only props change. This covers the dominant case for both the desktop UI and flowgraph:

Scenario What Happens
Glyph color changes Signal updates → prepareUpdatecommitUpdate → write to InstancedBufferAttribute
Panel resize Signal updates → prepareUpdatecommitUpdate → update matrix
Workflow node status change Signal updates → prepareUpdatecommitUpdate → status propagation

Structural changes (add/remove/reorder children) are Phase 2.

Implementation Details

1. Fiber Tree Construction During Mount

When createRoot().render(node) is called for the first time, build the fiber tree alongside host instances:

function mountElement(node: UElement, ctx: RootCtx, parentFiber: Fiber | null): Fiber {
  const instance = host.createInstance(node.type, node.props, ctx, parentFiber?.instance);
  const fiber: Fiber = {
    instance,
    tag: node.type,
    props: node.props,
    key: (node as any).key,
    children: [],
    parent: parentFiber,
    effect: null,
  };
  for (const child of node.children) {
    if (isUElement(child)) {
      const childFiber = mountElement(child, ctx, fiber);
      host.appendChild(instance, childFiber.instance, ctx);
      fiber.children.push(childFiber);
    } else if (isUPrimitive(child)) {
      const textInstance = host.createTextInstance(String(child), ctx, instance);
      // text fibers — possibly simplified
    }
  }
  return fiber;
}

2. Signal Subscription During Mount

When mounting a ReactiveNode-backed element, subscribe to its signal:

function mountReactiveElement(reactiveNode: ReactiveNode, ctx: RootCtx, parentFiber: Fiber | null): Fiber {
  const initialNode = reactiveNode.signal.value;
  const fiber = mountElement(initialNode, ctx, parentFiber);

  // Subscribe to signal changes
  const dispose = effect(() => {
    const nextNode = reactiveNode.signal.value;
    if (nextNode !== initialNode) {
      scheduleUpdate(fiber, nextNode);
    }
  });

  fiber.dispose = dispose;
  return fiber;
}

3. Update Scheduling

When a signal changes, schedule a reconciliation pass:

function scheduleUpdate(fiber: Fiber, nextNode: UElement): void {
  // Queue the update — could be batched
  pendingUpdates.push({ fiber, nextNode });
  if (!flushScheduled) {
    flushScheduled = true;
    queueMicrotask(flushUpdates);
  }
}

4. Property-Only Reconciliation (Phase 1)

For Phase 1, the reconciliation is simple: same tag, same key, same children count → just diff props.

function reconcileProps(fiber: Fiber, nextNode: UElement, ctx: RootCtx): void {
  if (fiber.tag !== nextNode.type) {
    // Tag changed — this is a structural change, defer to Phase 2
    return;
  }

  const payload = host.prepareUpdate?.(
    fiber.instance,
    fiber.tag,
    fiber.props,
    nextNode.props,
    ctx,
  );

  if (payload !== null && payload !== undefined) {
    fiber.effect = { type: "update", payload };
    fiber.props = nextNode.props;
  }

  // Recursively reconcile children (Phase 1: assume same structure)
  const prevChildren = fiber.children;
  const nextChildren = nextNode.children.filter(isUElement);
  for (let i = 0; i < Math.min(prevChildren.length, nextChildren.length); i++) {
    reconcileProps(prevChildren[i], nextChildren[i] as UElement, ctx);
  }
}

5. Effect Commit

Effects are committed top-down (parent before child):

function commitEffects(fiber: Fiber, ctx: RootCtx): void {
  if (fiber.effect?.type === "update") {
    host.commitUpdate?.(
      fiber.instance,
      fiber.effect.payload,
      fiber.tag,
      fiber.props, // prevProps already updated — we should store prevProps separately
      fiber.props,
      ctx,
    );
    fiber.effect = null;
  }
  for (const child of fiber.children) {
    commitEffects(child, ctx);
  }
}

6. Disposal on Unmount

When unmount() is called, dispose all signal subscriptions and remove all instances:

function unmountFiber(fiber: Fiber, ctx: RootCtx): void {
  fiber.dispose?.();
  for (const child of fiber.children) {
    unmountFiber(child, ctx);
    host.removeChild?.(fiber.instance, child.instance, ctx);
  }
  fiber.children = [];
}

Changes to Existing Files

File Change
src/host/config.ts Add Fiber type, fiber tree construction in mountNode(), signal subscription, update scheduling, property reconciliation, effect commit, disposal
src/core/reactive.ts Connect ReactiveRoot to reconciler (provide renderToHost(host, container) method?), implement real dispose functions
src/core/schema.ts Add key field to UElement (see Phase 0 prereq)
test/mod.test.ts Add tests for: re-render with prop changes, prepareUpdate/commitUpdate called, signal update triggers host update, disposal cleans up

Dependencies

  • Phase 0: key field on UElement (prerequisite for Phase 2, but useful for Phase 1 fiber identity too)
  • @preact/signals-core (already a dependency)
  • No new external dependencies

Open Questions

  1. Should ReactiveRoot know about HostConfig? Currently they're separate. Options:

    • ReactiveRoot.renderToHost(host, container) — couples reactive to host
    • createReactiveRoot(host, container) — new factory that bridges both
    • Keep them separate, let the consumer wire them — most flexible but requires boilerplate
  2. Should updates be batched automatically? @preact/signals-core already batches signal writes. Should we also batch reconciliation across multiple signal changes?

  3. How to handle function components in the fiber tree? Function components produce a UNode but don't have a host instance. Should they be transparent (skipped) in the fiber tree, or should they have a "virtual" fiber with no instance?

Out of Scope (Deferred to Phase 2)

  • Key-based children reconciliation (add/remove/reorder)
  • removeChild / insertBefore host calls
  • Tree-level structural diffing
  • Component re-rendering (function component → new output → reconcile against fiber tree)