Files
ujsx/docs/architecture/reactive-layer.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

11 KiB

status, last_updated
status last_updated
stable 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 functionsreactiveComponent and reactiveElement create ReactiveNodes 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

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.

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

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

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

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.

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

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. 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) 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) 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 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
  • Lifecycle management: 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