[GH-ISSUE #650] DOM-like capture/bubble event dispatch #175

Open
opened 2026-03-02 23:45:05 +03:00 by kerem · 0 comments
Owner

Originally created by @cevr on GitHub (Feb 8, 2026).
Original GitHub issue: https://github.com/anomalyco/opentui/issues/650

Background

#638 introduced <focus-scope> for keyboard isolation and scoped hooks. The implementation on feat/focus-scope (PR #646) works, but revealed that the underlying event architecture is fighting us:

  • Keyboard dispatch has a 3-tier model: global EventEmitter → scope-aware renderable dispatch → internal renderableHandlers Set. Adding scopes required dual-path dispatch (emitScopeFirst vs emitGlobalFirst) based on findClosestKeyboardScope().
  • Mouse dispatch uses simple parent-chain bubbling (processMouseEvent) — closest to DOM, but no capture phase.
  • Paste piggybacks on the keyboard path.

Every new feature (trapFocus isolation, global shortcuts like Ctrl+C surviving scopes, framework hooks discovering their scope) requires another special case in InternalKeyHandler. The complexity compounds: parallel listener stores (onInternal/offInternal), focusedRenderableProvider inverting renderer state into the handler, eventPreventedOrStopped / eventPropagationStopped flags, and framework hooks needing findClosestKeyboardScope.

Starting from the focus-scope work, we stepped back and asked: what's the simplest model that handles all of this without special cases?

Design: DOM-like capture/bubble

The answer is the same one the DOM uses. All propagating events follow a two-phase dispatch through the renderable tree:

  1. Capture (root → target): Listeners registered with { capture: true } fire top-down
  2. Bubble (target → root): Listeners registered without capture fire bottom-up

Event target resolution

Event Type Target
keypress, keyrelease, paste Currently focused renderable (or root if null)
mousedown, mouseup, etc. Hit-test result (existing)
resize Root only, no propagation

Propagation

path = [root, ..., grandparent, parent, target]
  • Capture: Walk path forward, invoke capture listeners
  • Target: Invoke both capture and bubble listeners on target node
  • Bubble: Walk path backward, invoke bubble listeners
  • stopPropagation() halts traversal after current node's listeners finish
  • stopImmediatePropagation() halts remaining listeners on current node too
  • preventDefault() suppresses default behavior (orthogonal to propagation)

Listener API on Renderable

addEventListener(type: string, handler: (event: TUIEvent) => void, options?: { capture?: boolean; once?: boolean }): void
removeEventListener(type: string, handler: (event: TUIEvent) => void, options?: { capture?: boolean }): void

Event base type

class TUIEvent {
  readonly type: string
  target: Renderable | null
  currentTarget: Renderable | null
  eventPhase: EventPhase  // NONE | CAPTURE | AT_TARGET | BUBBLE

  stopPropagation(): void
  stopImmediatePropagation(): void
  preventDefault(): void
}

KeyEvent, PasteEvent extend TUIEvent.

How this simplifies everything

trapFocus → bubble-phase stopPropagation

this.addEventListener("keypress", (e) => {
  if (e instanceof KeyEvent && e.ctrl && e.name === "c") return  // let Ctrl+C through
  e.stopPropagation()
})

Events bubble from focused renderable, hit the scope boundary, stop. No dispatch path switching. No findClosestKeyboardScope.

Global handlers → run before tree dispatch

renderer.keyInput.on("keypress", handler) registers on the EventEmitter. InternalKeyHandler.emit() iterates these inline before dispatching through the tree — conceptually "above" root in capture order.

This gives global handlers a deterministic, stable position: always first. If they call stopPropagation(), the tree never sees the event. If they don't, normal capture/bubble proceeds.

Framework hooks → addEventListener on renderable

// useKeyboard(handler, { ref })
// → ref.current.addEventListener("keypress", handler)
// Falls back to root if no ref

No scope discovery needed. Tree topology handles isolation naturally.

InternalKeyHandler → thin emit bridge

override emit(event, ...args) {
  // 1. Resolve target
  const target = focusedRenderable ?? root
  tuiEvent.target = target

  // 2. Run global listeners inline (before tree)
  for (const listener of this.listeners(event))
    listener(tuiEvent)

  // 3. Dispatch through tree if not stopped
  if (!tuiEvent.propagationStopped)
    dispatchEvent(tuiEvent)
}

No temporary addEventListener/removeEventListener dance per keystroke. No wrapper allocations.

What gets deleted

  • InternalKeyHandler.renderableHandlers / onInternal / offInternal
  • emitScopeFirst / emitGlobalFirst / emitGlobalListeners / emitInternalListeners
  • eventPreventedOrStopped / eventPropagationStopped
  • Renderable.processKeyEvent / processKeyReleaseEvent / processPasteEvent
  • _internalKeyInput on RenderContext
  • findClosestKeyboardScope (for dispatch decisions)

Edge cases

  • Ctrl+C: Global listener runs first, always fires before any scope trapper
  • Destroyed during dispatch: target.isDestroyed checked after each listener invocation
  • No focused renderable: Target = root, capture/bubble on root only
  • Auto-focus on click: Post-dispatch, checks defaultPrevented
  • Listener snapshot: Array copied before iterating (prevents mutation during dispatch)

Reference implementation

Branch cvr/event-architecture has a working implementation for keyboard/paste dispatch. 47 tests across 3 test files covering capture/bubble ordering, stopPropagation, stopImmediatePropagation, preventDefault independence, scope isolation, paste dispatch, once listeners, error handling, and nested component scenarios. All existing tests pass.

New files

  • packages/core/src/lib/event.tsTUIEvent, EventPhase
  • packages/core/src/lib/event-dispatch.tsdispatchEvent, buildPropagationPath

Modified

  • Renderable.tsaddEventListener/removeEventListener, handler types, defaultPrevented checks
  • KeyHandler.tsKeyEvent/PasteEvent extend TUIEvent, simplified InternalKeyHandler.emit()
  • renderer.ts — wire root and focus provider
  • types.ts — remove _internalKeyInput

Mouse dispatch through dispatchEvent is a natural next step but not yet implemented.


Thoughts? Open to feedback on the API surface, naming, or anything that smells off.

Mouse event migration

Branch cvr/event-architecture-mouse extends the capture/bubble model to mouse events.

Changes

  • MouseEvent extends TUIEvent — new lib/mouse-event.ts, re-exported from renderer.ts for backward compat. Inherits stopImmediatePropagation, eventPhase, currentTarget from base.
  • processMouseEventdispatchEvent — replaces manual parent-chain walk with full capture/bubble dispatch.
  • Property handlers wire through addEventListeneronMouseDown, onMouseScroll, etc. now register/remove via the same listener system as keyboard events.
  • onMouseEvent virtual method removed — subclasses (ScrollBox, EditBufferRenderable, TextBufferRenderable, examples) converted to addEventListener calls in constructors.

What stays unchanged

  • Focus/blur, resize, layout, selection — non-propagating lifecycle signals, still EventEmitter
  • Mouse event type names ("down", "up", "scroll", etc.)
  • Constructor signatures for MouseEvent
  • All existing property handler APIs (onMouseDown, onMouse, etc.)

Tests

39 tests pass (32 existing + 7 new covering TUIEvent inheritance, capture-before-bubble ordering, stopPropagation in capture, stopImmediatePropagation, scroll through capture/bubble, and property handler + addEventListener coexistence). All 3141 core, 26 React, and 176 Solid tests pass.

Originally created by @cevr on GitHub (Feb 8, 2026). Original GitHub issue: https://github.com/anomalyco/opentui/issues/650 ## Background #638 introduced `<focus-scope>` for keyboard isolation and scoped hooks. The implementation on `feat/focus-scope` (PR #646) works, but revealed that the underlying event architecture is fighting us: - **Keyboard** dispatch has a 3-tier model: global EventEmitter → scope-aware renderable dispatch → internal `renderableHandlers` Set. Adding scopes required dual-path dispatch (`emitScopeFirst` vs `emitGlobalFirst`) based on `findClosestKeyboardScope()`. - **Mouse** dispatch uses simple parent-chain bubbling (`processMouseEvent`) — closest to DOM, but no capture phase. - **Paste** piggybacks on the keyboard path. Every new feature (trapFocus isolation, global shortcuts like Ctrl+C surviving scopes, framework hooks discovering their scope) requires another special case in `InternalKeyHandler`. The complexity compounds: parallel listener stores (`onInternal`/`offInternal`), `focusedRenderableProvider` inverting renderer state into the handler, `eventPreventedOrStopped` / `eventPropagationStopped` flags, and framework hooks needing `findClosestKeyboardScope`. Starting from the focus-scope work, we stepped back and asked: what's the simplest model that handles all of this without special cases? ## Design: DOM-like capture/bubble The answer is the same one the DOM uses. All propagating events follow a two-phase dispatch through the renderable tree: 1. **Capture** (root → target): Listeners registered with `{ capture: true }` fire top-down 2. **Bubble** (target → root): Listeners registered without capture fire bottom-up ### Event target resolution | Event Type | Target | |---|---| | `keypress`, `keyrelease`, `paste` | Currently focused renderable (or root if null) | | `mousedown`, `mouseup`, etc. | Hit-test result (existing) | | `resize` | Root only, no propagation | ### Propagation ``` path = [root, ..., grandparent, parent, target] ``` - **Capture**: Walk path forward, invoke capture listeners - **Target**: Invoke both capture and bubble listeners on target node - **Bubble**: Walk path backward, invoke bubble listeners - `stopPropagation()` halts traversal after current node's listeners finish - `stopImmediatePropagation()` halts remaining listeners on current node too - `preventDefault()` suppresses default behavior (orthogonal to propagation) ### Listener API on Renderable ```typescript addEventListener(type: string, handler: (event: TUIEvent) => void, options?: { capture?: boolean; once?: boolean }): void removeEventListener(type: string, handler: (event: TUIEvent) => void, options?: { capture?: boolean }): void ``` ### Event base type ```typescript class TUIEvent { readonly type: string target: Renderable | null currentTarget: Renderable | null eventPhase: EventPhase // NONE | CAPTURE | AT_TARGET | BUBBLE stopPropagation(): void stopImmediatePropagation(): void preventDefault(): void } ``` `KeyEvent`, `PasteEvent` extend `TUIEvent`. ## How this simplifies everything ### trapFocus → bubble-phase stopPropagation ```typescript this.addEventListener("keypress", (e) => { if (e instanceof KeyEvent && e.ctrl && e.name === "c") return // let Ctrl+C through e.stopPropagation() }) ``` Events bubble from focused renderable, hit the scope boundary, stop. No dispatch path switching. No `findClosestKeyboardScope`. ### Global handlers → run before tree dispatch `renderer.keyInput.on("keypress", handler)` registers on the EventEmitter. `InternalKeyHandler.emit()` iterates these inline *before* dispatching through the tree — conceptually "above" root in capture order. This gives global handlers a deterministic, stable position: always first. If they call `stopPropagation()`, the tree never sees the event. If they don't, normal capture/bubble proceeds. ### Framework hooks → addEventListener on renderable ```typescript // useKeyboard(handler, { ref }) // → ref.current.addEventListener("keypress", handler) // Falls back to root if no ref ``` No scope discovery needed. Tree topology handles isolation naturally. ### InternalKeyHandler → thin emit bridge ```typescript override emit(event, ...args) { // 1. Resolve target const target = focusedRenderable ?? root tuiEvent.target = target // 2. Run global listeners inline (before tree) for (const listener of this.listeners(event)) listener(tuiEvent) // 3. Dispatch through tree if not stopped if (!tuiEvent.propagationStopped) dispatchEvent(tuiEvent) } ``` No temporary addEventListener/removeEventListener dance per keystroke. No wrapper allocations. ## What gets deleted - `InternalKeyHandler.renderableHandlers` / `onInternal` / `offInternal` - `emitScopeFirst` / `emitGlobalFirst` / `emitGlobalListeners` / `emitInternalListeners` - `eventPreventedOrStopped` / `eventPropagationStopped` - `Renderable.processKeyEvent` / `processKeyReleaseEvent` / `processPasteEvent` - `_internalKeyInput` on `RenderContext` - `findClosestKeyboardScope` (for dispatch decisions) ## Edge cases - **Ctrl+C**: Global listener runs first, always fires before any scope trapper - **Destroyed during dispatch**: `target.isDestroyed` checked after each listener invocation - **No focused renderable**: Target = root, capture/bubble on root only - **Auto-focus on click**: Post-dispatch, checks `defaultPrevented` - **Listener snapshot**: Array copied before iterating (prevents mutation during dispatch) ## Reference implementation Branch [`cvr/event-architecture`](https://github.com/anomalyco/opentui/tree/cvr/event-architecture) has a working implementation for keyboard/paste dispatch. 47 tests across 3 test files covering capture/bubble ordering, stopPropagation, stopImmediatePropagation, preventDefault independence, scope isolation, paste dispatch, once listeners, error handling, and nested component scenarios. All existing tests pass. ### New files - `packages/core/src/lib/event.ts` — `TUIEvent`, `EventPhase` - `packages/core/src/lib/event-dispatch.ts` — `dispatchEvent`, `buildPropagationPath` ### Modified - `Renderable.ts` — `addEventListener`/`removeEventListener`, handler types, `defaultPrevented` checks - `KeyHandler.ts` — `KeyEvent`/`PasteEvent` extend `TUIEvent`, simplified `InternalKeyHandler.emit()` - `renderer.ts` — wire root and focus provider - `types.ts` — remove `_internalKeyInput` Mouse dispatch through `dispatchEvent` is a natural next step but not yet implemented. --- Thoughts? Open to feedback on the API surface, naming, or anything that smells off. ## Mouse event migration Branch [`cvr/event-architecture-mouse`](https://github.com/cevr/opentui/tree/cvr/event-architecture-mouse) extends the capture/bubble model to mouse events. ### Changes - **`MouseEvent extends TUIEvent`** — new `lib/mouse-event.ts`, re-exported from `renderer.ts` for backward compat. Inherits `stopImmediatePropagation`, `eventPhase`, `currentTarget` from base. - **`processMouseEvent` → `dispatchEvent`** — replaces manual parent-chain walk with full capture/bubble dispatch. - **Property handlers wire through `addEventListener`** — `onMouseDown`, `onMouseScroll`, etc. now register/remove via the same listener system as keyboard events. - **`onMouseEvent` virtual method removed** — subclasses (`ScrollBox`, `EditBufferRenderable`, `TextBufferRenderable`, examples) converted to `addEventListener` calls in constructors. ### What stays unchanged - Focus/blur, resize, layout, selection — non-propagating lifecycle signals, still EventEmitter - Mouse event type names (`"down"`, `"up"`, `"scroll"`, etc.) - Constructor signatures for `MouseEvent` - All existing property handler APIs (`onMouseDown`, `onMouse`, etc.) ### Tests 39 tests pass (32 existing + 7 new covering TUIEvent inheritance, capture-before-bubble ordering, stopPropagation in capture, stopImmediatePropagation, scroll through capture/bubble, and property handler + addEventListener coexistence). All 3141 core, 26 React, and 176 Solid tests pass.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/opentui#175
No description provided.