228 lines
7.8 KiB
Markdown
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 |