--- status: stable last_updated: 2026-05-18 --- # Reactive Layer Signal-backed reactive wrappers around the UJSX tree, and the gaps between them and HostConfig reconciliation. ## Overview The reactive layer wraps `UNode` trees in Preact signals so that prop and child changes propagate automatically through `computed` nodes. It does **not** implement its own reactive primitives — it re-exports `@preact/signals-core` (`signal`, `computed`, `effect`, `batch`) and builds three abstractions on top: 1. **`ReactiveNode`** — a uniform interface for any signal-backed tree node (component output or element). 2. **`ReactiveRoot`** — the top-level `Signal` that owns the element tree, supports updates, subscriptions, and event-emitting renders. 3. **Factory functions** — `reactiveComponent` and `reactiveElement` create `ReactiveNode`s whose `computed` signals re-derive the node when inputs change. The layer's purpose is to make the UJSX tree reactive without coupling to any specific host. A signal change in a `ReactiveRoot` should eventually flow through a reconciler to `HostConfig.prepareUpdate`/`commitUpdate` — but that bridge does not exist yet. ## ReactiveNode ```typescript interface ReactiveNode { readonly type: string; readonly signal: ReadonlySignal; readonly dispose: () => void; } ``` - **`type`** — identifies the node's origin. For components, this is `component.displayName ?? "anonymous"`. For elements, this is the element type string (e.g. `"div"`, `"operation"`). - **`signal`** — a `ReadonlySignal`. Consumers read `.value` to get the current node. The signal is `computed`, so it automatically re-derives when its dependencies change. - **`dispose`** — intended to clean up the underlying `computed` subscription. **Currently a no-op** — see [Known Gaps](#known-gaps). `ReactiveNode` is the return type of both `reactiveComponent` and `reactiveElement`, giving consumers a single interface regardless of whether the node came from a component or an element. ## reactiveComponent ```typescript function reactiveComponent

( component: UComponent

, propsSignal: Signal

, ): ReactiveNode ``` Creates a `computed` signal that calls `component(propsSignal.value)`. When `propsSignal` changes, the computed re-evaluates, producing a new `UNode` reflecting the updated props. The component function runs inside the computed's tracking scope, so any signals it reads (not just `propsSignal`) become dependencies. This means a component that reads external signals will automatically re-render when those signals change. The `type` field uses `component.displayName ?? "anonymous"` for debugging. Components without a `displayName` are indistinguishable in logs — this is acceptable for now but could be improved with a `type` field on `UComponent`. ## reactiveElement ```typescript function reactiveElement( type: string, propsSignal: Signal, childrenSignals: ReadonlySignal[], ): ReactiveNode ``` Creates a `computed` signal that assembles a `UElement` from its inputs. When `propsSignal` or any `childrenSignal` changes, the computed re-derives, producing a new `UElement` with updated props and children. Children are unwrapped via `childrenSignals.map(s => s.value)`. This means every child signal is read on every evaluation — a change to any child triggers a full element rebuild. For large child lists, this is correct but not optimally granular. The cost is acceptable because `UElement` is a plain data object; the actual side effects happen downstream in the reconciler. Both `reactiveComponent` and `reactiveElement` return their `dispose` as a no-op `() => {}` — the `computed` signal is never cleaned up. ## ReactiveRoot ```typescript class ReactiveRoot { constructor(initial: UNode) get value(): ReadonlySignal update(fn: (current: UNode) => UNode): void subscribe(listener: (node: UNode) => void): () => void render(emit: (event: { type: string; id: string; payload: unknown }) => void): () => void } ``` `ReactiveRoot` holds the root `Signal` — the top of the element tree. It is the primary entry point for external code that needs to read or mutate the tree. ### `value` Returns the root signal as `ReadonlySignal`. Consumers can read `.value` or pass it as a dependency to their own `computed`/`effect` nodes, but cannot write to it directly. ### `update(fn)` Updates the root signal inside `batch()`. This ensures that multiple synchronous writes to the signal (or other signals read by the tree's computeds) are collapsed into a single propagation cycle. The update function receives the current value and returns the new value — a functional update pattern that avoids stale-value issues. ### `subscribe(listener)` Creates an `effect` that calls `listener(root.value)` on every change. Returns a dispose function. **This dispose function is not tracked by `ReactiveRoot`** — the caller is responsible for calling it. If the caller discards it, the effect leaks. See [Known Gaps](#known-gaps). ### `render(emit)` Creates an `effect` that reads `root.value` and emits a `{ type: "root.render", id, payload }` event on every change. Returns a dispose function that tears down the effect and nulls the internal `renderDisposer` reference. `render()` stores its effect disposer in `this.renderDisposer`, which means calling `render()` a second time overwrites the previous disposer without disposing it. This is a known hazard — the first render's effect will leak. The `id` field uses `root_${Date.now()}`, which provides no uniqueness guarantee within a millisecond window. This is acceptable for current debugging use but not for event deduplication. ## Re-exports ```typescript export { signal, computed, effect, batch }; export type { Signal, ReadonlySignal }; ``` The reactive layer re-exports the four core primitives and two core types from `@preact/signals-core`. This makes `@preact/signals-core` an implementation detail — consumers import from `ujsx/reactive` rather than reaching into `@preact/signals-core` directly. If the reactive primitive library ever changes, only `reactive.ts` needs updating. The decision to use `@preact/signals-core` (rather than building custom primitives) is documented in [ADR-003](decisions/003-preact-signals-for-reactivity.md). The rationale: signal libraries are subtle to implement correctly (glitch-free propagation, cycle detection, batching). Preact's implementation is small, well-tested, and framework-agnostic. ## Known Gaps ### All `dispose` functions are no-ops `reactiveComponent` and `reactiveElement` return `dispose: () => {}`. The underlying `computed` signal is never cleaned up. This means that if a `ReactiveNode` is discarded, its computed continues to track dependencies and re-evaluate on changes. In a long-lived application with dynamic tree structures, this leaks memory and CPU. ### `subscribe()` return value is thrown away by callers `ReactiveRoot.subscribe()` returns an `effect` dispose function, but `ReactiveRoot` does not track it. Callers who discard the return value create a permanently leaking effect. There is no `unsubscribeAll()` or cleanup mechanism. ### `render()` overwrites previous disposer Calling `render()` a second time replaces `this.renderDisposer` without disposing the first effect. The first render effect leaks permanently. ### No connection to HostConfig reconciler `ReactiveRoot.render()` emits events, but nothing consumes those events to call `HostConfig.prepareUpdate`/`commitUpdate`. The signal layer and the host layer are two separate islands. The reconciler architecture ([reconciler.md](reconciler.md)) proposes a fiber-based bridge: `ReactiveRoot` signal changes trigger a reconciliation pass that diffs props and calls `HostConfig` update methods. ### No auto-dispose on unmount `ReactiveRoot` has no `unmount()` or `destroy()` method. Effects created by `subscribe()` and `render()` are never automatically torn down. The lifecycle management architecture ([lifecycle.md](lifecycle.md)) addresses this, with `ReactiveRoot.dispose()` tracking and cleaning up all subscriber and render effect disposers. ## Constraints - **`@preact/signals-core` is the only reactive primitive library.** The layer does not abstract over signal implementations. Switching libraries requires changing `reactive.ts` and all code that imports the re-exports. - **`ReactiveNode.dispose` is a contract with no implementation.** Code that calls `dispose()` today does nothing. When real disposal is implemented, callers must be audited to ensure they call it at the right lifecycle point. - **`ReactiveRoot.update` overwrites the root signal entirely.** There is no granular update mechanism — the update function receives the entire current tree and returns a new one. For large trees, this is the correct granularity because signals handle the fine-grained propagation internally. - **`reactiveElement` reads every child signal on every evaluation.** A change to any child triggers a full element rebuild. This is correct but not optimal for large child lists with many independent signals. - **`ReactiveRoot.render()` event `id` is not guaranteed unique.** `Date.now()` collisions are possible within a single millisecond. Do not rely on `id` for deduplication. ## Open Questions 1. **Should `ReactiveNode.dispose` be implemented using `effect` cleanup or stored-disposer patterns?** The current `computed` signals have no public dispose API in `@preact/signals-core`. Disposal requires either switching to an effect-based approach (where each `computed` is tracked by an `effect` that can be disposed) or maintaining an explicit disposer list. 2. **Should `ReactiveRoot` track all subscription disposers?** Adding an internal `Set<() => void>` for active subscribers would allow `ReactiveRoot` to clean up on unmount. This creates a lifecycle coupling — `ReactiveRoot` would need a `destroy()` method. 3. **How should `ReactiveRoot` connect to the reconciler?** Options: (a) `ReactiveRoot` emits events that a reconciler subscribes to, (b) `createReactiveRoot(host, container)` bridges both layers, (c) consumer code wires them manually. See [reconciler.md](reconciler.md) for the fiber-based bridge approach. 4. **Should `render()` support multiple concurrent subscribers?** The current overwriting design suggests single-subscriber usage. If multiple hosts need to render the same reactive tree, they should each call `subscribe()` directly rather than `render()`. ## References - Source: `src/core/reactive.ts` - ADR-003: `docs/architecture/decisions/003-preact-signals-for-reactivity.md` - Reconciler architecture: [reconciler.md](reconciler.md) - Lifecycle management: [lifecycle.md](lifecycle.md) - Reactive → Host bridge research: `../research/reconciler/01-reactive-host-bridge.md` - Unmount & dispose research: `../research/reconciler/03-unmount-dispose-support.md` - Preact signals-core: `@preact/signals-core`