add reconciler implementation plan: 6-phase spec with dependency graph and parallelism analysis
This commit is contained in:
@@ -41,6 +41,18 @@ All research documents for the `@alkdev/ujsx` rewrite. This project is a univers
|
||||
| `/workspace/signals/packages/core/` | Preact Signals core: signal, computed, effect, batch, createModel |
|
||||
| `/workspace/conversations/research/` | Original research docs from conversation session |
|
||||
|
||||
## Reconciler Implementation Plan
|
||||
|
||||
| Doc | Description |
|
||||
|-----|-------------|
|
||||
| [reconciler/README.md](./reconciler/README.md) | **Index** — Phase overview, dependency graph, parallelism opportunities, key design decisions |
|
||||
| [reconciler/00-KEY-FIELD-DESIGN.md](./reconciler/00-KEY-FIELD-DESIGN.md) | Phase 0: Add `key?: string` as first-class field on `UElement` |
|
||||
| [reconciler/01-reactive-host-bridge.md](./reconciler/01-reactive-host-bridge.md) | Phase 1: Connect reactive layer to HostConfig reconciler via fiber tree |
|
||||
| [reconciler/02-key-based-children-reconciliation.md](./reconciler/02-key-based-children-reconciliation.md) | Phase 2: Key-based children matching with LIS algorithm for move detection |
|
||||
| [reconciler/03-unmount-dispose-support.md](./reconciler/03-unmount-dispose-support.md) | Phase 3: Proper disposal — unmount, signal cleanup, instance teardown |
|
||||
| [reconciler/04-typebox-optimization-layer.md](./reconciler/04-typebox-optimization-layer.md) | Phase 4: TypeBox Value primitives as incremental performance optimizations |
|
||||
| [reconciler/05-flowgraph-host-configs.md](./reconciler/05-flowgraph-host-configs.md) | Phase 5: Flowgraph HostConfig implementations (graphology DAG, reactive executor) |
|
||||
|
||||
## Key Decisions from Prior Discussion
|
||||
|
||||
1. **TypeBox Module IS the type registry** — no separate registry needed. `ValuePointer.Get/Set` for runtime access, `Module.Import` for resolved schemas.
|
||||
|
||||
132
docs/research/reconciler/00-KEY-FIELD-DESIGN.md
Normal file
132
docs/research/reconciler/00-KEY-FIELD-DESIGN.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Phase 0: `key` Field on `UElement`
|
||||
|
||||
## Status: Spec (Draft)
|
||||
|
||||
## Problem
|
||||
|
||||
The reconciler needs an identity mechanism to match old children to new children across re-renders. React uses the `key` prop for this. Currently, `UElement` has no `key` field — it would be buried inside `props`, which creates ambiguity about whether it's a user-defined prop or a reconciler hint.
|
||||
|
||||
## Proposed Design
|
||||
|
||||
Add `key` as a first-class optional field on `UElement`:
|
||||
|
||||
```typescript
|
||||
export type UElement = {
|
||||
type: string;
|
||||
props: UniversalProps;
|
||||
children: UNode[];
|
||||
key?: string;
|
||||
};
|
||||
```
|
||||
|
||||
### TypeBox Schema
|
||||
|
||||
```typescript
|
||||
UElement: Type.Object({
|
||||
type: Type.String(),
|
||||
props: Type.Ref("UniversalProps"),
|
||||
children: Type.Array(Type.Ref("UNode")),
|
||||
key: Type.Optional(Type.String()),
|
||||
}),
|
||||
```
|
||||
|
||||
### Why Not In `props`?
|
||||
|
||||
React puts `key` in `props` but treats it specially (strips it before passing to component functions). This has caused decades of confusion:
|
||||
|
||||
1. **`key` leaks into component props** — Developers try to read `this.props.key`, but it's undefined. React strips it. The mental model is "it's a prop but not really a prop."
|
||||
|
||||
2. **`key` doesn't validate with props schemas** — If a component's TypeBox schema is `Type.Object({ name: Type.String() })`, passing `key` alongside `name` would fail validation because `key` isn't in the schema.
|
||||
|
||||
3. **`key` is a reconciler concern, not a component concern** — Components should never need to know their own key. It's metadata for the reconciliation algorithm, not data for the component function.
|
||||
|
||||
Making `key` a first-class field solves all three issues.
|
||||
|
||||
### How `h()` Handles `key`
|
||||
|
||||
The `h()` factory extracts `key` from the props argument and promotes it to the element level:
|
||||
|
||||
```typescript
|
||||
export function h(
|
||||
type: UType,
|
||||
props?: UniversalProps | null,
|
||||
...children: UNode[]
|
||||
): UElement {
|
||||
const { key, ...rest } = props ?? {};
|
||||
return {
|
||||
type: typeof type === "string" ? type : type.displayName ?? type.name ?? "anonymous",
|
||||
props: rest,
|
||||
children: children.flat(Infinity).filter(c => c != null && c !== false) as UNode[],
|
||||
key: key as string | undefined,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
This means:
|
||||
- **JSX syntax works naturally:** `<Operation key="classify" name="..." />` — the JSX transform passes `key` in the props object, and `h()` extracts it.
|
||||
- **Component functions never receive `key`:** It's stripped before being passed to the component.
|
||||
- **The element has `key` at the top level:** The reconciler reads `element.key` without inspecting `props`.
|
||||
|
||||
### Serialization Implications
|
||||
|
||||
`key` is optional. When a `UElement` is serialized to JSON (for template storage, network transfer, etc.):
|
||||
|
||||
- With `key`: `{ "type": "operation", "props": { "name": "classify" }, "children": [], "key": "classify" }`
|
||||
- Without `key`: `{ "type": "operation", "props": { "name": "classify" }, "children": [] }`
|
||||
|
||||
The `key` field is included in the TypeBox schema as `Type.Optional(Type.String())`, so validation passes either way. Serialized templates that include `key` can be round-tripped without loss.
|
||||
|
||||
### `key` Semantics
|
||||
|
||||
| Situation | `key` value | Reconciler behavior |
|
||||
|-----------|------------|---------------------|
|
||||
| Not provided | `undefined` | Positional matching (same as current behavior) |
|
||||
| Provided, unique | Any string | Matched by `key` across re-renders |
|
||||
| Provided, duplicate | Same string as sibling | Last-wins; warn in development |
|
||||
| `null` | Treated as `undefined` | Positional matching |
|
||||
|
||||
Keys are **local to their parent** — two elements in different parts of the tree can have the same key without conflict.
|
||||
|
||||
### `key` and `URoot`
|
||||
|
||||
`URoot` does not get a `key` field. Roots are unique per `createRoot()` call and are never children of another element. There's no scenario where two roots need to be distinguished by the reconciler.
|
||||
|
||||
The existing `id` prop on `URoot.props` serves the identification purpose for roots:
|
||||
|
||||
```typescript
|
||||
export type URoot = {
|
||||
type: "root";
|
||||
props: UniversalProps; // id lives here
|
||||
children: UNode[];
|
||||
};
|
||||
```
|
||||
|
||||
## Changes to Existing Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/core/schema.ts` | Add `key?: string` to `UElement` type and TypeBox `UElement` schema |
|
||||
| `src/core/h.ts` | Extract `key` from props in `h()`, promote to element level. Strip from `props` before passing to component functions. |
|
||||
| `src/core/jsx-runtime.ts` | No change — re-exports `h()` which now handles `key` |
|
||||
| `test/mod.test.ts` | Add tests: `h()` extracts `key`, `key` not in `props`, `key` is optional, `key` in TypeBox schema validation |
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
This is a **non-breaking change**:
|
||||
|
||||
- Elements without `key` continue to work identically (positional matching)
|
||||
- The `key` field is optional in both the TypeScript type and the TypeBox schema
|
||||
- Existing code that passes `key` in props will now have it correctly extracted instead of being silently left in `props`
|
||||
- The TypeBox `UElement` schema accepts objects without `key` (it's `Type.Optional`)
|
||||
|
||||
## Dependencies
|
||||
|
||||
None — this is a prerequisite for Phase 2 (key-based children reconciliation) but has no dependencies itself.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should `key` accept numbers?** React accepts `key={0}`, `key={1}` etc. and converts to string. Should ujsx do the same, or enforce `string` only? Recommendation: `string` only — simpler, no implicit coercion. Users can wrap in `String()` if needed.
|
||||
|
||||
2. **Should `key` appear in the `UPrimitive` union?** No — primitives don't need identity. They're leaf nodes with no children to reconcile.
|
||||
|
||||
3. **What about the `id` prop convention?** Some hosts use `props.id` for identification (graph node IDs, DOM IDs). `key` is different — it's for the reconciler's identity matching and is never passed to the host. `props.id` is for the host's identity and IS passed through. They serve different purposes and can coexist.
|
||||
258
docs/research/reconciler/01-reactive-host-bridge.md
Normal file
258
docs/research/reconciler/01-reactive-host-bridge.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# 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)
|
||||
302
docs/research/reconciler/02-key-based-children-reconciliation.md
Normal file
302
docs/research/reconciler/02-key-based-children-reconciliation.md
Normal file
@@ -0,0 +1,302 @@
|
||||
# Phase 2: Key-Based Children Reconciliation
|
||||
|
||||
## Status: Spec (Draft)
|
||||
|
||||
## Problem
|
||||
|
||||
Phase 1 handles property updates on unchanged tree structures. But when children are added, removed, or reordered, the reconciler must determine which old children correspond to which new children. Without key-based matching, the reconciler can only do positional comparison — which produces incorrect results for reorderings (destroying and recreating instances instead of moving them).
|
||||
|
||||
This is the same problem React's reconciler solves with the `key` prop and the longest-increasing-subsequence (LIS) algorithm.
|
||||
|
||||
## Why TypeBox's Value.Diff Is Not Sufficient
|
||||
|
||||
`Value.Diff` compares arrays **positionally by index**. This is the fundamental mismatch:
|
||||
|
||||
```
|
||||
Current: [{ type: 'div', key: 'a' }, { type: 'div', key: 'b' }, { type: 'div', key: 'c' }]
|
||||
Next: [{ type: 'div', key: 'b' }, { type: 'div', key: 'a' }, { type: 'div', key: 'c' }]
|
||||
```
|
||||
|
||||
TypeBox `Diff` produces:
|
||||
- `Update('/0/key', 'b')` — treats position 0 as "changed from a to b"
|
||||
- `Update('/1/key', 'a')` — treats position 1 as "changed from b to a"
|
||||
|
||||
Correct reconciler interpretation:
|
||||
- Element with `key='a'` **moved** from position 0 → position 1
|
||||
- Element with `key='b'` **moved** from position 1 → position 0
|
||||
- Element with `key='c'` **stayed** at position 2
|
||||
|
||||
The reconciler must track identity (via `key`), not position. `Value.Diff` does not understand identity.
|
||||
|
||||
## The Algorithm
|
||||
|
||||
The algorithm is adapted from React's children reconciliation (react-reconciler `ChildReconciler`). It runs in O(n) for most cases and O(n log n) worst case (for the LIS computation).
|
||||
|
||||
### Step 1: Build Key Maps
|
||||
|
||||
```typescript
|
||||
interface ChildMapping {
|
||||
key: string | null; // null for unkeyed children
|
||||
index: number; // position in new children array
|
||||
node: UElement;
|
||||
}
|
||||
|
||||
function buildKeyMap(children: UNode[]): Map<string | null, { node: UElement; index: number }> {
|
||||
const map = new Map();
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
if (!isUElement(child)) continue;
|
||||
const key = child.key ?? null;
|
||||
if (map.has(key)) {
|
||||
// Duplicate key — warn in dev, use last occurrence
|
||||
}
|
||||
map.set(key, { node: child, index: i });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Match Old → New Children
|
||||
|
||||
```typescript
|
||||
interface MatchResult {
|
||||
matched: Array<{ oldFiber: Fiber; newChild: UElement; index: number }>;
|
||||
added: Array<{ newChild: UElement; index: number }>;
|
||||
removed: Fiber[];
|
||||
}
|
||||
|
||||
function matchChildren(oldFibers: Fiber[], newChildren: UNode[]): MatchResult {
|
||||
const oldKeyMap = new Map<string | null, Fiber>();
|
||||
for (const fiber of oldFibers) {
|
||||
oldKeyMap.set(fiber.key ?? null, fiber);
|
||||
}
|
||||
|
||||
const matched: MatchResult["matched"] = [];
|
||||
const added: MatchResult["added"] = [];
|
||||
const removed: Fiber[] = [];
|
||||
|
||||
const newKeySet = new Set<string | null>();
|
||||
|
||||
for (let i = 0; i < newChildren.length; i++) {
|
||||
const child = newChildren[i];
|
||||
if (!isUElement(child)) continue;
|
||||
|
||||
const key = child.key ?? null;
|
||||
newKeySet.add(key);
|
||||
|
||||
const oldFiber = oldKeyMap.get(key);
|
||||
if (oldFiber) {
|
||||
matched.push({ oldFiber, newChild: child, index: i });
|
||||
} else {
|
||||
added.push({ newChild: child, index: i });
|
||||
}
|
||||
}
|
||||
|
||||
// Old fibers not matched by any new child
|
||||
for (const fiber of oldFibers) {
|
||||
if (!newKeySet.has(fiber.key ?? null)) {
|
||||
removed.push(fiber);
|
||||
}
|
||||
}
|
||||
|
||||
return { matched, added, removed };
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Determine Moves Using LIS
|
||||
|
||||
For matched children, determine which ones need to move. The LIS of the matched children's old indices gives the longest sequence that's already in the correct relative order. Elements in the LIS stay; everything else moves.
|
||||
|
||||
```typescript
|
||||
function longestIncreasingSubsequence(indices: number[]): number[] {
|
||||
// Standard O(n log n) LIS algorithm
|
||||
// Returns the indices (into the input array) of elements in the LIS
|
||||
const piles: number[] = [];
|
||||
const backpointers: number[] = [];
|
||||
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
const val = indices[i];
|
||||
let lo = 0, hi = piles.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (indices[piles[mid]] < val) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
if (lo > 0) backpointers[i] = piles[lo - 1];
|
||||
else backpointers[i] = -1;
|
||||
piles[lo] = i;
|
||||
}
|
||||
|
||||
// Reconstruct
|
||||
const result: number[] = [];
|
||||
let k = piles[piles.length - 1];
|
||||
while (k >= 0) {
|
||||
result.unshift(k);
|
||||
k = backpointers[k];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Generate Mutations
|
||||
|
||||
```typescript
|
||||
interface ChildMutation {
|
||||
type: "update" | "move" | "insert" | "remove";
|
||||
fiber?: Fiber; // for update/move/remove
|
||||
newChild?: UElement; // for insert
|
||||
before?: Fiber | null; // for move/insert (insertBefore target)
|
||||
payload?: unknown; // update payload from prepareUpdate
|
||||
}
|
||||
|
||||
function reconcileChildren(
|
||||
oldFibers: Fiber[],
|
||||
newChildren: UNode[],
|
||||
ctx: RootCtx,
|
||||
): ChildMutation[] {
|
||||
const { matched, added, removed } = matchChildren(oldFibers, newChildren);
|
||||
|
||||
const mutations: ChildMutation[] = [];
|
||||
|
||||
// 1. Remove deleted children
|
||||
for (const fiber of removed) {
|
||||
mutations.push({ type: "remove", fiber });
|
||||
}
|
||||
|
||||
// 2. Compute LIS for matched children
|
||||
const oldIndices = matched.map(m => oldFibers.indexOf(m.oldFiber));
|
||||
const lisIndices = new Set(longestIncreasingSubsequence(oldIndices));
|
||||
|
||||
// 3. Process matched children
|
||||
for (let i = 0; i < matched.length; i++) {
|
||||
const { oldFiber, newChild } = matched[i];
|
||||
const needsMove = !lisIndices.has(i);
|
||||
|
||||
// Props update
|
||||
if (oldFiber.tag === newChild.type) {
|
||||
const payload = host.prepareUpdate?.(oldFiber.instance, oldFiber.tag, oldFiber.props, newChild.props, ctx);
|
||||
if (payload !== null) {
|
||||
mutations.push({ type: "update", fiber: oldFiber, payload });
|
||||
}
|
||||
} else {
|
||||
// Tag changed — treat as remove + insert
|
||||
mutations.push({ type: "remove", fiber: oldFiber });
|
||||
mutations.push({ type: "insert", newChild });
|
||||
}
|
||||
|
||||
// Move
|
||||
if (needsMove) {
|
||||
mutations.push({ type: "move", fiber: oldFiber, before: null /* determined during commit */ });
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Insert new children
|
||||
for (const { newChild } of added) {
|
||||
mutations.push({ type: "insert", newChild });
|
||||
}
|
||||
|
||||
return mutations;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Commit Mutations
|
||||
|
||||
Mutations are committed in a specific order:
|
||||
1. **Removes** — remove old instances first (reverse order to preserve indices)
|
||||
2. **Inserts** — create new instances and append
|
||||
3. **Moves** — use `insertBefore` to reposition
|
||||
4. **Updates** — call `commitUpdate` on changed instances
|
||||
|
||||
```typescript
|
||||
function commitChildMutations(mutations: ChildMutation[], parentInstance: I, ctx: RootCtx): void {
|
||||
// 1. Removes (reverse order)
|
||||
const removes = mutations.filter(m => m.type === "remove").reverse();
|
||||
for (const m of removes) {
|
||||
host.removeChild?.(parentInstance, m.fiber!.instance, ctx);
|
||||
}
|
||||
|
||||
// 2. Inserts + Moves (left-to-right for correct insertBefore targets)
|
||||
const insertsAndMoves = mutations.filter(m => m.type === "insert" || m.type === "move");
|
||||
for (const m of insertsAndMoves) {
|
||||
if (m.type === "insert") {
|
||||
const instance = host.createInstance(m.newChild!.type, m.newChild!.props, ctx, parentInstance);
|
||||
// Determine before target by current DOM state
|
||||
host.appendChild(parentInstance, instance, ctx); // or insertBefore
|
||||
} else if (m.type === "move") {
|
||||
// Find the next sibling that's not also being moved
|
||||
host.insertBefore?.(parentInstance, m.fiber!.instance, m.before?.instance ?? null, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Updates
|
||||
const updates = mutations.filter(m => m.type === "update");
|
||||
for (const m of updates) {
|
||||
host.commitUpdate?.(m.fiber!.instance, m.payload, m.fiber!.tag, m.fiber!.props, m.fiber!.props, ctx);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/host/
|
||||
config.ts # HostConfig interface (unchanged)
|
||||
reconcile.ts # NEW: reconcile(), matchChildren(), LIS, commitMutations()
|
||||
fiber.ts # NEW: Fiber type, fiber tree operations
|
||||
```
|
||||
|
||||
## Changes to Existing Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/host/config.ts` | `createRoot().render()` now calls `reconcile()` instead of mount-only walk. Second render diffs against stored fiber tree. |
|
||||
| `src/core/schema.ts` | `UElement` gets `key?: string` field |
|
||||
| `src/core/h.ts` | `h()` extracts `key` from props, does not pass it through to host's `createInstance` |
|
||||
| `test/mod.test.ts` | New test section for reconciliation: key matching, reorder, add, remove, mixed operations |
|
||||
|
||||
## Edge Cases to Handle
|
||||
|
||||
| Case | Behavior |
|
||||
|------|----------|
|
||||
| No keys (all `null`) | Falls back to positional matching (same as current behavior) |
|
||||
| Duplicate keys | Warn, use last occurrence (matches React behavior) |
|
||||
| Mixed keyed/unkeyed | Keyed children matched by key, unkeyed matched by position among remaining unkeyed slots |
|
||||
| Null children | Skip (already filtered by `h()`) |
|
||||
| Fragment children | Flatten before reconciliation |
|
||||
| Nested reconciliation | Each level of the tree reconciles its children independently |
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **O(n) for the common case** — Most re-renders have the same children order. The LIS computation only matters for reorderings.
|
||||
- **Hash-based quick bail-out** — Before running the full reconcile, compute `Value.Hash` on each child. If hashes match, skip that child entirely. This is an optimization, not a correctness requirement.
|
||||
- **Batched updates** — Signal changes within `batch()` should only trigger one reconciliation pass.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Phase 1 (reactive → host bridge) must be complete
|
||||
- `key` field on `UElement` (Phase 0 / schema change)
|
||||
- TypeBox `Value.Equal` for prop comparison (optimization)
|
||||
- TypeBox `Value.Hash` for subtree identity (optimization, not correctness)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should `key` be the only identity mechanism?** React also uses `type` as part of identity (same key, different type = destroy + create). Should ujsx do the same, or should key alone determine identity?
|
||||
|
||||
2. **How fragile is LIS for small lists?** For lists of < 5 items, the overhead of LIS might exceed the cost of just moving everything. Should there be a threshold below which we skip LIS?
|
||||
|
||||
3. **Should the fiber tree store the previous props separately?** For `commitUpdate`, the host receives both `prevProps` and `nextProps`. Currently Phase 1's `reconcileProps` overwrites `fiber.props` before committing. Need to store `prevProps` separately.
|
||||
|
||||
## Test Cases
|
||||
|
||||
1. Same children, same order, same props → no mutations
|
||||
2. Same children, same order, different props → update mutations only
|
||||
3. Same children, different order → move mutations (LIS determines which)
|
||||
4. Children added at end → insert mutations
|
||||
5. Children added at beginning → insert + move mutations
|
||||
6. Children removed from middle → remove mutations
|
||||
7. Children removed from end → remove mutations
|
||||
8. Mixed: add + remove + reorder → correct combination
|
||||
9. Unkeyed children → positional matching
|
||||
10. Mixed keyed/unkeyed children → keyed matched by key, unkeyed by position
|
||||
11. Duplicate keys → last-wins, warn
|
||||
12. Tag change with same key → remove old + insert new
|
||||
228
docs/research/reconciler/03-unmount-dispose-support.md
Normal file
228
docs/research/reconciler/03-unmount-dispose-support.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# Phase 3: Unmount & Dispose Support
|
||||
|
||||
## Status: Spec (Draft)
|
||||
|
||||
## Problem
|
||||
|
||||
The current reconciler has two disposal gaps:
|
||||
|
||||
1. **`unmount()` is a stub** — it calls `finalizeRoot()` and emits an event, but does not tear down the instance tree, remove children, or clean up signal subscriptions.
|
||||
|
||||
2. **`reactiveComponent` and `reactiveElement` have no-op `dispose` functions** — signal subscriptions created by `effect()` during reactive mounting are never cleaned up, causing memory leaks and potential stale updates.
|
||||
|
||||
Both must be fixed before the reconciler is production-ready. Without proper disposal:
|
||||
- Re-rendering a root leaks the old fiber tree
|
||||
- Signal effects from unmounted components continue to fire
|
||||
- Workflows that create/destroy sub-trees (e.g., conditional branches in flowgraph) accumulate orphaned subscriptions
|
||||
|
||||
## Current State
|
||||
|
||||
### `unmount()` in `src/host/config.ts`
|
||||
|
||||
```typescript
|
||||
unmount(): void {
|
||||
this.host.finalizeRoot?.(this.ctx);
|
||||
this.host.emit?.("root.unmount", `root_${Date.now()}`, {});
|
||||
// No fiber tree teardown
|
||||
// No signal disposal
|
||||
// No instance removal
|
||||
}
|
||||
```
|
||||
|
||||
### `dispose` in `src/core/reactive.ts`
|
||||
|
||||
```typescript
|
||||
// reactiveComponent
|
||||
dispose: () => {} // no-op
|
||||
|
||||
// reactiveElement
|
||||
dispose: () => {} // no-op
|
||||
```
|
||||
|
||||
### `effect` return values are discarded
|
||||
|
||||
Preact's `effect()` returns a dispose function. Currently, these return values are thrown away — no reference is stored, no cleanup is possible.
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
### Fiber Tree Disposal
|
||||
|
||||
Each `Fiber` node tracks its lifecycle resources:
|
||||
|
||||
```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;
|
||||
signalDisposers: (() => void)[]; // signal effects to clean up
|
||||
}
|
||||
```
|
||||
|
||||
### Disposal Flow
|
||||
|
||||
```
|
||||
root.unmount()
|
||||
→ disposeFiber(rootFiber, ctx)
|
||||
→ for each child fiber (bottom-up, children before parent):
|
||||
disposeFiber(child, ctx)
|
||||
host.removeChild(parent.instance, child.instance, ctx)
|
||||
→ dispose signal subscriptions
|
||||
→ clear fiber state
|
||||
→ host.finalizeRoot(ctx)
|
||||
```
|
||||
|
||||
Bottom-up disposal ensures children are removed before their parents, which is the correct order for most host environments (DOM, graphology, etc.).
|
||||
|
||||
### Signal Effect Disposal
|
||||
|
||||
When a `ReactiveNode` is mounted into the fiber tree, its `effect` subscription must be tracked:
|
||||
|
||||
```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 — store the dispose function
|
||||
const signalDisposer = effect(() => {
|
||||
const nextNode = reactiveNode.signal.value;
|
||||
scheduleUpdate(fiber, nextNode);
|
||||
});
|
||||
|
||||
fiber.signalDisposers.push(signalDisposer);
|
||||
return fiber;
|
||||
}
|
||||
```
|
||||
|
||||
When the fiber is unmounted:
|
||||
|
||||
```typescript
|
||||
function disposeFiber<I>(fiber: Fiber<I>, ctx: RootCtx): void {
|
||||
// 1. Dispose children first (bottom-up)
|
||||
for (const child of fiber.children) {
|
||||
disposeFiber(child, ctx);
|
||||
}
|
||||
|
||||
// 2. Dispose signal subscriptions
|
||||
for (const disposer of fiber.signalDisposers) {
|
||||
disposer();
|
||||
}
|
||||
fiber.signalDisposers = [];
|
||||
|
||||
// 3. Remove from parent's host instance
|
||||
if (fiber.parent) {
|
||||
fiber.parent.host.removeChild?.(fiber.parent.instance, fiber.instance, ctx);
|
||||
}
|
||||
|
||||
// 4. Clear references
|
||||
fiber.children = [];
|
||||
fiber.parent = null;
|
||||
fiber.effect = null;
|
||||
}
|
||||
```
|
||||
|
||||
### ReactiveRoot Disposal
|
||||
|
||||
`ReactiveRoot` also has disposal gaps. Its `render()` method creates an `effect`:
|
||||
|
||||
```typescript
|
||||
render(emit: (event: { type: string; id: string; payload: unknown }) => void): () => void {
|
||||
this.renderDisposer = effect(() => {
|
||||
const node = this.root.value;
|
||||
emit({ type: "root.render", id: `root_${Date.now()}`, payload: node });
|
||||
});
|
||||
return () => {
|
||||
this.renderDisposer?.();
|
||||
this.renderDisposer = null;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The dispose function is returned but `ReactiveRoot` doesn't call it on its own cleanup. This should be wired:
|
||||
|
||||
```typescript
|
||||
class ReactiveRoot {
|
||||
private root: Signal<UNode>;
|
||||
private renderDisposer: (() => void) | null = null;
|
||||
private subscriberDisposers: (() => void)[] = [];
|
||||
|
||||
update(fn: (current: UNode) => UNode): void { /* unchanged */ }
|
||||
|
||||
subscribe(listener: (node: UNode) => void): () => void {
|
||||
const disposer = effect(() => { listener(this.root.value); });
|
||||
this.subscriberDisposers.push(disposer);
|
||||
return () => {
|
||||
disposer();
|
||||
this.subscriberDisposers = this.subscriberDisposers.filter(d => d !== disposer);
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.renderDisposer?.();
|
||||
this.renderDisposer = null;
|
||||
for (const d of this.subscriberDisposers) d();
|
||||
this.subscriberDisposers = [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Partial Tree Disposal
|
||||
|
||||
For conditional rendering (e.g., a workflow branch that no longer exists), the reconciler must be able to dispose a sub-tree without unmounting the entire root. This happens during Phase 2's `reconcileChildren` when children are removed:
|
||||
|
||||
```typescript
|
||||
// In reconcileChildren:
|
||||
for (const removedFiber of removed) {
|
||||
disposeFiber(removedFiber, ctx);
|
||||
// removeChild is called during commitMutations, not here
|
||||
}
|
||||
```
|
||||
|
||||
### Host Notification
|
||||
|
||||
The `HostConfig` should optionally support a `finalizeInstance` method for per-instance cleanup:
|
||||
|
||||
```typescript
|
||||
export interface HostConfig<TTag extends string, Instance, RootCtx> {
|
||||
// ... existing methods ...
|
||||
finalizeInstance?(instance: Instance, ctx: RootCtx): void;
|
||||
}
|
||||
```
|
||||
|
||||
This allows hosts to perform cleanup when an instance is removed (e.g., releasing GPU buffer slots, closing database connections, removing graphology nodes).
|
||||
|
||||
## Changes to Existing Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/host/config.ts` | `unmount()` calls `disposeFiber()`. Add `finalizeInstance` to `HostConfig`. |
|
||||
| `src/core/reactive.ts` | `ReactiveRoot.dispose()` method. `reactiveComponent`/`reactiveElement` return real dispose functions. Track effect disposers. |
|
||||
| `src/host/fiber.ts` | (New) `Fiber` type with `signalDisposers`, `disposeFiber()` function |
|
||||
| `test/mod.test.ts` | Tests for: `unmount()` calls `removeChild` on all instances, signal disposal, `ReactiveRoot.dispose()`, `finalizeInstance` called |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Phase 1 (fiber tree construction) — need fiber tree to dispose
|
||||
- Phase 2 (key-based reconciliation) — partial tree disposal happens when children are removed
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should `disposeFiber` remove the instance from the parent, or should that be a separate step?** Option A: `disposeFiber` handles everything (dispose children, dispose signals, remove from parent). Option B: Split into `disposeResources` (signals, cleanup) and `removeFromParent` (host call). Option B is better for batched mutations where removes happen in a specific order.
|
||||
|
||||
2. **Should `ReactiveRoot` auto-dispose on `unmount`?** If `ReactiveRoot` is connected to a `Root`, calling `root.unmount()` should also dispose the `ReactiveRoot`. But they might not be 1:1.
|
||||
|
||||
3. **What about `computed` signals created by `reactiveComponent`?** `computed()` signals don't need explicit disposal — they're garbage collected when nothing references them. Only `effect()` subscriptions need disposal. Confirm this understanding against `@preact/signals-core` internals.
|
||||
|
||||
## Test Cases
|
||||
|
||||
1. Full `unmount()` removes all instances via `removeChild`
|
||||
2. Full `unmount()` disposes all signal subscriptions
|
||||
3. Partial removal (one child removed) disposes that child's fiber and signals
|
||||
4. `ReactiveRoot.dispose()` stops `subscribe` and `render` effects
|
||||
5. `finalizeInstance` called on each removed instance
|
||||
6. Dispose is idempotent — calling twice doesn't error
|
||||
7. Nested components: disposing a parent disposes all children
|
||||
8. Conditionally rendered component: toggling a branch on/off properly mounts/unmounts
|
||||
250
docs/research/reconciler/04-typebox-optimization-layer.md
Normal file
250
docs/research/reconciler/04-typebox-optimization-layer.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Phase 4: TypeBox Value Optimization Layer
|
||||
|
||||
## Status: Spec (Draft)
|
||||
|
||||
## Problem
|
||||
|
||||
Phases 1-3 establish correct reconciliation. Phase 4 makes it fast by leveraging TypeBox's value primitives to eliminate unnecessary work. Without these optimizations, every reconciliation pass walks every node and compares every prop — even for unchanged subtrees.
|
||||
|
||||
## Why These Optimizations Matter
|
||||
|
||||
For the desktop UI use case: a spoke might have hundreds of `InstancedGlyph` instances. Each reconciliation pass that walks all of them is wasted work if only one glyph's color changed.
|
||||
|
||||
For flowgraph: a workflow with 50+ operations shouldn't re-check all 50 nodes when one operation's status changes.
|
||||
|
||||
## TypeBox Primitives and Their Application
|
||||
|
||||
### 1. `Value.Equal` — Subtree Bail-Out
|
||||
|
||||
**Purpose:** Skip reconciliation of entire subtrees when nothing changed.
|
||||
|
||||
**How it works:** `Value.Equal(prevNode, nextNode)` performs a deep structural equality check with short-circuit optimizations (different key count → false, different array length → false, etc.).
|
||||
|
||||
**Where to use it:**
|
||||
|
||||
```typescript
|
||||
function reconcile(fiber: Fiber, nextNode: UElement, ctx: RootCtx): void {
|
||||
// Quick bail-out: if the entire subtree is identical, skip it
|
||||
if (Value.Equal(fiber.cachedNode, nextNode)) {
|
||||
return;
|
||||
}
|
||||
// ... proceed with prop diffing and children reconciliation
|
||||
}
|
||||
```
|
||||
|
||||
**Trade-off:** `Value.Equal` is O(n) itself. On very large subtrees, it may be slower than just diffing. Use it as a first check, with a size threshold below which you skip the equality check and go straight to diffing.
|
||||
|
||||
**When it saves work:**
|
||||
- Signal-driven updates where the signal fired but the computed value didn't actually change
|
||||
- Parent re-renders that pass the same props to children
|
||||
- Batch updates where some children didn't change
|
||||
|
||||
### 2. `Value.Hash` — O(1) Change Detection
|
||||
|
||||
**Purpose:** Detect whether a subtree changed without walking it.
|
||||
|
||||
**How it works:** `Value.Hash(value)` computes an FNV-1a 64-bit hash of the entire value structure. Same structure → same hash. Different structure → different hash (with extremely low collision probability).
|
||||
|
||||
**Where to use it:**
|
||||
|
||||
```typescript
|
||||
interface Fiber<I> {
|
||||
// ... existing fields ...
|
||||
hash: bigint | null; // cached hash of the rendered node
|
||||
}
|
||||
|
||||
function reconcile(fiber: Fiber, nextNode: UElement, ctx: RootCtx): void {
|
||||
// O(1) change detection
|
||||
const nextHash = Value.Hash(nextNode);
|
||||
if (fiber.hash !== null && fiber.hash === nextHash) {
|
||||
return; // guaranteed unchanged (modulo hash collision)
|
||||
}
|
||||
fiber.hash = nextHash;
|
||||
// ... proceed with reconciliation
|
||||
}
|
||||
```
|
||||
|
||||
**Limitation:** `Value.Hash` uses a **global mutable accumulator** (FNV-1a state). This means:
|
||||
- It is NOT re-entrant — you cannot hash while already hashing
|
||||
- You MUST NOT call `Value.Hash` from within a `computed` or `effect` that is itself triggered by a hash comparison
|
||||
- Each call must complete before the next call starts
|
||||
|
||||
In practice, this means you should compute hashes **outside** of reactive computations — perhaps during the commit phase, not during the reconciliation phase.
|
||||
|
||||
**When it saves work:**
|
||||
- Large subtrees that are usually unchanged (e.g., a workflow's definition when only execution status is changing)
|
||||
- Quick identity checks on children during key matching
|
||||
|
||||
### 3. `Value.Mutate` — In-Place Prop Updates
|
||||
|
||||
**Purpose:** Update a fiber's props without creating a new object, preserving reference identity.
|
||||
|
||||
**How it works:** `Value.Mutate(current, next)` copies properties from `next` into `current` in-place. If `current` is `{ type: 'div', color: 'red' }` and `next` is `{ type: 'div', color: 'blue', width: 100 }`, after `Mutate` the `current` object is `{ type: 'div', color: 'blue', width: 100 }` — same reference, updated values.
|
||||
|
||||
**Where to use it:**
|
||||
|
||||
Instead of replacing `fiber.props = nextProps` (which creates a new object), use:
|
||||
|
||||
```typescript
|
||||
// During commit:
|
||||
Value.Mutate(fiber.props, nextProps);
|
||||
// fiber.props is the same object reference, but with updated values
|
||||
```
|
||||
|
||||
**Why this matters for hosts:** Some host environments (Three.js, graphology) track instances by reference. Creating a new props object breaks reference equality and forces the host to re-register or re-link the instance. `Value.Mutate` preserves the reference.
|
||||
|
||||
**Caveat:** `Value.Mutate` may not handle function values correctly (TypeBox's `Value.Diff` crashes on functions). Verify that `Mutate` handles function prop values (like event handlers) before depending on it.
|
||||
|
||||
### 4. `Value.Clone` — Immutable Snapshots
|
||||
|
||||
**Purpose:** Create a deep clone of a node before mutation, for comparison or rollback.
|
||||
|
||||
**How it works:** `Value.Clone(value)` creates a deep copy that handles objects, arrays, Map, Set, Date, and TypedArray. The clone is a new object with identical values.
|
||||
|
||||
**Where to use it:**
|
||||
|
||||
```typescript
|
||||
// Before starting reconciliation, snapshot the current tree
|
||||
const snapshot = Value.Clone(currentTree);
|
||||
|
||||
// After reconciliation, if something went wrong, roll back
|
||||
// currentTree = snapshot; // (hypothetical)
|
||||
```
|
||||
|
||||
**Primary use in the fiber tree:**
|
||||
|
||||
Store a `prevProps` snapshot on each fiber so that `commitUpdate` can receive both `prevProps` and `nextProps`:
|
||||
|
||||
```typescript
|
||||
interface Fiber<I> {
|
||||
// ...
|
||||
prevProps: Record<string, unknown> | null; // set before reconciliation
|
||||
}
|
||||
|
||||
// During reconcile:
|
||||
fiber.prevProps = Value.Clone(fiber.props);
|
||||
// ... apply updates to fiber.props ...
|
||||
|
||||
// During commit:
|
||||
host.commitUpdate(fiber.instance, payload, fiber.tag, fiber.prevProps!, fiber.props, ctx);
|
||||
```
|
||||
|
||||
### 5. `Value.Diff` — Property-Level Diffing (Matched Pairs Only)
|
||||
|
||||
**Purpose:** When a fiber's props have changed, determine exactly which properties changed without comparing every property manually.
|
||||
|
||||
**How it works:** `Value.Diff(current, next)` produces an array of `Edit` operations (`Insert | Update | Delete`) with RFC 6901 JSON Pointer paths. This is NOT used for tree-level reconciliation (it's positionally indexed for arrays), but IS useful for property-level diffing on a matched pair of elements.
|
||||
|
||||
**Where to use it:**
|
||||
|
||||
```typescript
|
||||
// After key-based matching has established that fiber A corresponds to nextNode B:
|
||||
const edits = Value.Diff(fiber.props, nextNode.props);
|
||||
// edits = [Update('/color', 'blue'), Insert('/width', 100), Delete('/height')]
|
||||
|
||||
// Pass the edits as the update payload
|
||||
const payload = edits;
|
||||
host.commitUpdate(fiber.instance, payload, fiber.tag, fiber.prevProps, nextNode.props, ctx);
|
||||
```
|
||||
|
||||
**Why this is safe here but not for tree-level diffing:** We're using `Diff` on the *props* object of a single matched element, not on the children array. There's no reordering problem because props is an object (keyed by property name), not an array (positionally indexed).
|
||||
|
||||
**Limitation:** `Value.Diff` throws `ValueDiffError` for function values. UElement props can contain functions (event handlers, component references). Need to either:
|
||||
- Strip function props before diffing
|
||||
- Catch the error and fall back to full replacement
|
||||
- Use `Value.Equal` for function-valued props individually
|
||||
|
||||
### 6. `Value.Pointer` — Targeted Navigation
|
||||
|
||||
**Purpose:** Navigate to specific positions in the tree for targeted updates.
|
||||
|
||||
**How it works:** `ValuePointer.Get(value, pointer)` navigates using RFC 6901 JSON Pointer syntax. `ValuePointer.Set(value, pointer, update)` sets a value at a pointer.
|
||||
|
||||
**Where to use it:**
|
||||
|
||||
Not directly used by the reconciler (which walks the tree structurally), but useful for:
|
||||
- Host implementations that need to update a specific prop by path
|
||||
- Debugging tools that want to inspect a specific position in the fiber tree
|
||||
- The `selectNode`/`setNode` utilities in `src/core/pointer.ts` (which already wrap `ValuePointer`)
|
||||
|
||||
## Optimization Strategy (Priority Order)
|
||||
|
||||
| Priority | Optimization | Impact | Risk |
|
||||
|----------|------------|--------|------|
|
||||
| 1 | `Value.Equal` bail-out at fiber level | High — skips entire unchanged subtrees | Low — simple equality check |
|
||||
| 2 | `Value.Hash` for O(1) change detection | Medium — great for large unchanged subtrees | Medium — global accumulator constraint, hash collision (negligible) |
|
||||
| 3 | `Value.Clone` for `prevProps` snapshots | Medium — enables correct `commitUpdate` contracts | Low — standard clone |
|
||||
| 4 | `Value.Mutate` for in-place props | Low-Medium — reference identity for host instances | Medium — function value handling unknown |
|
||||
| 5 | `Value.Diff` for property-level diff payloads | Low — nice-to-have for hosts that want granular updates | Medium — function values crash Diff |
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
These optimizations should be layered **incrementally** onto the Phase 1-3 reconciler. They do not change the reconciliation algorithm — they just add early exits and more efficient prop management.
|
||||
|
||||
```typescript
|
||||
// Phase 4.1: Add Value.Equal bail-out
|
||||
function reconcile(fiber: Fiber, nextNode: UElement, ctx: RootCtx): void {
|
||||
if (Value.Equal(fiber.cachedNode, nextNode)) return;
|
||||
// ... Phase 1-3 reconciliation logic
|
||||
}
|
||||
|
||||
// Phase 4.2: Add Value.Hash (after confirming accumulator constraint is manageable)
|
||||
function reconcile(fiber: Fiber, nextNode: UElement, ctx: RootCtx): void {
|
||||
const nextHash = Value.Hash(nextNode);
|
||||
if (fiber.hash !== null && fiber.hash === nextHash) return;
|
||||
fiber.hash = nextHash;
|
||||
if (Value.Equal(fiber.cachedNode, nextNode)) return;
|
||||
// ... Phase 1-3 reconciliation logic
|
||||
}
|
||||
|
||||
// Phase 4.3: Add Value.Clone for prevProps
|
||||
function reconcile(fiber: Fiber, nextNode: UElement, ctx: RootCtx): void {
|
||||
// ... bail-out checks
|
||||
fiber.prevProps = Value.Clone(fiber.props);
|
||||
// ... proceed
|
||||
}
|
||||
|
||||
// Phase 4.4: Add Value.Mutate (if function value issue is resolved)
|
||||
function commitUpdate(fiber: Fiber, ctx: RootCtx): void {
|
||||
Value.Mutate(fiber.props, nextProps); // in-place
|
||||
host.commitUpdate(fiber.instance, payload, fiber.tag, fiber.prevProps!, fiber.props, ctx);
|
||||
}
|
||||
|
||||
// Phase 4.5: Add Value.Diff for prop-level payloads (optional)
|
||||
function prepareUpdatePayload(fiber: Fiber, nextProps: Record<string, unknown>): unknown {
|
||||
try {
|
||||
return Value.Diff(fiber.props, nextProps);
|
||||
} catch (e) {
|
||||
if (e instanceof ValueDiffError) return nextProps; // full replacement fallback
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Changes to Existing Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/host/fiber.ts` | Add `hash`, `cachedNode`, `prevProps` fields to `Fiber` |
|
||||
| `src/host/reconcile.ts` | Add `Value.Equal` bail-out, `Value.Hash` check, `Value.Clone` for prevProps |
|
||||
| `src/host/config.ts` | Option to use `Value.Mutate`/`Value.Diff` in commit path |
|
||||
| `test/mod.test.ts` | Performance-oriented tests: unchanged subtrees are skipped (verify via host call counts) |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@alkdev/typebox` (already a dependency) — provides `Value.Equal`, `Value.Hash`, `Value.Clone`, `Value.Mutate`, `Value.Diff`
|
||||
- Phases 1-3 must be functionally complete before adding optimizations
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Is `Value.Hash`'s global accumulator a dealbreaker?** If `reconcile` is called from within a `computed` signal, and that `computed` reads other signals that also use `Value.Hash`, the non-reentrant constraint is violated. Need to verify: is the reconciler ever called from within a responsive computation?
|
||||
|
||||
2. **Should `Value.Diff` payloads be the default?** Not all hosts care about granular diffs. The `HostConfig` could declare `supportsGranularUpdates: true` to opt in. Otherwise, just pass the full `nextProps`.
|
||||
|
||||
3. **Size threshold for `Value.Equal`?** For very small elements (3-4 props), walking all props is faster than creating a `Value.Equal` call. Empirical testing needed.
|
||||
|
||||
## Risks
|
||||
|
||||
- **`Value.Hash` re-entrancy** — if the global accumulator is shared across nested calls, hashes will be corrupted. Mitigation: compute hashes outside of `computed`/`effect` contexts, or implement a local hash function that doesn't use a global.
|
||||
- **`Value.Diff` on functions** — will throw. Must catch or filter function props.
|
||||
- **`Value.Mutate` on functions** — needs testing. If it uses `Value.Diff` internally, it may fail on function props.
|
||||
391
docs/research/reconciler/05-flowgraph-host-configs.md
Normal file
391
docs/research/reconciler/05-flowgraph-host-configs.md
Normal file
@@ -0,0 +1,391 @@
|
||||
# Phase 5: Flowgraph HostConfig
|
||||
|
||||
## Status: Spec (Draft)
|
||||
|
||||
## Problem
|
||||
|
||||
Flowgraph is a separate project (`@alkdev/flowgraph`) that uses `@alkdev/ujsx` as a dependency. It renders workflow templates (which are `UNode` trees) to different targets using `HostConfig`. This requires two new host implementations:
|
||||
|
||||
1. **Graphology DAG host** — renders a workflow template to a graphology directed acyclic graph for structural analysis, cycle detection, topological sort
|
||||
2. **Reactive execution host** — renders a workflow template to an in-memory reactive execution engine for runtime workflow execution
|
||||
|
||||
## Conceptual Mapping
|
||||
|
||||
| Workflow concept | UJSX concept |
|
||||
|---|---|
|
||||
| Workflow template | `URoot` / `UElement` tree (`{ type, props, children }`) |
|
||||
| Operation step | `<Operation name="classify" ...>` → `UElement` |
|
||||
| Sequential flow | Parent → child relationship |
|
||||
| Parallel branches | Multiple children of a `Parallel` component |
|
||||
| Status propagation | `signal`/`computed` reactive layer |
|
||||
| Template validation | `TransformRegistry` rule pass |
|
||||
| Template → DAG | `HostConfig` rendering into graphology |
|
||||
| Template → execution engine | Different `HostConfig` for different runners |
|
||||
|
||||
## Host 1: Graphology DAG
|
||||
|
||||
### Purpose
|
||||
|
||||
Convert a declarative workflow template into a graphology DAG for:
|
||||
- Structural analysis (connected components, reachability)
|
||||
- Cycle detection (prevent invalid workflows)
|
||||
- Topological sort (execution ordering)
|
||||
- Critical path analysis
|
||||
- Visualization
|
||||
|
||||
### Type Parameters
|
||||
|
||||
```typescript
|
||||
HostConfig<WorkflowTag, GraphologyNode, GraphologyContext>
|
||||
```
|
||||
|
||||
Where:
|
||||
- `WorkflowTag` = string literal union: `"operation" | "sequential" | "parallel" | "conditional" | "workflow"`
|
||||
- `GraphologyNode` = graphology node instance (with attributes)
|
||||
- `GraphologyContext` = `{ graph: Graph; nodeIdCounter: number }`
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
import Graph from "graphology";
|
||||
import { HostConfig } from "@alkdev/ujsx/host";
|
||||
import { hasCycle } from "graphology-dag";
|
||||
|
||||
type WorkflowTag = "operation" | "sequential" | "parallel" | "conditional" | "workflow";
|
||||
|
||||
interface GraphNodeAttrs {
|
||||
label: string;
|
||||
type: WorkflowTag;
|
||||
status: "pending" | "running" | "completed" | "failed" | "aborted";
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface GraphologyContext {
|
||||
graph: Graph;
|
||||
nodeIdCounter: number;
|
||||
parentStack: string[]; // stack of parent node IDs for edge creation
|
||||
}
|
||||
|
||||
export function createGraphologyHost(): HostConfig<WorkflowTag, string, GraphologyContext> {
|
||||
return {
|
||||
name: "graphology-dag",
|
||||
|
||||
createRootContext(container, options, context) {
|
||||
return {
|
||||
graph: new Graph({ type: "directed", multi: false }),
|
||||
nodeIdCounter: 0,
|
||||
parentStack: [],
|
||||
};
|
||||
},
|
||||
|
||||
finalizeRoot(ctx) {
|
||||
if (hasCycle(ctx.graph)) {
|
||||
throw new CycleError("Workflow contains a cycle");
|
||||
}
|
||||
},
|
||||
|
||||
createInstance(tag, props, ctx, parent) {
|
||||
const id = `node_${ctx.nodeIdCounter++}`;
|
||||
ctx.graph.addNode(id, {
|
||||
label: String(props.name ?? id),
|
||||
type: tag,
|
||||
status: "pending",
|
||||
props,
|
||||
});
|
||||
return id;
|
||||
},
|
||||
|
||||
createTextInstance(text, ctx, parent) {
|
||||
// Workflows don't have text nodes; skip
|
||||
return `text_${ctx.nodeIdCounter++}`;
|
||||
},
|
||||
|
||||
appendChild(parent, child, ctx) {
|
||||
ctx.graph.addEdge(parent, child, { type: "dependency" });
|
||||
},
|
||||
|
||||
insertBefore(parent, child, before, ctx) {
|
||||
ctx.graph.addEdge(parent, child, { type: "dependency" });
|
||||
// graphology doesn't have order, but we can add an ordinal attribute
|
||||
},
|
||||
|
||||
removeChild(parent, child, ctx) {
|
||||
ctx.graph.dropEdge(parent, child);
|
||||
ctx.graph.dropNode(child);
|
||||
},
|
||||
|
||||
prepareUpdate(instance, tag, prevProps, nextProps, ctx) {
|
||||
const attrs = ctx.graph.getNodeAttributes(instance);
|
||||
const changed = {};
|
||||
for (const key of Object.keys(nextProps)) {
|
||||
if (nextProps[key] !== prevProps[key]) {
|
||||
changed[key] = nextProps[key];
|
||||
}
|
||||
}
|
||||
return Object.keys(changed).length > 0 ? changed : null;
|
||||
},
|
||||
|
||||
commitUpdate(instance, payload, tag, prevProps, nextProps, ctx) {
|
||||
ctx.graph.updateNodeAttributes(instance, (attrs) => ({
|
||||
...attrs,
|
||||
props: { ...attrs.props, ...payload },
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### What the DAG Host Enables
|
||||
|
||||
```typescript
|
||||
import { h, createRoot } from "@alkdev/ujsx";
|
||||
import { createGraphologyHost } from "@alkdev/flowgraph/host/graphology";
|
||||
|
||||
const workflow = h("workflow", { name: "document-pipeline" },
|
||||
h("sequential", { name: "ingest" },
|
||||
h("operation", { name: "fetch", type: "http-request", url: "https://..." }),
|
||||
h("operation", { name: "parse", type: "json-parse" }),
|
||||
),
|
||||
h("parallel", { name: "analyze" },
|
||||
h("operation", { name: "classify", type: "llm-classify" }),
|
||||
h("operation", { name: "extract", type: "llm-extract" }),
|
||||
),
|
||||
h("conditional", { name: "route", condition: "classify.result" },
|
||||
h("operation", { name: "escalate", type: "notify" }),
|
||||
h("operation", { name: "archive", type: "store" }),
|
||||
),
|
||||
);
|
||||
|
||||
const host = createGraphologyHost();
|
||||
const root = createRoot(host, {});
|
||||
root.render(workflow);
|
||||
|
||||
// ctx.graph is now a graphology DAG
|
||||
const { graph } = root.ctx;
|
||||
console.log(graph.order); // number of nodes
|
||||
console.log(topologicalSort(graph)); // execution order
|
||||
```
|
||||
|
||||
## Host 2: Reactive Execution Engine
|
||||
|
||||
### Purpose
|
||||
|
||||
Render a workflow template to a reactive execution engine where:
|
||||
- Each operation node has a `signal` for its status
|
||||
- Downstream operations use `computed` signals that check upstream completion
|
||||
- Status propagation is automatic: when `operation_A.status` becomes `completed`, dependent operations automatically start (if preconditions are met)
|
||||
|
||||
### Type Parameters
|
||||
|
||||
```typescript
|
||||
HostConfig<WorkflowTag, OperationInstance, ExecutionContext>
|
||||
```
|
||||
|
||||
Where:
|
||||
- `OperationInstance` = `{ id: string; status: Signal<OpStatus>; result: Signal<unknown>; definition: Record<string, unknown> }`
|
||||
- `ExecutionContext` = `{ operations: Map<string, OperationInstance>; runner: OperationRunner }`
|
||||
|
||||
### Implementation Sketch
|
||||
|
||||
```typescript
|
||||
import { signal, computed, batch } from "@preact/signals-core";
|
||||
|
||||
type OpStatus = "pending" | "running" | "completed" | "failed" | "aborted";
|
||||
|
||||
interface OperationInstance {
|
||||
id: string;
|
||||
status: Signal<OpStatus>;
|
||||
result: Signal<unknown>;
|
||||
definition: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ExecutionContext {
|
||||
operations: Map<string, OperationInstance>;
|
||||
parentStack: string[];
|
||||
runner: OperationRunner;
|
||||
}
|
||||
|
||||
export function createReactiveHost(runner: OperationRunner): HostConfig<WorkflowTag, OperationInstance, ExecutionContext> {
|
||||
return {
|
||||
name: "reactive-execution",
|
||||
|
||||
createRootContext(container, options) {
|
||||
return {
|
||||
operations: new Map(),
|
||||
parentStack: [],
|
||||
runner,
|
||||
};
|
||||
},
|
||||
|
||||
createInstance(tag, props, ctx) {
|
||||
const instance: OperationInstance = {
|
||||
id: String(props.name ?? `op_${ctx.operations.size}`),
|
||||
status: signal<OpStatus>("pending"),
|
||||
result: signal<unknown>(null),
|
||||
definition: props,
|
||||
};
|
||||
ctx.operations.set(instance.id, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
appendChild(parent, child, ctx) {
|
||||
// Establish dependency: child depends on parent
|
||||
// When parent completes, child's preconditions are re-evaluated
|
||||
const parentInstance = typeof parent === "string"
|
||||
? ctx.operations.get(parent)!
|
||||
: parent as OperationInstance;
|
||||
const childInstance = child as OperationInstance;
|
||||
|
||||
// Create a computed that watches parent status
|
||||
const preconditionsMet = computed(() => {
|
||||
// For sequential: parent must be completed
|
||||
if (parentInstance.status.value === "completed") return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// Effect: when preconditions are met, start the child operation
|
||||
effect(() => {
|
||||
if (preconditionsMet.value && childInstance.status.value === "pending") {
|
||||
batch(() => {
|
||||
childInstance.status.value = "running";
|
||||
ctx.runner.execute(childInstance).then(
|
||||
(result) => batch(() => {
|
||||
childInstance.result.value = result;
|
||||
childInstance.status.value = "completed";
|
||||
}),
|
||||
(error) => batch(() => {
|
||||
childInstance.result.value = error;
|
||||
childInstance.status.value = "failed";
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
commitUpdate(instance, payload, tag, prevProps, nextProps, ctx) {
|
||||
// Update the operation's definition
|
||||
instance.definition = { ...instance.definition, ...payload };
|
||||
},
|
||||
|
||||
// ... remaining methods
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Abort Cascade
|
||||
|
||||
The reactive host naturally supports abort cascading. When a parent operation fails, downstream operations can be automatically aborted:
|
||||
|
||||
```typescript
|
||||
// In appendChild, also watch for parent failure:
|
||||
const abortTrigger = computed(() => {
|
||||
return parentInstance.status.value === "failed" || parentInstance.status.value === "aborted";
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (abortTrigger.value && childInstance.status.value === "pending") {
|
||||
batch(() => {
|
||||
childInstance.status.value = "aborted";
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
This is the key advantage of the signal-based approach: no manual event wiring for status propagation. The reactive graph handles it automatically.
|
||||
|
||||
## Integration with Flowgraph
|
||||
|
||||
### Flowgraph Package Structure
|
||||
|
||||
```
|
||||
@alkdev/flowgraph/
|
||||
src/
|
||||
component/ # ujsx components for workflow definition
|
||||
operation.ts # <Operation name="..." /> component
|
||||
sequential.ts # <Sequential> — sequential execution
|
||||
parallel.ts # <Parallel> — concurrent execution
|
||||
conditional.ts # <Conditional> — branching
|
||||
index.ts
|
||||
host/
|
||||
graphology.ts # HostConfig: ujsx template → graphology DAG
|
||||
reactive.ts # HostConfig: ujsx template → reactive execution engine
|
||||
schema/
|
||||
enums.ts # CallStatus, NodeStatus, EdgeType
|
||||
node.ts # WorkflowNode schema (OperationNode, CallNode)
|
||||
edge.ts # WorkflowEdge schema
|
||||
graph.ts # SerializedGraph factory + flowgraph schemas
|
||||
graph/
|
||||
construction.ts # FlowGraph class (wraps graphology)
|
||||
validation.ts # Cycle detection, type-compat checking
|
||||
queries.ts # topological sort, ancestors/descendants, critical path
|
||||
mutation.ts # addNode, addEdge, updateStatus
|
||||
reactive/
|
||||
workflow.ts # ReactiveRoot for workflow state
|
||||
node.ts # Per-node signal management
|
||||
analysis/
|
||||
type-compat.ts # Output→Input schema compatibility checking
|
||||
workflow.ts # Execution ordering, precondition validation
|
||||
defaults.ts # Enum defaults
|
||||
error/
|
||||
index.ts # FlowgraphError, CycleError, TypeCompatError
|
||||
index.ts # Public API
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
```
|
||||
@alkdev/typebox # Schema definitions
|
||||
@alkdev/pubsub # Event transport (peer dep, optional)
|
||||
@alkdev/operations # OperationSpec types for type-compat edges (peer dep)
|
||||
@alkdev/ujsx # Template definition, reactive layer, transform
|
||||
@preact/signals-core # Transitive via ujsx, reactive state
|
||||
graphology # DAG structural analysis
|
||||
graphology-dag # Cycle detection, topological sort
|
||||
```
|
||||
|
||||
### Key Design Decision
|
||||
|
||||
`@alkdev/ujsx` should be a **direct dependency** of `@alkdev/flowgraph`. The workflow template IS the canonical representation, and the graphology DAG is one rendering target. This keeps things simple — one package, clear dependency chain.
|
||||
|
||||
The `from_ujsx` adapter for operations stays in the operations package as a separate export path (consistent with how it already handles `from-mcp` and `from-openapi`).
|
||||
|
||||
## Changes to UJSX
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| None specific | Flowgraph is a consumer, not a modification of ujsx. |
|
||||
|
||||
However, this phase validates the reconciler design: if the flowgraph host configs cannot be built cleanly on top of the Phase 1-3 reconciler, the reconciler design needs adjustment. Build both hosts early as integration tests.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Phases 1-3 must be complete (the reactive host needs proper signal disposal, the graphology host needs `removeChild`)
|
||||
- Phase 4 is optional but highly beneficial (graphology host benefits from `Value.Equal` bail-out on unchanged workflows)
|
||||
- `graphology` and `graphology-dag` — new external dependencies for flowgraph (not ujsx)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should flowgraph components be in `@alkdev/flowgraph` or `@alkdev/ujsx`?** The workflow components (`<Operation>`, `<Sequential>`, `<Parallel>`) could go in either package. Putting them in flowgraph keeps ujsx generic. Putting them in ujsx (under a separate export like `@alkdev/ujsx/workflow`) makes them reusable. Recommendation: keep them in flowgraph — they're domain-specific.
|
||||
|
||||
2. **How does the reactive host handle async operations?** `effect()` is synchronous. The `runner.execute()` call is async. Need to ensure the `effect` that starts execution doesn't re-run on every signal read inside the async chain.
|
||||
|
||||
3. **Should the graphology host support incremental updates?** If a workflow template changes (add/remove operations), the reconciler should update the graph in place rather than rebuilding it. This requires Phase 2 (key-based reconciliation) to be working.
|
||||
|
||||
## Test Cases
|
||||
|
||||
For graphology host:
|
||||
1. Render simple sequential workflow → correct DAG structure
|
||||
2. Render parallel branches → correct multi-child edges
|
||||
3. Cycle detection on invalid workflow
|
||||
4. Topological sort order matches declaration order
|
||||
5. Update an operation's props → graphology node attributes update
|
||||
6. Remove an operation → graphology node and edges removed
|
||||
|
||||
For reactive host:
|
||||
1. Execute sequential workflow → operations run in order
|
||||
2. Execute parallel branches → operations start concurrently
|
||||
3. Operation failure → downstream operations aborted
|
||||
4. Status propagation is automatic (no manual wiring)
|
||||
5. Disposing a workflow stops all pending operations
|
||||
6. Signal subscriptions are cleaned up on unmount
|
||||
71
docs/research/reconciler/README.md
Normal file
71
docs/research/reconciler/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# UJSX Reconciler: Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This document indexes the research and spec documents for adding a full reconciler to `@alkdev/ujsx`. The plan is ordered by dependency — each phase builds on the previous one. Phases 0 and 1 are prerequisite; Phases 2-3 can partially overlap; Phase 4 is incremental optimization; Phase 5 is a downstream consumer that validates the design.
|
||||
|
||||
## Phase Index
|
||||
|
||||
| # | Document | Description | Dependencies | Status |
|
||||
|---|----------|-------------|-------------|--------|
|
||||
| 0 | [00-KEY-FIELD-DESIGN.md](./00-KEY-FIELD-DESIGN.md) | Add `key?: string` as a first-class field on `UElement`. Extracted by `h()`, not passed to components. Prerequisite for all reconciliation. | None | Draft |
|
||||
| 1 | [01-reactive-host-bridge.md](./01-reactive-host-bridge.md) | Connect the reactive layer (ReactiveRoot, signals) to the HostConfig reconciler. Signal-driven property updates flow through `prepareUpdate`/`commitUpdate`. Introduces fiber tree. | Phase 0 | Draft |
|
||||
| 2 | [02-key-based-children-reconciliation.md](./02-key-based-children-reconciliation.md) | Key-based children matching with LIS algorithm for move detection. Handles add/remove/reorder of children. The custom reconciliation piece (~300 lines). | Phase 0, Phase 1 | Draft |
|
||||
| 3 | [03-unmount-dispose-support.md](./03-unmount-dispose-support.md) | Proper disposal: `unmount()` tears down the fiber tree, removes instances, cleans up signal subscriptions. Fixes no-op `dispose` functions in reactive layer. | Phase 1, Phase 2 | Draft |
|
||||
| 4 | [04-typebox-optimization-layer.md](./04-typebox-optimization-layer.md) | Layer TypeBox value primitives (`Value.Equal`, `Value.Hash`, `Value.Clone`, `Value.Mutate`, `Value.Diff`) as performance optimizations on the reconciler. Incremental, not required for correctness. | Phase 1-3 | Draft |
|
||||
| 5 | [05-flowgraph-host-configs.md](./05-flowgraph-host-configs.md) | Build flowgraph-specific HostConfig implementations (graphology DAG, reactive execution engine). Validates the reconciler design by building a real consumer. | Phase 1-3 (Phase 4 optional) | Draft |
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
Phase 0 (key field)
|
||||
│
|
||||
▼
|
||||
Phase 1 (reactive → host bridge)
|
||||
│
|
||||
├──────────────────┐
|
||||
▼ ▼
|
||||
Phase 2 (children Phase 3 (unmount/dispose)
|
||||
reconciliation) │
|
||||
│ │
|
||||
└────────┬─────────┘
|
||||
▼
|
||||
Phase 4 (TypeBox optimizations) ← incremental, can be layered over time
|
||||
│
|
||||
▼
|
||||
Phase 5 (flowgraph host configs) ← downstream consumer, validates design
|
||||
```
|
||||
|
||||
## Parallelism Opportunities
|
||||
|
||||
After Phase 1 is complete:
|
||||
- **Phase 2 and Phase 3 can run in parallel** — children reconciliation and disposal are largely independent concerns. Phase 3's partial-tree disposal (removing a child) does depend on Phase 2's reconciliation identifying which children to remove, so commit Phase 2 first if strict ordering is needed.
|
||||
- **Phase 4 can start as soon as Phase 1 is complete** — `Value.Equal` bail-out is useful even without children reconciliation. But maximum benefit comes after Phase 2-3.
|
||||
- **Phase 5 can start as soon as Phase 1-3 are functionally complete** — the graphology host doesn't need optimizations, but the reactive host benefits from proper disposal.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Signals handle 90% of updates** — No tree diffing for property changes. Signal subscriptions drive `prepareUpdate`/`commitUpdate` directly.
|
||||
2. **`key` is a first-class field, not a prop** — Avoids the React "key leaks into props" problem. Cleaner for schema validation (TypeBox schemas don't need to declare `key`).
|
||||
3. **Fiber tree, not virtual DOM** — The reconciler maintains a parallel tree of fiber nodes that map UElement positions to host instances. Fibers track lifecycle state (props, effects, subscriptions).
|
||||
4. **TypeBox value primitives are optimizations, not the foundation** — `Value.Diff` cannot serve as the tree-level reconciler (positional array diffing). But `Value.Equal`, `Value.Hash`, `Value.Clone`, `Value.Mutate` are excellent building blocks for the reconciler's internal operations.
|
||||
5. **Flowgraph is a consumer, not a modification** — The workflow-specific host configs live in `@alkdev/flowgraph`, not in ujsx. Ujsx stays generic.
|
||||
|
||||
## Estimated Scope
|
||||
|
||||
| Phase | Lines of Code (approx) | New Files | Modified Files |
|
||||
|-------|----------------------|-----------|----------------|
|
||||
| 0 | ~30 | 0 | schema.ts, h.ts |
|
||||
| 1 | ~200 | fiber.ts | config.ts, reactive.ts |
|
||||
| 2 | ~300 | reconcile.ts | config.ts, h.ts |
|
||||
| 3 | ~100 | 0 | config.ts, reactive.ts, fiber.ts |
|
||||
| 4 | ~150 | 0 | reconcile.ts, fiber.ts |
|
||||
| 5 | ~400 (in flowgraph repo) | graphology.ts, reactive.ts | N/A (separate package) |
|
||||
|
||||
## Reference
|
||||
|
||||
Prior analysis and research:
|
||||
- `../signals-ujsx-reactive-pipeline.md` — Signals + UJSX reactive architecture
|
||||
- `../ujsx-v2-typebox-rewrite.md` — TypeBox-driven rewrite plan, HostConfig design
|
||||
- `../prior-poc-source-reference.md` — POC codebase reference, known gaps
|
||||
- `../typebox-module-valuepointer.md` — TypeBox ValuePointer patterns
|
||||
Reference in New Issue
Block a user