add reconciler implementation plan: 6-phase spec with dependency graph and parallelism analysis
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user