EidosAction (delegated event dispatcher)¶
Verified against the code · 0481ba39896fbe5e4b59257b631ffe6218e86f6e · 2026-10-20
956 D1a (2026-10-20): re-read at main 58abc0b5 (cited code unchanged at 0481ba39), 0 findings. KM YAML and compiled dev page match the code. All three canonical operations (registerAction, _dispatchAction, _hasAction) exist on EidosCore with the documented behaviours (duplicate-name-throws, blank-name-throws, non-function-throws, identity-guarded unregister, silent no-op on unknown name, submit preventDefault before handler, four delegated listeners, double-load guard). The C3 ratchet frozen at 0 is confirmed in tests/test_frontend_inline_handlers_ratchet.py.¶
Layer: frontend
Overview — one place to bind UI events¶
Before EidosAction, every interactive element in the frontend declared its own handler inline:
onclick="doThing()", onsubmit="fn(); return false;", oninput="fn(this)". Each of these
inline strings required the browser's Content Security Policy to keep 'unsafe-inline' in
script-src — the primary remaining XSS inline-execution vector. A single injected
<img onerror="..."> could exploit it as long as any inline handler existed.
EidosAction is the canonical event-binding primitive. It lives in frontend/eidos-action.js
as a layer script — loaded in index.html before app.js — and exposes three members on
the global EidosCore object:
EidosCore.registerAction(name, fn) // fn(el, event); returns { unregister() }
EidosCore._dispatchAction(name, ev, el) // private surface, for tests
EidosCore._hasAction(name) // private surface, for tests
app.js carries registerAction / _dispatchAction / _hasAction through its
_preEidosCore merge so the window.EidosCore = { … } literal does not drop them — the
_actions Map lives in eidos-action.js's closure, so a registration made before app.js runs
survives the reassignment. This is the same carry-over pattern used by eidos-modal.js
(openModal/closeModal) and eidos-modules.js (registerModule/module).
Deployed HTML carries data-action / data-change / data-submit / data-input attributes
instead of on*= strings. A single document-level delegated listener per event type dispatches
to the registered handler. With no inline on*= attributes remaining, 'unsafe-inline' can
be dropped from CSP script-src — which EP-4.5d achieves.
API¶
EidosCore.registerAction(name, fn) → { unregister() }¶
Registers fn under name for delegated dispatch.
namemust be a non-empty string; a blank name throws.fnmust be a function; a non-function throws.- A duplicate name throws — unregister the existing handler first.
- Returns a handle whose
unregister()is identity-guarded: if the name has been re-registered since, the stale handle'sunregister()does nothing (the new registration is never torn down by a stale call).
EidosCore._dispatchAction(name, event, el) (private — tests only)¶
Calls the registered handler for name with (el, event). Unknown names are a silent no-op
(spec D5: "fail-slow on dispatch, fail-fast on registration"). Exposed for test assertions only.
EidosCore._hasAction(name) → boolean (private — tests only)¶
Returns true if name is registered. Exposed for test assertions only — avoids the need to
go through _dispatchAction to check registration state.
Attribute conventions¶
Four distinct data-* attributes, one per DOM event type:
| DOM event | Old inline form | New attribute | Notes |
|---|---|---|---|
click |
onclick="fn()" |
data-action="fn" |
transparent (no preventDefault) |
change |
onchange="fn()" |
data-change="fn" |
transparent |
input |
oninput="fn(this)" |
data-input="fn" |
transparent |
submit |
onsubmit="fn(); return false;" |
data-submit="fn" |
dispatcher calls preventDefault() first |
A single element may carry multiple attributes for multiple events without ambiguity.
Handler signature¶
Every registered handler receives (el, event) — the element carrying the data-* attribute
(not the click target) and the raw DOM event:
EidosCore.registerAction('togglePanel', function (el, event) {
// el — the element with data-action="togglePanel"
// event — the click MouseEvent
});
Handlers that previously used this (e.g. onclick="fn(this)") receive el instead.
Handlers that returned false to prevent submission (e.g. onsubmit="fn(); return false;")
no longer need to — the dispatcher calls event.preventDefault() before the handler runs
for data-submit, so a handler exception cannot cause an accidental form submission.
Dispatcher behaviour¶
The delegated listener walks the event's composedPath() from the target element upward,
finding the first ancestor (inclusive) that carries the relevant data-* attribute. Only
that element's handler runs — inner handlers do not bubble past their closest registered
ancestor.
- click / change / input: transparent — no
preventDefault, nostopPropagation. Mirrors the original inlineonclick=/onchange=/oninput=behaviour exactly. - submit: the dispatcher calls
event.preventDefault()before the handler. This replaces the ubiquitousonsubmit="fn(); return false;"pattern and ensures the form cannot be accidentally submitted even if the handler throws. - Unknown name: silent no-op. An element with
data-action="typoName"where no handler namedtypoNameis registered does nothing (no exception). Fail-slow on dispatch is intentional; fail-fast happens on registration (registerActionwith a blank name or duplicate throws immediately).
The composedPath() fallback: when composedPath is absent (non-browser environments) or
returns an empty array, the dispatcher walks target.parentElement upward instead, preserving
correct behaviour in test environments.
Load order + double-load guard¶
eidos-action.js loads in index.html before app.js. When it runs:
- Creates the
_actionsMap in its IIFE's closure. - Sets
window.EidosCore.registerAction,._dispatchAction,._hasAction. - Registers the four document-level delegated listeners.
app.js carries registerAction / _dispatchAction / _hasAction from _preEidosCore
into the window.EidosCore = { … } literal, so the _actions Map closure is preserved
across the reassignment.
The double-load guard if (!EC.registerAction) makes loading idempotent: a stray second
<script> tag or a hot-reload does not re-register the delegated listeners or reset the
_actions Map. This mirrors eidos-modal.js's if (!EC.openModal) guard.
The ratchet¶
tests/test_frontend_inline_handlers_ratchet.py guards the C3 counter with a downward-only
ratchet frozen at _FROZEN = 0:
- Adding a new inline
on*=attribute anywhere in the deployed frontend lifts the count above 0 and fails the test immediately. - Restoring a migrated inline handler has the same effect.
- The ratchet can only be lowered, never raised — there are no more inline handlers to migrate.
The scanner (tools/action_inventory.py) is imported by the test rather than re-implemented
(the 4.4b lesson: count from one script, not from a second copy). Four bite tests cover every
counting-logic branch: onclick= is counted, data-action= is not, JS-comment handlers are
not, and template-literal emitted handlers are counted.
See also¶
docs/specs/eidos-action.md— the full design spec (D1–D5, API contracts, dispatcher behaviour, load-order analysis, ratchet design)frontend/eidos-modal.js— the sister primitive (modal lifecycle); same carry-over and double-load-guard pattern- EidosCore Boundary — EidosCore boundary and the C3 counter history