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

183 lines
8.7 KiB
Markdown

---
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<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:
```typescript
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 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)