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)
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:
- Mount — creating host instances and building the fiber tree (implemented, currently mount-only)
- Update — propagating signal changes through
prepareUpdate/commitUpdate(planned, see reconciler.md) - 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 callsfinalizeRoot()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 aneffectbut doesn't auto-dispose it on unmountsubscribe()returns a disposer butReactiveRootdoesn't track subscribers- No
destroy()ordispose()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 = nullafter disposal (no double-remove from parent) ReactiveRoot.dispose()clearingrenderDisposerandsubscriberDisposers
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:
reactiveComponentcreates acomputedfor the component output → no cleanup neededreactiveElementcreates acomputedfor 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 orReactiveRoottwice is safe. computedsignals don't need disposal — onlyeffect()subscriptions need cleanup.computedsignals are garbage collected when their references are cleared.finalizeInstanceis 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 callhost.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
- Should
disposeFiberhandle both resource disposal and instance removal? Option A:disposeFiberdoes everything (dispose signals, remove from parent). Option B:disposeFiberonly disposes resources, andcommitMutationshandles removal ordering. Option B is better for batched mutations. - Should
ReactiveRootauto-dispose onroot.unmount()? If they're connected (the root owns theReactiveRoot), thenunmount→disposeis natural. But they might not be 1:1 — aReactiveRootcould drive multiple roots. Decoupling is safer. - What about async disposal? Some host instances might have async cleanup (closing network connections, waiting for GPU commands).
finalizeInstanceis synchronous. Should there be an async variantasyncFinalizeInstance? 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
- HostConfig interface: host-config.md
- Reactive layer: reactive-layer.md
- ADR-005 (signal-driven updates): decisions/005-signal-driven-updates-over-tree-diffing.md