Files
ujsx/docs/architecture/lifecycle.md
glm-5.1 0d5b9d5ea8 stabilize architecture docs: address review findings and advance to stable
Critical fixes:
- Restructure pointers.md: move setNode prop-key writes section under
  its own heading (was incorrectly nested under selectNode)
- Add Context/Density/Direction/RenderContext documentation section
  to host-config.md (was only a brief constraint bullet)
- Advance all 5 ADRs from Status: Proposed → Accepted and frontmatter
  from status: draft → status: stable (decisions are driving implementation)
- Add error handling philosophy section to README

Warning/suggestion fixes:
- Add isUElement null check (node !== null) to schema.md discriminator table
- Add UjsxEnvelope convenience type documentation to events.md
- Add Direction Unicode arrow naming note to transforms.md
- Standardize all cross-references from absolute docs/research/ paths
  to relative ../research/ paths across all architecture docs
- Fix schema.md ADR references to use relative paths
- Reduce redundancy between transforms.md and host-config.md Direction notes
- Update all architecture doc frontmatter from draft → stable

Deferred:
- Performance model section (reconciler not yet built)
- Concepts/glossary document (low ROI at current scale)
- Line counts in source references (would date quickly)
2026-05-18 16:10:24 +00:00

8.7 KiB

status, last_updated
status last_updated
stable 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)
  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 fireeffect() 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

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 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:

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)[];  // effect cleanup functions
  prevProps: Record<string, unknown> | 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:

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).

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 removaldisposeFiber() 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 unmountdispose 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