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)
ReactiveRootholds aSignal<UNode>— the root of the element treereactiveComponent(component, propsSignal)returns aReactiveNodewhose.signaliscomputed(() => component(propsSignal.value))reactiveElement(type, propsSignal, childrenSignals)returns aReactiveNodeproducing a computedUElementReactiveRoot.subscribe(listener)creates aneffectthat fires on every changeReactiveRoot.render(emit)creates an effect that emitsroot.renderevents- All
disposefunctions are no-ops
Host Layer (src/host/config.ts)
HostConfig<TTag, Instance, RootCtx>declaresprepareUpdateandcommitUpdateas optional methodscreateRoot().render(node)is mount-only — walks the tree, creates instances, never calls update methodscreateRoot().unmount()is a stub — callsfinalizeRoot()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:
- Compares the old tree to the new tree
- Calls
prepareUpdateon instances whose props changed - Calls
commitUpdatewith the resulting payload - Calls
removeChildfor removed nodes - Calls
insertBeforefor 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 → 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:
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:
keyfield onUElement(prerequisite for Phase 2, but useful for Phase 1 fiber identity too) @preact/signals-core(already a dependency)- No new external dependencies
Open Questions
-
Should
ReactiveRootknow aboutHostConfig? Currently they're separate. Options:ReactiveRoot.renderToHost(host, container)— couples reactive to hostcreateReactiveRoot(host, container)— new factory that bridges both- Keep them separate, let the consumer wire them — most flexible but requires boilerplate
-
Should updates be batched automatically?
@preact/signals-corealready batches signal writes. Should we also batch reconciliation across multiple signal changes? -
How to handle function components in the fiber tree? Function components produce a
UNodebut 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/insertBeforehost calls- Tree-level structural diffing
- Component re-rendering (function component → new output → reconcile against fiber tree)