DOM interop¶
Element is Wybthon's thin wrapper around browser
DOM nodes. It provides helpers for attributes, classes, styles, and
queries while still giving you the raw element for any escape hatch
you need. For events, use the delegated on_* props (see
Events); for non-bubbling event types, attach a native
listener via ref.current.element.addEventListener with a Pyodide
proxy.
from wybthon import Element
root = Element("#app", existing=True)
box = Element("div")
box.set_text("Hello")
box.append_to(root)
You rarely instantiate Element yourself when authoring components;
the renderer creates and manages nodes for you. Reach for it when you
need to bridge to imperative DOM APIs (focus management, scrolling,
measuring, third-party widgets) or when you receive a node back from a
ref or an event.
Refs: holding onto an element¶
A Ref is a small mutable container that the renderer
fills with the mounted Element. Pass ref= to any host element and
read ref.current after mount:
from wybthon import Ref, component, on_settled
from wybthon.html import div, input_
@component
def AutoFocusInput():
ref = Ref()
def focus():
if ref.current is not None:
ref.current.element.focus()
on_settled(focus)
return div(input_(type="text", placeholder="Focus me", ref=ref))
Refs are assigned while the element mounts and reset to None when it
unmounts. Read them in on_settled (which runs
after the first commit), in an effect, or in an event handler, never
at the top of the component body during initial render.
Ref shapes¶
The ref prop accepts three shapes:
- A
Refobject:ref.currentis set on mount and cleared on unmount. - A callable:
ref(element)is called once on mount. It isn't called again on unmount, so pair it withon_cleanupif you need teardown. - A list or tuple mixing both: every entry is assigned. This is how a component forwards a parent's ref while keeping one of its own:
from collections.abc import Callable
from wybthon import Element, Props, Ref, component, on_settled
from wybthon.html import input_
class FancyInputProps(Props):
ref: Ref | Callable[[Element], None] | None = None
@component
def FancyInput(props: FancyInputProps):
local = Ref()
on_settled(lambda: local.current.element.focus())
return input_(type="text", ref=[local, props.ref])
Refs pass through components like any other prop; there's no special
forwarding API. Declare ref as a plain field (not Prop[...]) so
props.ref returns the Ref or callback the parent passed, untouched.
A None entry in the list is ignored.
Ownership and disposal order¶
Refs follow the ownership tree. A component's ref.current is set from
the first commit until the component unmounts. Unmounting queues one
kernel command that removes the component's nodes and releases them
natively, then runs the component's own cleanups, then clears its host
nodes' refs. So an on_cleanup registered in the component body can
still reach ref.current to tear down a native listener or a
third-party widget.
The raw node is another matter. Materializing ref.current.element for
the first time inside a cleanup commits the queued removal, and the
kernel has already released the node by then, so element is None.
If teardown needs the raw node, read ref.current.element while the
component is mounted (in on_settled, say); the Element caches it,
and the cleanup gets the detached node. Use it for teardown, not for
measurements. Cleanups that run later (for example ones registered by
an ancestor) see ref.current is None, so guard for it. See
Lifecycle and ownership.
How the renderer relates to Element¶
The renderer itself never touches raw DOM nodes or Element wrappers.
Every host VNode is identified by an integer node id, and all mutations
(clone, insert, set-attr, dispose) are emitted as compact operations
into a command buffer that a small JavaScript kernel applies in one
bridge crossing per commit. See Virtual DOM.
Element sits on top of that system as the escape hatch for imperative
work:
- An
Elementcan be backed by a raw DOM node (when you construct one yourself, or wrap one withElement(node=...)) or by a kernel node id (what the renderer hands out throughref=andevt.current_target). - Id-backed elements materialize the raw node lazily: the first
elementaccess commits any pending batched ops, then fetches the node from the kernel's registry, so what you see always reflects every queued mutation. node_idgoes the other way: it registers a raw-node-backed element with the kernel so the renderer can target it.renderuses this when you pass anElementcontainer.- Query helpers (
Element.query,Element.query_all,find,find_all) also commit pending ops first, so nodes created earlier in the same update are visible.
Because commits happen automatically at these boundaries, imperative work through a ref stays in sync with the framework's bookkeeping.
Don't fight the renderer
Attributes, classes, and children that the renderer owns should be
driven by reactive bindings, not by Element calls. The next
reconciliation only patches what changed on the VNode, so it won't
notice manual edits and may leave them in place, or overwrite them
unexpectedly. Use Element for things the renderer doesn't manage:
focus, selection, scroll position, measurements, and third-party
widgets that own their subtree.
Element helpers¶
The wrapper mirrors familiar DOM names:
| Helper | Purpose |
|---|---|
element |
The raw JS node (materialized lazily). |
value, checked, files |
Form-control state, readable and (for value, checked) writable. |
set_text, set_html, load_html |
Replace content. set_html bypasses diffing and doesn't sanitize. |
set_attr, get_attr, remove_attr |
Attribute access. |
set_style(dict_or_kwargs) |
Calls style.setProperty per entry. |
add_class, remove_class, toggle_class, has_class |
classList shortcuts. |
append, append_to, remove |
Tree manipulation for nodes you own. |
Element.query, Element.query_all, find, find_all |
Selector queries (commit pending ops first). |
attach_ref(ref) |
Store this element on ref.current. |
Styles and dataset via the VDOM¶
You usually don't need Element for styling:
- The
styleprop takes a dict with camelCase or snake_case keys (background_color,fontSize); the renderer converts to kebab-case and emits a single style op. Keys missing on update are removed, and passingNoneclears earlier styles. Values may be accessors, and a raw style string is accepted too. - The
datasetprop takes a dict; entries render asdata-*attributes and missing keys on update are removed. Keyword props likedata_testid="x"also work. class_accepts a string, a list of strings, a{name: truthy_or_accessor}dict, or an accessor returning any of those.
Rendering into an existing node¶
render(vnode, container) accepts an Element
container and returns a Root. Call root.dispose() to
unmount, run every cleanup, and clear the container's kernel bookkeeping:
from wybthon import Element, render
from wybthon.html import h1
root = render(h1("Hello"), Element("#app", existing=True))
root.dispose()