add reconciler architecture docs and update existing docs with cross-references
Phase 2: transitioning reconciler research into architecture documents. New docs: - reconciler.md: fiber tree, reconciliation algorithm (signal-driven props + key-based children), update scheduling, commit order, TypeBox optimization layer, file structure, consumer impact - lifecycle.md: mount/update/dispose phases, fiber tree disposal, partial tree removal, ReactiveRoot.dispose(), finalizeInstance, idempotent disposal, computed vs effect cleanup - ADR-004: key as first-class field on UElement (not a prop) - ADR-005: signal-driven updates for props, reconciliation for structure (hybrid approach, not full tree diffing) Updated docs: - README.md: add reconciler.md, lifecycle.md, ADRs 004/005 to index; update reconciler roadmap with architecture doc links - schema.md: add key?: string to UElement type with TODO comment; update known gaps to reference ADR-004 and reconciler.md; rephrase key constraint as temporary - element-factory.md: update key extraction gap to reference ADR-004 and reconciler.md - host-config.md: reference reconciler.md and lifecycle.md for the reconciler bridge and disposal gaps - reactive-layer.md: reference reconciler.md and lifecycle.md for the signal-host bridge and disposal gaps - events.md: reference lifecycle.md for unmount/dispose gap
This commit is contained in:
183
docs/architecture/lifecycle.md
Normal file
183
docs/architecture/lifecycle.md
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
status: draft
|
||||
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: `docs/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)
|
||||
Reference in New Issue
Block a user