258 lines
9.1 KiB
Markdown
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) |