# 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` — 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` 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: ```typescript interface Fiber { instance: I; tag: string; props: Record; key: string | undefined; children: Fiber[]; parent: Fiber | null; effect: Effect | null; } type Effect = | { type: "update"; payload: unknown } | { type: "insert"; before: Fiber | 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 → `prepareUpdate` → `commitUpdate` → write to InstancedBufferAttribute | | Panel resize | Signal updates → `prepareUpdate` → `commitUpdate` → update matrix | | Workflow node status change | Signal updates → `prepareUpdate` → `commitUpdate` → 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: ```typescript 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: ```typescript 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: ```typescript 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. ```typescript 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): ```typescript 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: ```typescript 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)