7.8 KiB
Phase 3: Unmount & Dispose Support
Status: Spec (Draft)
Problem
The current reconciler has two disposal gaps:
-
unmount()is a stub — it callsfinalizeRoot()and emits an event, but does not tear down the instance tree, remove children, or clean up signal subscriptions. -
reactiveComponentandreactiveElementhave no-opdisposefunctions — signal subscriptions created byeffect()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
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
// 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:
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:
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:
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:
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:
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:
// 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:
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
-
Should
disposeFiberremove the instance from the parent, or should that be a separate step? Option A:disposeFiberhandles everything (dispose children, dispose signals, remove from parent). Option B: Split intodisposeResources(signals, cleanup) andremoveFromParent(host call). Option B is better for batched mutations where removes happen in a specific order. -
Should
ReactiveRootauto-dispose onunmount? IfReactiveRootis connected to aRoot, callingroot.unmount()should also dispose theReactiveRoot. But they might not be 1:1. -
What about
computedsignals created byreactiveComponent?computed()signals don't need explicit disposal — they're garbage collected when nothing references them. Onlyeffect()subscriptions need disposal. Confirm this understanding against@preact/signals-coreinternals.
Test Cases
- Full
unmount()removes all instances viaremoveChild - Full
unmount()disposes all signal subscriptions - Partial removal (one child removed) disposes that child's fiber and signals
ReactiveRoot.dispose()stopssubscribeandrendereffectsfinalizeInstancecalled on each removed instance- Dispose is idempotent — calling twice doesn't error
- Nested components: disposing a parent disposes all children
- Conditionally rendered component: toggling a branch on/off properly mounts/unmounts