--- status: stable last_updated: 2026-05-18 --- # Lifecycle Management How UJSX handles mounting, updating, and disposing element trees and their associated resources. ## Overview Lifecycle management in UJSX covers three phases: 1. **Mount** — creating host instances and building the fiber tree (implemented, currently mount-only) 2. **Update** — propagating signal changes through `prepareUpdate`/`commitUpdate` (planned, see [reconciler.md](reconciler.md)) 3. **Unmount/Dispose** — tearing down host instances, cleaning up signal subscriptions, removing fibers (planned, this document) The current implementation handles mount correctly but treats update as no-op and unmount as a stub. This document describes the target state for the dispose phase, with references to the reconciler research. ## The Disposal Problem Without proper disposal: - **Re-rendering a root leaks the old fiber tree** — instances, signal subscriptions, and fiber nodes are never cleaned up - **Signal subscriptions from unmounted components continue to fire** — `effect()` return values are discarded, leaving zombie computations - **Conditional rendering is impossible** — adding/removing a subtree (e.g., a workflow branch) accumulates orphaned subscriptions - **`unmount()` is a no-op for resources** — it calls `finalizeRoot()` and emits an event, but does not tear down instances or subscriptions The disposal system closes these gaps by providing full teardown for: - **Root unmount** — dispose the entire fiber tree and all signal subscriptions - **Partial tree removal** — dispose a child fiber and its subtree when reconciliation removes it - **Individual signal cleanup** — each `effect()` created during mounting tracks its disposer for cleanup ## Current State (Gaps) ### `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 and reactiveElement dispose: () => {} // no-op ``` ### `ReactiveRoot` in `src/core/reactive.ts` - `render()` creates an `effect` but doesn't auto-dispose it on unmount - `subscribe()` returns a disposer but `ReactiveRoot` doesn't track subscribers - No `destroy()` or `dispose()` method ## Target 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)[]; // effect cleanup functions prevProps: Record | null; } ``` The `signalDisposers` array stores every `effect()` return value created during this fiber's mounting. When the fiber is disposed, all disposers are called. ### Root Unmount Flow ``` root.unmount() → disposeFiber(rootFiber, ctx) → for each child (bottom-up, children before parent): disposeFiber(child, ctx) host.removeChild(parent.instance, child.instance, ctx) → host.finalizeInstance?.(child.instance, ctx) // per-instance cleanup → call each disposer in fiber.signalDisposers → clear fiber state (children = [], parent = null, effect = null) → host.finalizeRoot(ctx) ``` Bottom-up disposal ensures children are removed before their parents, which is the correct order for most host environments (DOM, graphology, Three.js scene graphs). ### Partial Tree Disposal During reconciliation, when children are removed: ``` reconcileChildren identifies removed fibers → for each removed fiber: disposeFiber(removedFiber, ctx) // removeChild is called during commitMutations, not here ``` This supports conditional rendering: when a component's output changes from `{ type: "panel", children: [...] }` to `{ type: "panel", children: [] }`, the removed children's fiber trees are fully disposed. ### Host Notification `HostConfig` gains an optional `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). ### ReactiveRoot Disposal `ReactiveRoot` gains a `dispose()` method that: - Calls any render effect disposer and clears it - Iterates all tracked subscriber disposers and calls each one - Clears internal tracking state The `subscribe()` method is updated to track disposer returns: each `effect()` created by `subscribe()` has its disposer stored in an internal list, and the returned unsubscribe function both calls the disposer and removes it from tracking. Both `dispose()` and the returned unsubscribe functions are **idempotent** — calling them multiple times is safe. ### Disposal Idempotency All disposal operations must be idempotent. Calling `dispose()` twice must not error. This is achieved by: - Setting `fiber.signalDisposers = []` after disposal (no double-calling) - Setting `fiber.parent = null` after disposal (no double-remove from parent) - `ReactiveRoot.dispose()` clearing `renderDisposer` and `subscriberDisposers` ### `computed` vs `effect` Cleanup `computed()` signals in Preact's implementation do not need explicit disposal — they're garbage collected when nothing references them. Only `effect()` return values need to be called for cleanup. This distinction is important: - `reactiveComponent` creates a `computed` for the component output → no cleanup needed - `reactiveElement` creates a `computed` for the element → no cleanup needed - Signal subscriptions created during mounting use `effect()` → must be tracked and disposed ## 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` track and call real disposers. `subscribe()` tracks disposer returns. | | `src/host/fiber.ts` | (New) `Fiber` type with `signalDisposers`, `disposeFiber()` function | | `src/host/reconcile.ts` | (New) Partial tree disposal during `reconcileChildren` | ## Constraints - **Bottom-up disposal** — children are removed before their parents. This is the correct order for hierarchical instance systems (DOM, scene graphs, DAGs). - **No double-dispose** — all disposal operations are idempotent. Calling `dispose()` on a fiber or `ReactiveRoot` twice is safe. - **`computed` signals don't need disposal** — only `effect()` subscriptions need cleanup. `computed` signals are garbage collected when their references are cleared. - **`finalizeInstance` is optional** — hosts that don't need per-instance cleanup can leave it undefined. This is backward compatible with existing HostConfig implementations. - **Hosts own instance removal** — `disposeFiber()` disposes resources (signal subscriptions, fiber state) but does NOT call `host.removeChild()`. Instance removal happens during the commit phase, in a specific order, as part of the reconciliation algorithm. This separation ensures correct mutation ordering. ## Open Questions 1. **Should `disposeFiber` handle both resource disposal and instance removal?** Option A: `disposeFiber` does everything (dispose signals, remove from parent). Option B: `disposeFiber` only disposes resources, and `commitMutations` handles removal ordering. Option B is better for batched mutations. 2. **Should `ReactiveRoot` auto-dispose on `root.unmount()`?** If they're connected (the root owns the `ReactiveRoot`), then `unmount` → `dispose` is natural. But they might not be 1:1 — a `ReactiveRoot` could drive multiple roots. Decoupling is safer. 3. **What about async disposal?** Some host instances might have async cleanup (closing network connections, waiting for GPU commands). `finalizeInstance` is synchronous. Should there be an async variant `asyncFinalizeInstance`? Not in the initial implementation — hosts with async cleanup should handle it internally and resolve when ready. ## References - Unmount & dispose research: `../research/reconciler/03-unmount-dispose-support.md` - Reconciler architecture: [reconciler.md](reconciler.md) - HostConfig interface: [host-config.md](host-config.md) - Reactive layer: [reactive-layer.md](reactive-layer.md) - ADR-005 (signal-driven updates): [decisions/005-signal-driven-updates-over-tree-diffing.md](decisions/005-signal-driven-updates-over-tree-diffing.md)