Events¶
Event handlers are on_* props on host elements. They're delegated:
one native listener per event type per render root, with a single
Python call per handler that matches.
from wybthon import button, component
@component
def Button():
return button("Click", on_click=lambda: print("clicked"))
Supported prop names: on_click, on_input, on_change, and so on.
Both on_foo and onFoo styles are accepted and normalize to DOM
event names. Non-callable values are ignored, and passing None on an
update removes the handler.
A handler may take the event or no arguments at all. Wybthon checks the handler's signature once, when it's registered, so a zero-argument handler costs nothing extra. Prefer one whenever you don't read the event:
DomEvent¶
Handlers that take an argument receive a DomEvent
built from a small payload the kernel assembles natively, so reading the
common fields never crosses the Python-to-JS bridge:
type: the event type string ("click","input").target: a payload-backed view of the original event target.value,checked, andfilesmirror the DOM properties handlers actually read; the raw JS node is available astarget.elementwhen you need more.current_target: anElementfor the node whose handler is running during delegated bubbling.key,code,alt_key,ctrl_key,meta_key,shift_key: keyboard fields (keyandcodeareNonefor non-keyboard events).button,client_x,client_y: mouse and pointer fields.prevent_default(): asks the dispatcher to callpreventDefault()on the native event once your handler returns. Safe to call in non-browser tests.stop_propagation(): stops the delegated walk for this event and native propagation above the handled node.raw: the native browser event object for anything not in the payload. Only valid synchronously during dispatch.
Read input values as you would in JavaScript or SolidJS:
A submit handler:
from wybthon import DomEvent, button, component, form, input_
@component
def Search():
def submit(evt: DomEvent):
evt.prevent_default()
print("submitted from", evt.current_target)
return form(
input_(name="q", on_input=lambda e: print("typed", e.target.value)),
button("Go", type="submit"),
on_submit=submit,
)
Delegation model¶
Delegation lives in the rendering kernel, the JavaScript side of the
batched renderer. On first use of an event type, the kernel installs one
native listener on each delegation root (the container you passed to
render and the mount target of any mounted
Portal; document is used until a root exists).
When an event fires, the kernel walks up
from the original target natively and calls into Python once for the
bubbling route, running each handler registered for that type along the
way. The payload crosses the bridge as one JSON string, so a click on a
row in a 10,000-row table costs a single Python call.
Registering a handler costs no bridge crossing of its own. A compiled
template declares its delegated listener types when it's registered, so
cloning a row marks its handlers natively and sends no LISTEN op.
Other handlers register with a batched LISTEN op riding the same
command buffer as DOM mutations. Either way, mounting a list with
thousands of handlers adds nothing to the bridge-crossing count.
Handlers flush when they return
After a delegated handler returns, the dispatcher flushes: every effect the handler dirtied runs, and the resulting DOM ops commit in one bridge crossing before the browser paints. Signal writes inside a handler update the UI without any manual step, no matter how many writes the handler made. See Staged writes and flush timing.
Handler errors are logged through the dev-mode error channel; they
aren't routed to an Errored boundary. See
Error boundaries.
Cleanup guarantees:
- When a node is unmounted, its handlers are dropped on the Python side. The kernel's native
DISPOSE_RANGEorDISPOSEop removes the nodes and clears their listener bookkeeping in the same walk, so Python sends no per-node release list. - When the last handler for an event type is removed across the whole app (by unmount, or by diffing a handler to
None), the native listener for that type is removed from every root.
Naming and normalization¶
on_clickbecomes"click".onInputandon_inputboth become"input".onClickandonclickbecome"click".- A
_capturesuffix listens in the capture phase:on_click_capture. - Any prop starting with
on_oronis treated as an event handler.
Event types that work best with delegation¶
Prefer events that bubble:
- Mouse:
click,dblclick,mousedown,mouseup,mousemove,mouseover,mouseout,contextmenu,wheel - Keyboard:
keydown,keyup(avoid the deprecatedkeypress) - Input and form:
input,change,submit,reset - Pointer:
pointerdown,pointerup,pointermove,pointerover,pointerout,pointercancel
Non-bubbling events still work as props. focus, blur,
mouseenter, mouseleave, pointerenter, pointerleave, scroll,
load, error, invalid, and toggle get a direct native listener on
their node instead of the delegated one. Use focusin and focusout
when an ancestor should hear focus changes from its descendants.
Native listeners through a ref¶
For anything the props don't cover, such as a third-party library that
wants the element itself, attach a native listener directly through
Pyodide using a Ref. Refs are assigned during mount, so
do the wiring in on_settled, which runs after
the first commit. Wrap the handler in create_proxy so it survives
garbage collection, and remove it on cleanup:
from pyodide.ffi import create_proxy
from wybthon import Ref, component, div, on_cleanup, on_settled
@component
def DropZone():
ref = Ref()
proxy = create_proxy(lambda e: print("files dropped"))
def setup():
if ref.current is not None:
ref.current.element.addEventListener("drop", proxy)
def teardown():
if ref.current is not None:
ref.current.element.removeEventListener("drop", proxy)
proxy.destroy()
on_settled(setup)
on_cleanup(teardown)
return div("Drop files here", ref=ref, class_="drop-zone")
Reading ref.current.element commits any pending batched ops first, so
the node exists and reflects every queued mutation. See
DOM interop.
Pyodide cross-browser notes¶
- Delegation depends on bubbling to the render root. Non-bubbling types use direct listeners automatically, as described above.
- Chrome and Edge may treat
touchstartandtouchmovelisteners as passive, soprevent_default()may be ignored for them. Useevent(handler, passive=False)or a direct listener with{"passive": False}options if you need to prevent scrolling. keypressis deprecated; preferkeydownandkeyup.
Testing handlers¶
wybthon.testing renders into an in-memory DOM in
plain CPython and dispatches events through the same delegated handlers. Each fire helper flushes when the
handlers return, just as in the browser:
from wybthon import button, component, create_signal, div, p
from wybthon.testing import fire, render
@component
def Counter():
count, set_count = create_signal(0)
return div(p(t"Count: {count}"), button("+", on_click=lambda: set_count(lambda n: n + 1)))
def test_counter():
with render(Counter()) as screen:
fire.click(screen.get_by_text("+"))
assert screen.get_by_text("Count: 1")
fire.input(node, value), fire.change(node, checked=True),
fire.submit(form), and fire.key_down(node, "Enter") set the matching
payload fields. fire(node, "dblclick", shift_key=True) dispatches any
other type.
Next steps¶
- Read Forms for higher-level controlled-input patterns.
- See DOM interop for the underlying
ElementandRefAPIs. - Browse the
eventsAPI reference for delegation internals.
Async handlers and native options¶
An ordinary async def handler is scheduled automatically. It runs in an owned asyncio task and is canceled on handler replacement or unmount. Payload fields and current_target remain usable after an await; event.raw is valid only during synchronous dispatch. Call prevent_default before the first await when you need to prevent native behavior.
event(callback, capture=True, passive=False, once=False) configures native listener options. Focus, blur, and other non-bubbling events work through direct listeners. Ordinary bubbling uses composedPath, sends one route across the bridge, and flushes after its handlers. Custom event detail, composition state, selected option values, and scroll position are included in the payload. A passive handler can't prevent the default action.