# 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 { instance: I; tag: string; props: Record; key: string | undefined; children: Fiber[]; parent: Fiber | 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(fiber: Fiber, 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; 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 { // ... 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