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

258 lines
9.1 KiB
Markdown

# 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:
```typescript
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:
```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)