Event Binding & the Modal Host¶
This is the deep dive on the card's binding layer and its body-portal modal host β the one runtime seam that Card Architecture gestures at but never details.
Read first, then come back:
- render-cycle.md owns the render cycle (_scheduleRender microtask
dedup, the 8-step _render() sequence, the "innerHTML replaced β listeners discarded β
bindEvents() re-attaches from scratch" invariant). This doc does not re-teach that.
- architecture-overview.md owns the four-layer prototype-mixin pattern
(actions / state / renderers / bindings).
- Frontend Module Reference owns the full src/bindings/*.js
file map. Section 1 below is a wiring index, not a replacement for it.
Everything here is the delta: the _on/_onAll binding helpers, the document.body modal
portal, and the trigger surface that drives re-renders outside the hass setter.
1. The binding layer at a glance¶
this._bindings.bindEvents() runs at the end of every _render() (main.js:1400), after the
shadow-root HTML has been (conditionally) swapped and after _updateModalHost(). Why re-binding
from scratch is safe is render-cycle.md's invariant β not repeated here.
bindEvents() is a flat fan-out: 20 _bind* calls in a fixed order (bindings/index.js:119-140).
Each _bind* lives in its own module, mixed onto VacuumCardBindings.prototype by the
apply*Bindings(...) calls at bindings/index.js:397-415 β with one exception: _bindToasts is
defined inline in index.js:147 (there is no toasts.js module and no applyToastsBindings
import). Modules that own a large region sub-bind
further (e.g. _bindMap β 16 sub-binders at bindings/map.js:52-77; _bindRoomEditor β 5 at
bindings/room-editor.js:21-27).
Module (src/bindings/) |
_bind* entry |
Region / view it wires |
|---|---|---|
nav.js |
_bindNav |
Header nav tabs, bottom-nav, view switching |
language.js |
_bindLanguage |
Language control menu + per-user override |
base-station.js |
_bindBaseStation |
Base Station view controls |
maintenance.js |
_bindMaintenance |
Maintenance view + maintenance-item modal (host) |
metrics.js |
_bindMetrics |
Metrics view |
order.js |
_bindOrder |
Clean-order controls + order-selector modal (host) |
run-profiles.js |
_bindRunProfiles |
Run-profiles view |
saved-zones.js |
_bindSavedZones |
Saved-zones view |
review.js |
_bindReview |
External-run review |
external-jobs.js |
_bindExternalJobs |
External-jobs list + wizard modal (host) |
rooms.js |
_bindRooms |
Rooms view (the default view) |
room-access.js |
_bindRoomAccess |
Room include/exclude + room-access modal (host) |
room-estimate.js |
_bindRoomEstimate |
Room-estimate + estimate modal (host) |
room-editor.js |
_bindRoomEditor |
Room-editor modal (host) β see Β§4 |
room-rules.js |
_bindRoomRules |
Per-room rules |
theme.js |
_bindThemeEditor |
Theme editor + theme-JSON modal (host) |
map.js |
_bindMap |
Live map, zones, furnished art (16 sub-binders) |
setup.js |
_bindSetup |
Setup / onboarding view |
mobile-shell.js |
_bindMobileShell |
Mobile overlay + bottom sheet |
(inline in index.js:147) |
_bindToasts |
Toast host (body-level β see Β§3) |
Where does the module list live? bindEvents() at bindings/index.js:119-140 is the single
source of truth for order; bindings/index.js:397-415 is the source of truth for which modules
exist. Add a bindings module β register it in both. (The lone inline _bindToasts at index.js:147
is the exception β it appears in the order list but has no module file or import.)
2. The _on / _onAll helpers¶
Installed onto the card instance (not the bindings object) by applyCardDomHelpers(this), called
once in the constructor at main.js:124 β before any binding module runs, because every module
calls this.card._on(...).
Signatures (bindings/core.js:54-122):
card.$(selector) // shadowRoot.querySelector β Element | null
card.$all(selector) // shadowRoot.querySelectorAll β Element[]
card._on(el, event, handler, options) // single element; no-ops on null el
card._onAll(selector, event, handler, options)// $all(selector).forEach(el => _on(el, ...))
The root is always the shadow root. $/$all query this.shadowRoot (core.js:55,62), so
_onAll can only ever reach nodes inside the shadow root. This is the whole reason the modal
host needs a separate path (Β§3).
Direct listeners, not delegation. _on calls addEventListener on the element itself
(core.js:113) β one real listener per element/event, no event delegation. A data-* selector maps
to a handler by matching elements in the shadow root and attaching to each:
card._onAll("[data-action='toggle-overlay']", "change", handler).
Idempotency is what makes "re-bind every render" work β with no manual removeEventListener.
bindEvents() re-runs every render, so a naΓ―ve addEventListener would stack N listeners on any
element the render didn't replace. _on prevents that two ways (core.js:96-114):
- Elements with
datasetget a per-event marker attribute, e.g.el.dataset.evccBoundClick = "1"; a second_onfor the same element+event bails (core.js:98-101). AninnerHTMLwipe produces a fresh element with no marker, so the next bind attaches correctly. This is exactly why the DOM-wiped-every-render model is safe. - Hosts without
dataset(ShadowRoot / Document / Window) fall back to a module-levelWeakMap_boundEventsMapkeyed by host βSetof event names (core.js:40,103-112). These hosts survive render cycles, so the entry persists for their lifetime β intended.
What breaks if you bypass these helpers:
- Raw el.addEventListener(...) in a per-render bind path β duplicate listeners stack every
render (double-firing saves, N file pickers per click). The core.js:70-77 comment names this
exact failure. The only places raw addEventListener is legitimate are the body-level hosts
(Β§3), which live outside the shadow root and carry their own guards.
- Caching a DOM node across renders β you're holding a detached node. On a content render its
innerHTML was replaced; your reference points at an orphan with dead listeners that isn't in the
tree. Re-query inside handlers, never stash. See bindings/map.js:2178 ("Re-query each paint so
a mid-drag re-render doesn't strand a detached node") for the canonical fix.
- Adding a second _on for the same element+event β silently dropped (it's idempotent). The
map container multiplexes zone-draw / hide-draw / pan through one pointerdown handler
precisely because a second pointerdown bind is a no-op (bindings/map.js:2161-2166 β the
"drag does nothing" bug).
3. The body-portal modal host¶
card._modalHost is a <div class="evcc-modal-host"> created with document.createElement and
appended to document.body (main.js:1537-1540) β a portal node outside the shadow root.
Why it exists. A modal must escape the card's stacking and overflow context to sit above HA
chrome and not be clipped by the card's own overflow. A node inside the shadow root can't reliably
out-stack the dashboard; a body-level node with z-index: 9999 (styles/index.js:229, in MODAL_HOST_STYLES) can.
Why it needs its own bind path. _onAll queries this.shadowRoot (core.js:62), so it can
never match a body-portal node. The modal binder therefore queries the host directly β
host.querySelector(...) / host.querySelectorAll(...) β and attaches raw addEventListener
(bindings/index.js:152-323), bypassing _on's idempotency entirely.
How idempotency is recovered β at the swap boundary, not per-listener. _updateModalHost()
(main.js:1479-1567) rebuilds the host markup each render, then calls bindModalHostEvents(host)
only inside the if (this._modalHost.dataset.renderedHtml !== modalMarkup) branch
(main.js:1544-1565). So bindings run only after an actual innerHTML swap that recreates every
modal element (dropping old listeners). A same-markup re-render β e.g. a background battery/status
push while a modal sits open β skips the swap and the re-bind, so raw addEventListener never
stacks duplicates. The main.js:1559-1564 comment spells this out.
Lifecycle:
- Created lazily the first render any modal markup is non-empty (main.js:1537-1540).
- Torn down mid-session the moment all modal markup is empty: _updateModalHost does
this._modalHost.remove(); this._modalHost = null; (main.js:1529-1535) β the whole portal is
discarded, taking its raw listeners with it. There is no per-listener teardown; listeners die
with their nodes.
- Torn down on unmount in disconnectedCallback (main.js:1750-1758), which removes and nulls
both _modalHost and the parallel _toastHost.
Leak consideration. Because the host lives on document.body, it survives the card being pulled
from the DOM unless explicitly removed. Any new body-level host you add must be torn down in
disconnectedCallback or it orphans on <body> after card removal / dashboard nav. The ESC keydown
follows the same discipline: a single document-level listener (handler defined in the constructor,
main.js:89) anchored once in connectedCallback (main.js:1682) and removed symmetrically in
disconnect (main.js:1743) β deliberately not re-attached per modal render.
Dialog-within-modal stacking. Confirm/alert/prompt markup is concatenated last so it stacks
above the modal that spawned it (main.js:1520-1527), and it carries its own
[data-evcc-dialog] stop-propagation because the generic backdrop stop-propagation only catches the
first modal (bindings/index.js:331-343). Miss this and a dialog click leaks to the backdrop and
closes the modal beneath it.
Toast host is the parallel body-level host (main.js:1622-1657), z-index: 10000
(styles/index.js:913) to sit above the modal host's 9999. Since it's outside the shadow root, _on/_onAll can't see it either, so
its dismiss button wires with a manual dataset.evccBoundClick guard (main.js:1648-1650). Any
code touching the toast or modal hosts must replicate that manual idempotency β the shadow-root
helpers won't cover them.
4. The per-room-color trap (worked example)¶
This is the canonical "which bind path?" decision, and it shipped as a real bug.
The room editor's field handlers exist on the shadow-root path: _bindRoomEditorFields at
bindings/room-editor.js:354-377 binds [data-field] via this.card._onAll(...). But the room
editor renders into the body-level _modalHost, not the shadow root
(renderRoomEditorModal, wired from main.js:1485-1488). Because _onAll queries only the shadow
root (core.js:62), those _onAll handlers matched nothing β the room-color <input> (and its
Reset button) had no listener, so edits silently vanished.
The file itself flags the trap, verbatim at bindings/room-editor.js:374-377:
NB: the room editor is a BODY-LEVEL modal, so its fields are actually bound in
bindModalHostEvents()(bindings/index.js) viahost.querySelectorAllβ the room-color input + reset live there too. Shadow-root_onAllhere would never match the modal.
The fix binds them in the host path: the color <input> and Reset live in
bindModalHostEvents at bindings/index.js:255-272, using host.querySelectorAll(...) +
addEventListener.
Decision rule. Rendered into the shadow root β bind with card._on/_onAll. Rendered into
_modalHost (any renderXModal) β bind in bindModalHostEvents(host) with host.querySelectorAll.
Pick the wrong path and the handler is a silent no-op β no error, just dead controls.
5. Live-vs-commit (input vs change)¶
Convention on any native picker or slider: input = live, no render; change = commit + render.
Rendering on input swaps the <input> DOM node while the OS picker is still open over it,
orphaning it and losing the value.
The per-room color picker is the canonical case (bindings/index.js:252-263):
input.addEventListener("input", () => this.card._state.updateEditorField("color", input.value)); // live, NO render
input.addEventListener("change", () => { this.card._state.updateEditorField("color", input.value);
this.card._scheduleRender(); }); // commit + render
inputcaptures the pick live with no_scheduleRender()β the card also re-renders on HA state pushes, and swapping the<input>while its native picker is open drops the pick.change(picker closed) commits and re-renders, so the hex swatch + Reset button appear.
Same split elsewhere:
- Layout-name draft input is "input" with no render (bindings/map.js:816-818).
- Map-overlay checkbox commits on "change" + optimistic render (bindings/map.js:84-101).
- Theme editor (bindings/theme.js): [data-theme-token] binds both β "input" for live apply
(theme.js:538 β range sliders flood input every drag pixel; skip the backend call here) and
"change" for persistence (theme.js:595). The color inputs ([data-theme-color-input])
persist on "change" (theme.js:573) then call _scheduleDeferredRender() (theme.js:592),
the 600 ms debounce owned by render-cycle.md, so the modified-badge update
doesn't fire mid-gesture.
Inverting the split loses edits: render on input β the field's node is replaced mid-gesture β
focus lost and the in-flight value discarded.
6. The _scheduleRender trigger map (non-hass-setter)¶
Every re-render funnels through card._scheduleRender() (microtask dedup β render-cycle.md).
The hass setter's own refresh cascade (debounced service refreshes + load-once flags) is owned by
state-management.md β not restated here. Below are the triggers that fire a render
outside the hass setter.
| Trigger | Fired by | file:line |
|---|---|---|
| ResizeObserver crosses mobile/desktop boundary | _boundHandleResize cb |
main.js:120 |
animal-svg-registered document event |
_boundHandleAnimalRegistered |
main.js:96 |
End of setConfig |
setConfig |
main.js:206 |
External locale (config.i18n) loaded |
_maybeLoadExternalLocales |
main.js:380 |
| Per-user lang override loaded | _maybeLoadLangOverride |
main.js:409 |
| Runtime locale catalogs loaded | _maybeLoadLocale |
main.js:428 |
| Language menu toggle / close | language control | main.js:436 / 443 |
| User picks a language | setLanguageOverride |
main.js:457 |
| Confirmations auto-clear (registered once, fired by state) | setConfirmationsRenderTrigger |
main.js:506 |
| Last-view restored on first sync | _restoreLastView |
main.js:615 |
| View switch | setView |
main.js:665 |
Live-map camera poll tick (2000 ms setInterval) |
_scheduleLiveMapRefresh |
main.js:766 |
Live-pose poll tick (2000 ms setInterval) |
_scheduleLivePosePoll |
main.js:818 / 822 |
| Deferred theme-picker settle (600 ms debounce) | _scheduleDeferredRender |
main.js:1290 |
| Toast shown / cleared post-TTL | showToast |
main.js:1229 / 1234 |
| Card-native confirm / alert / prompt opened | _confirm / _alert / _prompt |
main.js:1254 / 1263 / 1272 |
| ESC closes dialog / modal | _handleGlobalKeydown |
main.js:1597 / 1616 |
| Toast dismiss click (body-level host) | toast host handler | main.js:1655 |
| Re-mount / panel nav before first hass | connectedCallback |
main.js:1706 |
| Animal-svg manifest import resolved | _loadAnimalSvg |
main.js:1732 |
| Panel resume (visibility / focus / pageshow / location-changed) | _handlePanelResume |
main.js:1791 |
Plus the many refresh-timer resolutions the hass cascade arms but which fire on their own timers
(start-status main.js:691, dashboard-snapshot 721, dock-action 847, pause-timeout 862,
metrics 904, learning-history 939, run-profiles 988, saved-zones 1029, incomplete-run-log
1058, trouble-rooms-log 1087, theme 1194). External call sites live throughout
src/bindings/* and src/controllers/learning-controller.js, all invoking card._scheduleRender()
on the same batched path (e.g. bindings/external-jobs.js:24,34,47,63,74).
7. Cliffs β what breaks if you touch it¶
Binding / modal-host specific. Anything about the render cycle itself is render-cycle.md.
- Bind on the wrong path (shadow vs body) β silent no-op.
_onAllonly sees the shadow root (core.js:62); anything rendered into_modalHostmust be bound inbindModalHostEvents(bindings/index.js:152). No error β just dead controls. This is the Β§4 room-color trap. - A single feature can bind in both paths.
_bindOrderis the clearest case: the clean-order controls bind shadow-side inorder.js, but the order-selector modal actions bind separately inbindModalHostEvents(bindings/index.js:178-214). Editing order bindings means touching both files β the same shadow-vs-host split as Β§4, hidden inside one feature. - Stash a DOM ref across renders β detached node. Content renders replace
innerHTML; a cached reference is an orphan with dead listeners. Re-query inside handlers (bindings/map.js:2178). - Raw
addEventListenerin a per-render bind path β duplicate handlers each render. Only the body-level hosts may use rawaddEventListener, and only because they carry their own guards (host swap-gatemain.js:1544; toastdataset.evccBoundClickmain.js:1648-1650). Everything in the shadow root goes throughcard._on/_onAll. - A second
_onfor the same element+event β dropped._onis idempotent (core.js:98-112). Multiplex through one handler (bindings/map.js:2161-2166); don't expect a second bind to land. - Body-level host not torn down β leak.
_modalHost/_toastHostlive ondocument.bodyand survive card removal.disconnectedCallback(main.js:1750-1758) must remove+null every one. Same for any document-level listener (ESC anchoredmain.js:1682, removedmain.js:1743). input-vs-changeinverted β lost edits / stolen focus. Render oninputand you swap the<input>mid-gesture (bindings/index.js:252-263).input= live/no-render,change= commit/render.- Dialog stop-propagation missing β modal closes underneath. A dialog stacked in the host needs
its own
[data-evcc-dialog]stop-propagation; the generic one catches only the first modal (bindings/index.js:331-343). - Double-click disambiguation (the 220 ms timer) is a render-cycle cliff, not a binding one β see render-cycle.md.