Files
ujsx/docs/architecture/events.md
glm-5.1 da82b52b27 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
2026-05-18 15:15:13 +00:00

9.3 KiB

status, last_updated
status last_updated
draft 2026-05-18

Events

EventEnvelope, PubSubLike, and the UjsxEventMap that define the observability layer.

Overview

UJSX uses a typed event system for observability. HostConfigs emit events at key lifecycle points — root render, instance creation, component invocation — and consumers subscribe to those events for logging, debugging, or forwarding to external systems. The system is fire-and-forget: events are published with no expectation of acknowledgment, no retry, and no built-in routing beyond type-based subscription.

The event layer is intentionally decoupled from the reactive layer. Signals handle state propagation (data flow). Events handle observability (what happened). Mixing them would conflate "the tree changed" with "the host was notified," which are different concerns with different timing guarantees.

EventEnvelope

interface EventEnvelope<TType extends string, TPayload> {
  readonly type: TType;
  readonly id: string;
  readonly payload: TPayload;
}

Every event is wrapped in an envelope with three fields:

  • type — a string discriminator that identifies the event kind. Typed via TType so consumers can narrow payloads by type.
  • id — a unique identifier for correlation and deduplication. Events with the same id represent the same occurrence.
  • payload — the typed data carried by the event. Shape is determined by UjsxEventMap[TType].

The envelope is readonly — consumers cannot mutate a received event. This prevents accidental mutation of shared event objects and makes it safe to forward or store events without copying.

Why both type and id?

type answers "what kind of event is this?" and enables type-based subscription. id answers "which specific occurrence?" and enables correlation across systems. A root render event and an instance create event for the same render cycle may share no data except timing — id gives consumers a join key. The id generation strategy (currently Date.now() in proxyEventEmitter) is not specified by the envelope interface; consumers should not assume monotonicity or uniqueness beyond a reasonable best-effort guarantee.

PubSubLike

interface PubSubLike<TEventMap extends Record<string, unknown>> {
  publish<TType>(type: TType, id: string, payload: TEventMap[TType]): void;
  subscribe<TType>(type: TType, id: string): AsyncIterable<EventEnvelope<TType, TEventMap[TType]>>;
}

PubSubLike is a generic typed publish/subscribe interface. It is not a concrete implementation — it is the contract that UJSX's event system expects from a pubsub provider.

Design decisions

  • subscribe returns AsyncIterable — not a callback, not an EventEmitter, not a Promise. AsyncIterable is the most composable streaming primitive in modern JavaScript: it works with for await ... of, can be wrapped in backpressure-aware pipelines, and composes naturally with AbortSignal-based cancellation. This matches the consuming patterns of workflow engines and async orchestrators.

  • Both type and id on subscribetype filters by event kind. id filters by occurrence. A subscriber can listen to all root.render events (type-only) or to a specific render cycle (type + id). The concrete pubsub implementation decides whether id is a filter or a partition key.

  • publish returns void — fire-and-forget. No acknowledgment, no response, no promise. The publisher does not wait for subscribers. This is consistent with the observability role: events are side effects of tree operations, not request/response messages.

@alkdev/pubsub

The PubSubLike interface is defined in @alkdev/pubsub. UJSX re-exports it as the type constraint for its event system. The actual pubsub implementation is injected by the consumer — UJSX does not bundle a pubsub runtime.

UjsxEventMap

type UjsxEventMap = {
  "root.render": { childCount: number };
  "root.unmount": Record<string, unknown>;
  "instance.create": { kind: "text" | "element"; tag?: string; value?: string; props?: Record<string, unknown> };
  "component.invoke": { type: string };
  "type.call": { objectName: string; methodName: string; args: unknown[] };
  "transform.apply": { ruleName: string; direction: string };
};

The event map defines the known event types and their payload shapes. Each key is an event type string; each value is the expected payload structure.

Event When emitted Payload
root.render HostConfig calls render() on a root childCount of the rendered tree
root.unmount HostConfig calls unmount() on a root Empty record (no data)
instance.create HostConfig creates a text or element instance kind discriminates text vs. element; tag, value, props are element-specific
component.invoke HostConfig calls a component function type is the component's type string or display name
type.call Proxy event for remote type system dispatch objectName, methodName, args describe the invoked method
transform.apply TransformRegistry applies a matching rule ruleName and direction identify which rule matched

Event granularity

These events are coarse-grained. They answer "what happened at this boundary?" not "what was the state of every node?". Fine-grained node-level changes are handled by the reactive layer (signals), not by events.

createPubSubEmitter

function createPubSubEmitter<TEventMap>(pubsub: PubSubLike<TEventMap>): (type: string, id: string, payload: unknown) => void

Wraps a PubSubLike into a simple callback function. This is the bridge between the HostConfig's emit() function and the typed pubsub system. HostConfig needs a (type, id, payload) => void emitter — createPubSubEmitter provides exactly that signature while preserving type safety at the call site.

The returned function does not validate that type is a known event key. If an unknown type is passed, the underlying pubsub implementation decides what happens (likely a no-op or a log warning).

proxyEventEmitter

function proxyEventEmitter(pubsub: PubSubLike<UjsxEventMap>): { onTypeCall(objectName, methodName, args): void }

Convenience wrapper that maps the type.call event type to a method-call interface. Instead of:

pubsub.publish("type.call", id, { objectName, methodName, args });

Consumers write:

proxy.onTypeCall("MyObject", "myMethod", [arg1, arg2]);

The id is auto-generated from Date.now(). This is adequate for debugging and local event forwarding, but not for distributed deduplication. The id is not guaranteed to be unique across concurrent calls within the same millisecond.

Known Gaps

No event routing or middleware

Events are published directly to the pubsub. There is no middleware chain for filtering, transforming, or enriching events before they reach subscribers. Consumers that need middleware should wrap their pubsub implementation.

No event history or replay

The event system is live-only. Subscribers receive events from the moment they subscribe; there is no replay of past events. Consumers that need event sourcing should persist events in their own store.

No error events

The event map does not include error events (e.g., instance.error, render.error). HostConfig errors currently propagate as thrown exceptions, not as events. Adding error events would require deciding between fire-and-forget error logging and error-handling orchestration.

type.call is a narrow proxy

proxyEventEmitter only handles type.call. There is no proxy for other event types. As the event map grows, this convenience may need to expand or be replaced by a more general proxy pattern.

No cleanup on unmount

The current unmount() implementation in HostConfig does not tear down event subscriptions. If a pubsub emitter was wired to the host's emit() during render, unmount does not unsubscribe. This gap is shared with the broader reconciler disposal gap — see lifecycle.md and the unmount & dispose research (docs/research/reconciler/03-unmount-dispose-support.md).

Constraints

  • Fire-and-forget — events are published without acknowledgment. The publisher does not know if any subscriber received or processed the event.
  • PubSubLike is injected — UJSX does not provide a pubsub implementation. The consumer supplies one. This keeps UJSX platform-agnostic and avoids coupling to a specific pubsub runtime.
  • UjsxEventMap is a closed type — the event map is a fixed union. Adding new event types requires modifying the UjsxEventMap type definition. This is intentional: event types define the observability contract and should be explicitly enumerated.
  • AsyncIterable subscriptionsubscribe returns AsyncIterable, not a callback. Consumers must use for await ... of or convert to a callback-based API. This is a deliberate choice for composability.
  • id generation is caller-specified — the envelope requires an id, but does not prescribe how it is generated. proxyEventEmitter uses Date.now(), but this is a convenience, not a contract. Other callers may use UUIDs, counters, or any unique string.

References

  • Source: src/core/events.ts
  • PubSubLike interface: @alkdev/pubsub
  • HostConfig emit: src/host/config.ts
  • Direction type: src/core/context.ts