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

7.8 KiB

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

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

  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