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>
22 KiB
Story 3.9: Event Dispatch Completeness
Status: ready-for-dev
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
-
Capture phase:
addEventListener(type, handler, {capture: true})oraddEventListener(type, handler, true)registers a capture listener. Events propagate Document → root → ... → target during capture phase before target and bubble phases. Capture listeners fire witheventPhase === 1. Per DOM §2.9. -
stopImmediatePropagation:
event.stopImmediatePropagation()prevents remaining listeners on the current target from firing (unlikestopPropagation()which only prevents ancestors). Per DOM §2.5. -
Mouse events:
mousedown,mouseup,mousemove,mouseenter,mouseleave,mouseover,mouseoutdispatched to JS handlers. MouseEvent objects includeclientX,clientY,button,buttons,ctrlKey,shiftKey,altKey,metaKey.mousedown/mouseup/mouseover/mouseoutbubble;mouseenter/mouseleavedo not. Per UI Events §3. -
Keyboard events:
keydown,keyupdispatched to JS handlers on the focused element (or document if nothing focused). KeyboardEvent objects includekey,code,ctrlKey,shiftKey,altKey,metaKey,repeat. Per UI Events §5. -
Focus events:
focus,blur(don't bubble),focusin,focusout(bubble) dispatched when focus changes between elements. Events includerelatedTarget(element losing/gaining focus). Per UI Events §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.isTrustedistruefor user-initiated events,falsefor synthetic. Per DOM §2.2. -
Integration tests verify each event type and dispatch phase, and
just cipasses.
What NOT to Implement
- No
input/changeevents -- form input events deferred to Epic 4 (User Input & Forms). - No touch events --
touchstart,touchmove,touchendout of scope (desktop browser). - No
wheel/scrollevents -- scroll events deferred to Epic 5. - No
resizeevent -- 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. Onlykeydown/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: boolandis_trusted: booltoDomEventinevent.rs:- Initialize
immediate_propagation_stopped = false - Initialize
is_trusted = truefor user events,falsefor synthetic
- Initialize
- 1.2 Implement capture phase in
dispatch_event()inevent_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_stoppedafter each node
- Set
- Target phase: invoke ALL listeners on target (both capture and non-capture)
- Set
event.phase = EventPhase::Target
- Set
- Bubble phase: iterate path forward (parent → ... → root → Document), if
event.bubbles- Set
event.phase = EventPhase::Bubble - Invoke listeners where
listener.capture == false
- Set
- 1.3 Add
stopImmediatePropagation()to event methods inhost_environment.rs:- Set
event.immediate_propagation_stopped = true - Also sets
event.propagation_stopped = true(per spec)
- Set
- 1.4 Check
immediate_propagation_stoppedininvoke_listeners():- After each listener invocation, if
immediate_propagation_stopped == true, break out of listener loop (not just node loop)
- After each listener invocation, if
- 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 elementstopPropagation()in capture phase prevents target and bubble phases
- 1.1 Add
-
Task 2: Mouse events (AC: #3)
- 2.1 Add
EventDataenum toDomEventinevent.rs: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: EventDatafield toDomEvent - Default to
EventData::Nonefor existing events (click, DOMContentLoaded, etc.)
- Add
- 2.2 Expose MouseEvent properties via
get_property()inhost_environment.rs:- For Event type with
EventData::Mouse: exposeclientX,clientY,button,buttons,ctrlKey,shiftKey,altKey,metaKey - For non-mouse events, these properties return
undefined
- For Event type with
- 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
DomEventwithEventData::Mouse { ... } - Set
bubblesbased 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
- Parameters:
- 2.4 Dispatch mouse events from
event_handler.rs:- On
WindowEvent::MousePressed→ dispatchmousedownthen (on release or same frame) preparemouseup/clicksequence - On
WindowEvent::MouseReleased→ dispatchmouseup, thenclickif same target - On
WindowEvent::CursorMoved→ dispatchmousemoveon 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)
- If element changes: dispatch
- Calculate
clientX/clientYrelative to viewport (subtract window chrome/scroll offsets)
- On
- 2.5 Update existing
dispatch_click()to use the new mouse event infrastructure:clickshould include MouseEvent properties (clientX, clientY, button=0)- Preserve backwards compatibility with existing click tests
- 2.6 Add tests:
mousedown→mouseup→clicksequence on element- MouseEvent properties (clientX, clientY, button) accessible in handler
mouseover/mouseoutbubble;mouseenter/mouseleavedon't- Modifier keys (ctrlKey, shiftKey) passed through
- 2.1 Add
-
Task 3: Keyboard events (AC: #4)
- 3.1 Expose KeyboardEvent properties via
get_property():- For Event with
EventData::Keyboard: exposekey,code,repeat,ctrlKey,shiftKey,altKey,metaKey
- For Event with
- 3.2 Add
dispatch_keyboard_event()to WebApiFacade:- Parameters:
event_type: &str,target: NodeId,key: &str,code: &str,modifiers: Modifiers,repeat: bool keydownandkeyupboth bubble and are cancelable- Target: focused element if one exists, otherwise document
- Parameters:
- 3.3 Dispatch keyboard events from
event_handler.rs:- On
WindowEvent::KeyPressed→ dispatchkeydown - On
WindowEvent::KeyReleased→ dispatchkeyup - Map platform key codes to
key(logical key like"Enter","a","ArrowUp") andcode(physical key like"KeyA","Enter","ArrowUp") - Check
winitkey event forkeyandcodemappings -- winit providesevent.logical_keyandevent.physical_key - Pass modifier state from
winit::event::Modifiers
- On
- 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)
- Add
- 3.5 Add tests:
keydownfires on focused elementkeydownfires on document when nothing focused- KeyboardEvent properties (key, code, ctrlKey) accessible
keydownbubbles to parentpreventDefault()on keydown prevents default action
- 3.1 Expose KeyboardEvent properties via
-
Task 4: Focus events (AC: #5)
- 4.1 Expose FocusEvent properties via
get_property():- For Event with
EventData::Focus: exposerelatedTarget(HostObject or Null)
- For Event with
- 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):
focusouton old element (bubbles,relatedTarget= new element)focusinon new element (bubbles,relatedTarget= old element)bluron old element (no bubble,relatedTarget= new element)focuson new element (no bubble,relatedTarget= old element)
focus/blurare NOT cancelable;focusin/focusoutare NOT cancelable
- Parameters:
- 4.3 Trigger focus changes from click events:
- When element is clicked, if it's focusable (inputs, buttons, elements with tabindex), update
focused_elementand dispatch focus events - Only certain elements are focusable:
<input>,<textarea>,<select>,<button>,<a>with href, elements withtabindex - For this story, treat any element as focusable when clicked (refinement in Epic 4)
- When element is clicked, if it's focusable (inputs, buttons, elements with tabindex), update
- 4.4 Add tests:
- Click on element A, then click on element B → blur on A, focus on B
focusdoesn't bubble;focusindoesrelatedTargetis correct on both focus and blur events
- 4.1 Expose FocusEvent properties via
-
Task 5: Custom events and dispatchEvent (AC: #6)
- 5.1 Add
Eventconstructor to JS globals:new Event(type)→ create event with type, bubbles=false, cancelable=falsenew Event(type, { bubbles: true, cancelable: true })→ set options- Implement via
construct()inhost_environment.rsfor 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.targetto the element - Dispatch through full capture → target → bubble path
- Return
!event.defaultPrevented(boolean, per DOM §2.6)
- 5.3 Add
event.isTrustedproperty:- Expose via
get_property()for Event type truefor events created by user interaction,falsefordispatchEvent()
- Expose via
- 5.4 Add tests:
new Event("custom", {bubbles: true})creates eventelement.dispatchEvent(event)triggers handlers- Custom event bubbles if
bubbles: true event.isTrusted === falsefor custom eventsevent.isTrusted === truefor click events
- 5.1 Add
-
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_apicargo test -p rust_browser --test js_eventscargo test -p rust_browser --test js_dom_testscargo test -p rust_browser --test js_testscargo 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
- 6.1 Add comprehensive integration tests in
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):
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):
// 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):
// 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
unsafecode needed - No new external dependencies
References
- DOM Living Standard §2 -- Events -- Event dispatch algorithm
- DOM Living Standard §2.9 -- Dispatching events -- Capture → target → bubble
- UI Events §3 -- Mouse Events -- MouseEvent interface
- UI Events §5 -- Keyboard Events -- KeyboardEvent interface
- UI Events §6 -- Focus Events -- 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}}