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)
183 lines
8.7 KiB
Markdown
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) |