Skip to content

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.

  • name must be a non-empty string; a blank name throws.
  • fn must 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's unregister() 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, no stopPropagation. Mirrors the original inline onclick= / onchange= / oninput= behaviour exactly.
  • submit: the dispatcher calls event.preventDefault() before the handler. This replaces the ubiquitous onsubmit="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 named typoName is registered does nothing (no exception). Fail-slow on dispatch is intentional; fail-fast happens on registration (registerAction with 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:

  1. Creates the _actions Map in its IIFE's closure.
  2. Sets window.EidosCore.registerAction, ._dispatchAction, ._hasAction.
  3. 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