Skip to content

Refactor event handling#513

Draft
nicoburns wants to merge 7 commits into
DioxusLabs:mainfrom
nicoburns:event-refactor-fable
Draft

Refactor event handling#513
nicoburns wants to merge 7 commits into
DioxusLabs:mainfrom
nicoburns:event-refactor-fable

Conversation

@nicoburns

Copy link
Copy Markdown
Member

No description provided.

Signed-off-by: Nico Burns <nico@nicoburns.com>
… queue

Previously events derived from state changes (focus events, input events,
scroll events, click synthesis) were threaded through dispatch_event callback
parameters into a queue owned by the EventDriver. This meant events could
only be generated on code paths that had been explicitly plumbed through
from a driver: programmatic focus changes (e.g. NodeHandle::set_focus in
dioxus-native-dom, or Tab-key focus traversal) silently fired no events.

Move the queue onto BaseDocument (queue_event/pop_pending_event) and make
the state-changing APIs generate their own events: set_focus_to/clear_focus
now queue blur/focusout/focus/focusin themselves (absorbing the old
generate_focus_events helper), and scroll/input/click default actions queue
directly onto the document. Anything with access to the document can now
generate correctly-ordered events without access to a driver.

EventDriver drains the document queue in a run-to-completion loop
(flush_pending_events, also called at the start of handle_ui_event), and
DioxusDocument::poll dispatches events queued by vdom updates (e.g. focus
events from a mounted handler calling set_focus). The queue is capped so
events cannot accumulate without bound if an embedder never drains them.
Event dispatch in the DOM is reentrant: a listener may synchronously trigger
the dispatch of further events (e.g. a focus handler focusing another
element), re-entering the handler while it is already on the stack. A
`&mut self` receiver makes that impossible to express in Rust, so switch
`EventHandler::handle_event_listener` to `&self` and require handlers to
use interior mutability.

CallbackEventHandler now stores `Rc<dyn Fn>` callbacks instead of
`Rc<RefCell<dyn FnMut>>`, so a callback can legally be re-invoked while
already executing. DioxusEventHandler now holds an `Rc<Runtime>` instead of
`&mut VirtualDom`. Both mirror dioxus-web, whose delegated event handler is
a `Closure<dyn Fn>` capturing `Rc<Runtime>` specifically so that it "may be
invoked recursively if one event triggers another".

This is groundwork for a nested synchronous dispatch_event API.
…steps

Listeners previously received a raw `&mut dyn Document`, which let them
mutate document state but gave them no way to dispatch events: state-changing
APIs like set_focus_to queue their events for dispatch after the current
event completes. The DOM spec however requires dispatchEvent and the
focusing steps to be synchronous and reentrant: when a focus listener calls
focus() on another element, that element's focus listeners run nested,
before the outer listener continues.

Listeners now receive an `EventContext` which provides document access
(doc/doc_mut) plus synchronous dispatch:

- `dispatch_event(event) -> bool`: dispatches through capture/target/bubble
  and runs the default action before returning (DOM dispatchEvent). Returns
  false if the event was cancelled.
- `set_focus(node_id)` / `clear_focus()`: the DOM focusing steps. Runs the
  focus change and synchronously dispatches exactly the blur/focusout/
  focus/focusin events it generated (other queued events stay queued).

To support this the dispatch algorithm moves out of EventDriver methods
into free functions over (doc, handler) so that it can be called
reentrantly from within a listener; the driver and the context both call
into the same code. Events generated by default actions continue to go
through the document's pending-event queue.
Reentrant dispatch means a listener which (transitively) re-dispatches its
own event would recurse until the stack overflows. Track the nesting depth
in the EventDriver (shared with listeners through the EventContext) and
drop events dispatched beyond MAX_DISPATCH_DEPTH (64) with a warning,
mirroring browsers which abort scripts that exceed the engine's recursion
limit during dispatch. Dropping (rather than deferring to the event queue)
ensures a self-recursive listener terminates instead of turning the
run-to-completion loop into an infinite loop.
@nicoburns
nicoburns requested review from ealmloff and jkelleyrtp July 13, 2026 22:21
@nicoburns nicoburns added the experimental Experimental - we may not want to merge this label Jul 13, 2026
On the web, dioxus's MountedData::set_focus calls element.focus(), which
runs the DOM focusing steps synchronously: blur/focus listeners fire before
the set_focus future resolves. dioxus-native's NodeHandle::set_focus used
the queued path instead, so onfocus handlers only ran at the next poll or
UI event, after code following the .await had already executed.

Give NodeHandle an Rc<Runtime> so it can construct a DioxusEventHandler,
add driver-level dispatch_event/set_focus/clear_focus to EventDriver
(mirroring EventContext, for callers outside a listener invocation), and
run the focusing steps synchronously in NodeHandle::set_focus.

Since embedder APIs like this create a fresh EventDriver per call, a
per-driver recursion counter cannot bound a focus <-> focus loop that
recurses through new drivers. Move the dispatch depth onto BaseDocument so
the bound holds across drivers.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

experimental Experimental - we may not want to merge this

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant