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)
157 lines
11 KiB
Markdown
157 lines
11 KiB
Markdown
---
|
|
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<UNode>` 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<UNode>;
|
|
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<UNode>`. 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<P extends UniversalProps>(
|
|
component: UComponent<P>,
|
|
propsSignal: Signal<P>,
|
|
): 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<UniversalProps>,
|
|
childrenSignals: ReadonlySignal<UNode>[],
|
|
): 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<UNode>
|
|
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<UNode>` — 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<UNode>`. 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` |