Files
rust_browser/_bmad-output/implementation-artifacts/3-9-event-dispatch-completeness.md
Zachary D. Rowitsch fb64ca1d34
All checks were successful
ci / fast (linux) (push) Successful in 7m9s
Create story files for Epic 3 stories 3.5-3.10
Create comprehensive implementation-ready story files for the remaining
Epic 3 (JavaScript Engine Maturity) stories and update sprint status
from backlog to ready-for-dev:

- 3.5: Built-in Completeness (Array/String/Object)
- 3.6: Built-in Completeness (Date/RegExp/Map/Set)
- 3.7: WeakRef, FinalizationRegistry & Strict Mode Edge Cases
- 3.8: DOM Bindings via web_api
- 3.9: Event Dispatch Completeness
- 3.10: Web API Exposure (fetch, Math, setInterval, rAF)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 08:06:03 -04:00

348 lines
22 KiB
Markdown

# Story 3.9: Event Dispatch Completeness
Status: ready-for-dev
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Story
As a web developer using JavaScript,
I want all event types and dispatch phases to work correctly,
So that interactive elements respond to user actions as expected.
## Acceptance Criteria
1. **Capture phase:** `addEventListener(type, handler, {capture: true})` or `addEventListener(type, handler, true)` registers a capture listener. Events propagate Document → root → ... → target during capture phase before target and bubble phases. Capture listeners fire with `eventPhase === 1`. Per DOM §2.9.
2. **stopImmediatePropagation:** `event.stopImmediatePropagation()` prevents remaining listeners on the current target from firing (unlike `stopPropagation()` which only prevents ancestors). Per DOM §2.5.
3. **Mouse events:** `mousedown`, `mouseup`, `mousemove`, `mouseenter`, `mouseleave`, `mouseover`, `mouseout` dispatched to JS handlers. MouseEvent objects include `clientX`, `clientY`, `button`, `buttons`, `ctrlKey`, `shiftKey`, `altKey`, `metaKey`. `mousedown`/`mouseup`/`mouseover`/`mouseout` bubble; `mouseenter`/`mouseleave` do not. Per UI Events §3.
4. **Keyboard events:** `keydown`, `keyup` dispatched to JS handlers on the focused element (or document if nothing focused). KeyboardEvent objects include `key`, `code`, `ctrlKey`, `shiftKey`, `altKey`, `metaKey`, `repeat`. Per UI Events §5.
5. **Focus events:** `focus`, `blur` (don't bubble), `focusin`, `focusout` (bubble) dispatched when focus changes between elements. Events include `relatedTarget` (element losing/gaining focus). Per UI Events §6.
6. **Custom events:** `new Event(type, {bubbles, cancelable})` constructor works from JS. `element.dispatchEvent(event)` dispatches a synthetic event through the full capture → target → bubble path. `event.isTrusted` is `true` for user-initiated events, `false` for synthetic. Per DOM §2.2.
7. **Integration tests verify each event type** and dispatch phase, and `just ci` passes.
## What NOT to Implement
- **No `input`/`change` events** -- form input events deferred to Epic 4 (User Input & Forms).
- **No touch events** -- `touchstart`, `touchmove`, `touchend` out of scope (desktop browser).
- **No `wheel`/`scroll` events** -- scroll events deferred to Epic 5.
- **No `resize` event** -- window resize deferred to Epic 5.
- **No `DragEvent`** -- drag and drop out of scope.
- **No `CompositionEvent`** -- IME input events out of scope.
- **No `keypress`** -- deprecated event, skip. Only `keydown`/`keyup`.
- **No `MouseEvent.offsetX/offsetY`** -- layout-relative coordinates require layout tree access from event dispatch. Defer.
- **No `Event.composedPath()`** -- Shadow DOM not implemented.
## Files to Modify
| File | Change |
|------|--------|
| `crates/web_api/src/event.rs` | Add `EventData` enum (Mouse, Keyboard, Focus variants) to `DomEvent`. Add `immediate_propagation_stopped`, `is_trusted` flags. |
| `crates/web_api/src/event_dispatch.rs` | Add capture phase loop (reverse propagation path). Check `listener.capture` flag during dispatch. Add `stopImmediatePropagation` check in `invoke_listeners()`. |
| `crates/web_api/src/event_target.rs` | Ensure capture listeners are stored and retrievable separately for capture vs bubble phases. |
| `crates/web_api/src/dom_host/host_environment.rs` | Expose MouseEvent/KeyboardEvent/FocusEvent properties via `get_property()`. Add `stopImmediatePropagation()` to `call_method()`. Add `dispatchEvent()` to Element/Document methods. Add `Event` constructor. |
| `crates/web_api/src/lib.rs` | Add `dispatch_mouse_event()`, `dispatch_keyboard_event()`, `dispatch_focus_event()` methods to WebApiFacade. Add `dispatch_synthetic_event()` for custom events. |
| `crates/app_browser/src/event_handler.rs` | Dispatch `mousedown`/`mouseup`/`mousemove` on mouse input events. Dispatch `keydown`/`keyup` on keyboard input events. Calculate client coordinates from window position. Track hovered element for `mouseenter`/`mouseleave`/`mouseover`/`mouseout`. |
| `crates/browser_runtime/src/lib.rs` | Add `dispatch_mouse_event()`, `dispatch_keyboard_event()`, `dispatch_focus_event()` wrappers. |
| `crates/platform/src/event_loop.rs` | Verify raw keyboard/mouse events are forwarded to event handler (may already be done). |
| `tests/js_events.rs` | Add tests for capture phase, keyboard events, mouse events, focus events, custom events, stopImmediatePropagation. |
| `docs/HTML5_Implementation_Checklist.md` | Check off capture phase, event types. |
## Tasks / Subtasks
- [ ] Task 1: Capture phase and stopImmediatePropagation (AC: #1, #2)
- [ ] 1.1 Add `immediate_propagation_stopped: bool` and `is_trusted: bool` to `DomEvent` in `event.rs`:
- Initialize `immediate_propagation_stopped = false`
- Initialize `is_trusted = true` for user events, `false` for synthetic
- [ ] 1.2 Implement capture phase in `dispatch_event()` in `event_dispatch.rs`:
- Current flow: Target → Bubble
- New flow: **Capture → Target → Bubble** (per DOM §2.9)
- Build propagation path as before: `[target, parent, ..., root, Document]`
- **Capture phase**: iterate path in REVERSE (Document → root → ... → parent of target), skip target itself
- Set `event.phase = EventPhase::Capture`
- For each ancestor, invoke listeners where `listener.capture == true`
- Check `propagation_stopped` after each node
- **Target phase**: invoke ALL listeners on target (both capture and non-capture)
- Set `event.phase = EventPhase::Target`
- **Bubble phase**: iterate path forward (parent → ... → root → Document), if `event.bubbles`
- Set `event.phase = EventPhase::Bubble`
- Invoke listeners where `listener.capture == false`
- [ ] 1.3 Add `stopImmediatePropagation()` to event methods in `host_environment.rs`:
- Set `event.immediate_propagation_stopped = true`
- Also sets `event.propagation_stopped = true` (per spec)
- [ ] 1.4 Check `immediate_propagation_stopped` in `invoke_listeners()`:
- After each listener invocation, if `immediate_propagation_stopped == true`, break out of listener loop (not just node loop)
- [ ] 1.5 Update listener snapshot collection in `event_target.rs`:
- `collect_for_path()` should include capture flag so dispatch can filter
- Return `Vec<(EventTargetId, Vec<EventListener>)>` with capture flag preserved
- [ ] 1.6 Add tests:
- Capture handler fires before target handler
- Capture handler fires before bubble handler
- Multiple capture handlers fire in registration order
- `stopImmediatePropagation()` stops remaining handlers on same element
- `stopPropagation()` in capture phase prevents target and bubble phases
- [ ] Task 2: Mouse events (AC: #3)
- [ ] 2.1 Add `EventData` enum to `DomEvent` in `event.rs`:
```rust
pub enum EventData {
None,
Mouse { client_x: f64, client_y: f64, button: u16, buttons: u16,
ctrl_key: bool, shift_key: bool, alt_key: bool, meta_key: bool },
Keyboard { key: String, code: String, repeat: bool,
ctrl_key: bool, shift_key: bool, alt_key: bool, meta_key: bool },
Focus { related_target: Option<EventTargetId> },
}
```
- Add `data: EventData` field to `DomEvent`
- Default to `EventData::None` for existing events (click, DOMContentLoaded, etc.)
- [ ] 2.2 Expose MouseEvent properties via `get_property()` in `host_environment.rs`:
- For Event type with `EventData::Mouse`: expose `clientX`, `clientY`, `button`, `buttons`, `ctrlKey`, `shiftKey`, `altKey`, `metaKey`
- For non-mouse events, these properties return `undefined`
- [ ] 2.3 Add `dispatch_mouse_event()` to WebApiFacade:
- Parameters: `event_type: &str`, `target: NodeId`, `client_x: f64`, `client_y: f64`, `button: u16`, `modifiers: Modifiers`
- Create `DomEvent` with `EventData::Mouse { ... }`
- Set `bubbles` based on event type: `mousedown`/`mouseup`/`mouseover`/`mouseout`/`click` → true; `mouseenter`/`mouseleave` → false
- Dispatch via `dispatch_event()`
- For non-bubbling events (`mouseenter`/`mouseleave`): fire only on target, no propagation path
- [ ] 2.4 Dispatch mouse events from `event_handler.rs`:
- On `WindowEvent::MousePressed` → dispatch `mousedown` then (on release or same frame) prepare `mouseup`/`click` sequence
- On `WindowEvent::MouseReleased` → dispatch `mouseup`, then `click` if same target
- On `WindowEvent::CursorMoved` → dispatch `mousemove` on element under cursor
- Track previously hovered element for enter/leave:
- If element changes: dispatch `mouseout` (old, bubbles) → `mouseleave` (old, no bubble) → `mouseover` (new, bubbles) → `mouseenter` (new, no bubble)
- Calculate `clientX`/`clientY` relative to viewport (subtract window chrome/scroll offsets)
- [ ] 2.5 Update existing `dispatch_click()` to use the new mouse event infrastructure:
- `click` should include MouseEvent properties (clientX, clientY, button=0)
- Preserve backwards compatibility with existing click tests
- [ ] 2.6 Add tests:
- `mousedown` → `mouseup` → `click` sequence on element
- MouseEvent properties (clientX, clientY, button) accessible in handler
- `mouseover`/`mouseout` bubble; `mouseenter`/`mouseleave` don't
- Modifier keys (ctrlKey, shiftKey) passed through
- [ ] Task 3: Keyboard events (AC: #4)
- [ ] 3.1 Expose KeyboardEvent properties via `get_property()`:
- For Event with `EventData::Keyboard`: expose `key`, `code`, `repeat`, `ctrlKey`, `shiftKey`, `altKey`, `metaKey`
- [ ] 3.2 Add `dispatch_keyboard_event()` to WebApiFacade:
- Parameters: `event_type: &str`, `target: NodeId`, `key: &str`, `code: &str`, `modifiers: Modifiers`, `repeat: bool`
- `keydown` and `keyup` both bubble and are cancelable
- Target: focused element if one exists, otherwise document
- [ ] 3.3 Dispatch keyboard events from `event_handler.rs`:
- On `WindowEvent::KeyPressed` → dispatch `keydown`
- On `WindowEvent::KeyReleased` → dispatch `keyup`
- Map platform key codes to `key` (logical key like `"Enter"`, `"a"`, `"ArrowUp"`) and `code` (physical key like `"KeyA"`, `"Enter"`, `"ArrowUp"`)
- Check `winit` key event for `key` and `code` mappings -- winit provides `event.logical_key` and `event.physical_key`
- Pass modifier state from `winit::event::Modifiers`
- [ ] 3.4 Add focused element tracking:
- Add `focused_element: Option<NodeId>` to WebApiFacade (or EventHandler state)
- Default to `None` (keyboard events target document)
- Updated by focus events (Task 4) and click events (clicking an element focuses it)
- [ ] 3.5 Add tests:
- `keydown` fires on focused element
- `keydown` fires on document when nothing focused
- KeyboardEvent properties (key, code, ctrlKey) accessible
- `keydown` bubbles to parent
- `preventDefault()` on keydown prevents default action
- [ ] Task 4: Focus events (AC: #5)
- [ ] 4.1 Expose FocusEvent properties via `get_property()`:
- For Event with `EventData::Focus`: expose `relatedTarget` (HostObject or Null)
- [ ] 4.2 Add `dispatch_focus_change()` to WebApiFacade:
- Parameters: `old_focus: Option<NodeId>`, `new_focus: Option<NodeId>`
- Dispatch sequence (per UI Events §6.4.2):
1. `focusout` on old element (bubbles, `relatedTarget` = new element)
2. `focusin` on new element (bubbles, `relatedTarget` = old element)
3. `blur` on old element (no bubble, `relatedTarget` = new element)
4. `focus` on new element (no bubble, `relatedTarget` = old element)
- `focus`/`blur` are NOT cancelable; `focusin`/`focusout` are NOT cancelable
- [ ] 4.3 Trigger focus changes from click events:
- When element is clicked, if it's focusable (inputs, buttons, elements with tabindex), update `focused_element` and dispatch focus events
- Only certain elements are focusable: `<input>`, `<textarea>`, `<select>`, `<button>`, `<a>` with href, elements with `tabindex`
- For this story, treat any element as focusable when clicked (refinement in Epic 4)
- [ ] 4.4 Add tests:
- Click on element A, then click on element B → blur on A, focus on B
- `focus` doesn't bubble; `focusin` does
- `relatedTarget` is correct on both focus and blur events
- [ ] Task 5: Custom events and dispatchEvent (AC: #6)
- [ ] 5.1 Add `Event` constructor to JS globals:
- `new Event(type)` → create event with type, bubbles=false, cancelable=false
- `new Event(type, { bubbles: true, cancelable: true })` → set options
- Implement via `construct()` in `host_environment.rs` for constructor name `"Event"`
- Return HostObject with type `"Event"` and a new event ID
- Store pending custom events in WebApiFacade (similar to active_events)
- [ ] 5.2 Add `dispatchEvent(event)` to Element/Document methods:
- Accept a custom Event HostObject
- Set `event.is_trusted = false` (synthetic event)
- Set `event.target` to the element
- Dispatch through full capture → target → bubble path
- Return `!event.defaultPrevented` (boolean, per DOM §2.6)
- [ ] 5.3 Add `event.isTrusted` property:
- Expose via `get_property()` for Event type
- `true` for events created by user interaction, `false` for `dispatchEvent()`
- [ ] 5.4 Add tests:
- `new Event("custom", {bubbles: true})` creates event
- `element.dispatchEvent(event)` triggers handlers
- Custom event bubbles if `bubbles: true`
- `event.isTrusted === false` for custom events
- `event.isTrusted === true` for click events
- [ ] Task 6: Testing and validation (AC: #7)
- [ ] 6.1 Add comprehensive integration tests in `tests/js_events.rs`:
- Capture → target → bubble ordering
- stopImmediatePropagation vs stopPropagation
- Mouse event sequence (mousedown → mouseup → click)
- Keyboard events on focused element
- Focus/blur on element change
- Custom events via dispatchEvent
- All event properties accessible in handlers
- [ ] 6.2 Run all existing test suites:
- `cargo test -p web_api`
- `cargo test -p rust_browser --test js_events`
- `cargo test -p rust_browser --test js_dom_tests`
- `cargo test -p rust_browser --test js_tests`
- `cargo test -p rust_browser --test goldens`
- [ ] 6.3 Update `docs/HTML5_Implementation_Checklist.md`:
- Check off capture phase
- Check off keyboard/mouse/focus event types
- [ ] 6.4 Run `just ci` -- full validation pass
## Dev Notes
### Key Architecture Decisions
**Extend DomEvent with EventData enum, don't subclass.** The existing `DomEvent` struct is used everywhere. Rather than creating `MouseEvent`/`KeyboardEvent` subclasses (which would require trait objects or generics throughout dispatch), add an `EventData` enum field. This keeps the dispatch path monomorphic and simple.
**Capture phase is a reverse walk of the same propagation path.** The existing `build_propagation_path()` returns `[target, parent, ..., root, Document]`. For capture: iterate indices `(len-1)..1` (Document → root → parent of target). For target: index `0`. For bubble: indices `1..len`. No new path building needed.
**Mouse event sequence matters.** Per UI Events, clicking produces: `mousedown` → `mouseup` → `click`. All three fire on the same target. `mousedown` is dispatched on press, `mouseup` + `click` on release. If mouse moves between press and release (different target), `click` is suppressed.
**Focus tracking is shared state.** `focused_element: Option<NodeId>` must be accessible from both event dispatch (to target keyboard events) and DOM operations (to determine which element receives focus events). Store in WebApiFacade.
### Implementation Patterns from Existing Code
**Event dispatch** (in `event_dispatch.rs`):
```rust
pub fn dispatch_event(
event: &mut DomEvent,
document: &Document,
listener_registry: &EventListenerRegistry,
js_engine: &mut JsEngine,
host: &mut DomHost<'_>,
) -> DispatchResult {
let path = build_propagation_path(document, target);
let snapshot = listener_registry.collect_for_path(&path, &event.event_type);
// Currently: target phase, then bubble phase
// Add: capture phase BEFORE target phase
}
```
**Listener registration** (in `host_environment.rs`):
```rust
// addEventListener(type, callback, capture)
"addEventListener" => {
let capture = args.get(2).map_or(false, |v| v.to_boolean());
// capture is stored but currently ignored during dispatch
}
```
**Platform key events** (in `platform/src/event_loop.rs`):
```rust
// winit events mapped to WindowEvent variants
// Check if KeyPressed/KeyReleased carry key information
```
### Critical Implementation Details
**`mouseenter`/`mouseleave` DON'T bubble.** Unlike `mouseover`/`mouseout`, these events fire only on the target, not ancestors. In `dispatch_event()`, check if event type is `mouseenter` or `mouseleave` and skip propagation path (fire only on target).
**`focus`/`blur` DON'T bubble either.** But `focusin`/`focusout` DO. The focus change sequence dispatches both pairs to ensure both bubbling and non-bubbling listeners are supported.
**Custom Event storage.** When `new Event(type)` is called, create a `DomEvent` and store it with a unique ID. When `dispatchEvent(eventHostObject)` is called, look up the event by ID, set target, and dispatch. After dispatch, the event remains accessible (properties can be read after dispatch).
**`isTrusted` is read-only.** Scripts cannot set `event.isTrusted`. Only the engine sets it. User events get `true`, `dispatchEvent` events get `false`.
**Element hover tracking for mouseenter/mouseleave.** Track `hovered_element: Option<NodeId>` in event handler state. On `CursorMoved`, hit-test to find element under cursor. If different from previous, dispatch the mouseout → mouseleave → mouseover → mouseenter sequence.
### Dependencies
**Story 3.8 (DOM Bindings):** Focus events need `parentNode` navigation for bubble path building (already exists in dispatch). `dispatchEvent()` needs to be a method on Element host objects (added in 3.8 or here).
**No dependency on Stories 3.5-3.7.** Event dispatch doesn't require property descriptors, Date/Map/Set, or WeakRef.
### Previous Story Patterns
From Story 3.8 (DOM Bindings):
- HostObject types with ID ranges -- custom events need their own ID range
- `get_property()` dispatch on type_name -- extend for MouseEvent/KeyboardEvent properties
From existing event tests (`tests/js_events.rs`):
- Tests use `dispatch_click()` on WebApiFacade directly
- Integration tests run JS code and check results via script output
### Risk Assessment
**MEDIUM: Mouse event dispatch from platform layer.** Requires understanding how `winit` delivers mouse position, button state, and modifier keys. The mapping from `winit::event::MouseButton`/`KeyEvent` to our `EventData` structs needs careful attention to coordinate systems.
**MEDIUM: Hover tracking for mouseenter/mouseleave.** Requires hit-testing on every `CursorMoved` event, which could be performance-sensitive. The hit-test infrastructure already exists for click handling -- reuse it.
**LOW: Capture phase.** Well-defined spec behavior. Straightforward reversal of propagation path iteration.
**LOW: stopImmediatePropagation.** Single boolean flag check in listener loop.
**LOW: Custom events.** Small API surface. Main complexity is Event constructor and storage.
### Phased Implementation Strategy
**Phase A -- Capture + stopImmediatePropagation (Task 1):** Foundation fix for dispatch. All subsequent events benefit from correct 3-phase dispatch.
**Phase B -- Mouse Events (Task 2):** Extend DomEvent with EventData, wire from platform layer. Largest task.
**Phase C -- Keyboard Events (Task 3):** Similar pattern to mouse events but simpler (no coordinate tracking).
**Phase D -- Focus Events (Task 4):** Depends on focused element tracking. Ties into keyboard event targeting.
**Phase E -- Custom Events (Task 5):** New API surface. Independent of other tasks.
**Phase F -- Testing + Validation (Task 6):** After all event types implemented.
### Project Structure Notes
- Event system changes in `crates/web_api/src/` (Layer 1) -- no layer violations
- Platform input mapping in `crates/app_browser/src/event_handler.rs` (Layer 3) -- reads from platform, calls web_api
- `browser_runtime` (Layer 2) -- thin wrappers forwarding to web_api
- No `unsafe` code needed
- No new external dependencies
### References
- [DOM Living Standard §2 -- Events](https://dom.spec.whatwg.org/#events) -- Event dispatch algorithm
- [DOM Living Standard §2.9 -- Dispatching events](https://dom.spec.whatwg.org/#dispatching-events) -- Capture → target → bubble
- [UI Events §3 -- Mouse Events](https://w3c.github.io/uievents/#events-mouseevents) -- MouseEvent interface
- [UI Events §5 -- Keyboard Events](https://w3c.github.io/uievents/#events-keyboardevents) -- KeyboardEvent interface
- [UI Events §6 -- Focus Events](https://w3c.github.io/uievents/#events-focusevent) -- FocusEvent interface
- [Source: crates/web_api/src/event.rs] -- DomEvent struct, EventPhase enum
- [Source: crates/web_api/src/event_dispatch.rs] -- dispatch_event(), build_propagation_path(), invoke_listeners()
- [Source: crates/web_api/src/event_target.rs] -- EventListenerRegistry, listener deduplication
- [Source: crates/web_api/src/dom_host/host_environment.rs] -- addEventListener, event property access
- [Source: crates/app_browser/src/event_handler.rs] -- Platform event handling, click dispatch
- [Source: _bmad-output/planning-artifacts/epics.md#Story 3.9] -- Story requirements
## Dev Agent Record
### Agent Model Used
{{agent_model_name_version}}
### Debug Log References
### Completion Notes List
### File List