Files
ujsx/docs/research/reconciler/03-unmount-dispose-support.md

228 lines
7.8 KiB
Markdown

# 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