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

9.8 KiB

status, last_updated
status last_updated
stable 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.

UjsxEnvelope

type UjsxEnvelope<TType extends keyof UjsxEventMap = keyof UjsxEventMap> = EventEnvelope<
  TType,
  UjsxEventMap[TType]
>;

A convenience type alias that parameterizes EventEnvelope with UjsxEventMap. Instead of writing EventEnvelope<"root.render", { childCount: number }>, consumers write UjsxEnvelope<"root.render">. The type parameter defaults to the full key union, so UjsxEnvelope alone means "an envelope for any UJSX event."

This type is exported from src/core/events.ts and re-exported from the barrel.

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 (../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