Package Exports
- @cision/eslint-plugin-react-custom-element-events
- @cision/eslint-plugin-react-custom-element-events/src/index.js
This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (@cision/eslint-plugin-react-custom-element-events) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.
Readme
eslint-plugin-react-custom-element-events
- ESLint rules that catch custom element event binding mistakes in React 19+ (auto-fixable).
- A test suite that illustrates how React 19 routes event handler props on custom elements through three different code paths.
Quick Start
npm install --save-dev @cision/eslint-plugin-react-custom-element-eventseslint.config.js (flat config):
const customElementEvents = require('@cision/eslint-plugin-react-custom-element-events');
module.exports = [
{
plugins: {
'cision-react-custom-element-events': customElementEvents,
},
rules: {
'cision-react-custom-element-events/no-undercased-react-event-on-custom-element': 'error',
'cision-react-custom-element-events/no-suspicious-camelcase-event-on-custom-element': 'warn',
'cision-react-custom-element-events/no-camelcase-nondelegated-event-on-custom-element': 'error',
},
},
];| Rule | Severity | Fix | What it catches |
|---|---|---|---|
cision-react-custom-element-events/no-undercased-react-event-on-custom-element |
error |
autofix | ondrag → onDrag (known React event in wrong case) |
cision-react-custom-element-events/no-suspicious-camelcase-event-on-custom-element |
warn |
suggestion | onMyCamelCasedEvent → onmyCamelCasedEvent (likely wrong casing) |
cision-react-custom-element-events/no-camelcase-nondelegated-event-on-custom-element |
error |
autofix | onClose → onclose (non-delegated event that React never wires on custom elements) |
Versioning & event name list
The rules read from two generated data files:
src/data/reactEventNames.json— 180 mappings of all known React synthetic events (e.g."ondrag"→"onDrag")src/data/nonDelegatedEventNames.json— 32 mappings of non-delegated events that must stay lowercase on custom elements (e.g."onclose"→"onClose")
Both are generated by parsing the installed react-dom development bundle (npm run generate).
Automatic publishing: A scheduled GitHub Action checks for new React releases daily. When the event registry changes it regenerates the data files, runs tests, bumps to a new minor version, and publishes automatically.
Manual publishing: Rule logic fixes are released manually. Push a version tag (e.g. v1.2.0) and the publish workflow runs tests and publishes to npm.
Requires react-dom >=19.0.0 (declared in peerDependencies).
Background
React 19 treats event props on custom elements (tags with a - in the name) differently depending on whether the prop name is known to React's synthetic event system. The dispatching logic lives in setPropOnCustomElement.
There are three code paths:
Path 1: Explicitly special-cased props (onClick, onScroll, onScrollEnd)
A handful of prop names have hard-coded case branches in setPropOnCustomElement with event-specific wiring (e.g. onClick gets a Mobile Safari workaround, onScroll/onScrollEnd get non-delegated listeners).
Path 2: Known React events (onDrag, onChange, onFocus, etc.)
Prop names that exist in React's registrationNameDependencies registry are recognized by a hasOwnProperty check and handled through React's synthetic event system. The registry is populated at startup from DOMEventProperties.js and its plugin files — this is the dynamic/derived set. Your handler receives a SyntheticEvent.
The lookup is case-sensitive. The registry key is "onDrag" (camelCase), so ondrag (all lowercase) misses the check and falls through to path 3 below. The handler still fires, but it now participates in a completely different dispatch system: stopPropagation() interacts with native DOM bubbling rather than React's synthetic propagation, meaning it can silently break (or fail to break) other React handlers in the tree. The event also bypasses React's priority scheduling (discrete vs. continuous), which can affect Suspense and hydration behavior. The same applies to onclick, onchange, etc.
The non-delegated trap (onClose, onCancel, onToggle, onLoad, …)
Some path 2 events are in React's nonDelegatedEvents set: close, cancel, toggle, beforetoggle, load, error, scroll, scrollend, invalid, and all media events (play, pause, ended, etc.). For these, React does not attach a delegated bubble-phase listener at the root. Instead, for specific native elements (e.g. <dialog>, <video>, <details>), React calls listenToNonDelegatedEvent() directly on the element.
This wiring is never done for custom elements. So while onClose is in the registry and looks like it should work, it silently does nothing:
<dialog onClose={fn}>→ works (React wires a non-delegated listener on the<dialog>)<my-dialog onClose={fn}>→ never fires (no wiring for custom elements)<my-dialog onclose={fn}>→ works — falls through to path 3 (directaddEventListener)
This is the opposite of delegated events like onDrag — for non-delegated events on custom elements, the lowercase form is the correct one.
Path 3: Unknown props → direct addEventListener (oncustomevent)
Everything else falls through to setValueForPropertyOnCustomComponent, which strips the on prefix and passes the remainder verbatim to element.addEventListener(eventName, fn). The handler receives a raw native Event.
This is the correct path for truly custom events like oncustomevent, and it's also where non-delegated events on custom elements end up when written in lowercase (onclose, oncancel, onplay, etc.).
Note that the event name is case-sensitive and not lowered — the on prefix is stripped and the rest is passed through as-is. If your custom element dispatches myCamelCasedEvent, the correct prop is onmyCamelCasedEvent (lowercase m after on). Writing onMyCamelCasedEvent would listen for MyCamelCasedEvent (capital M) and silently miss the event.
Summary
Prop on <my-el> |
What happens | Correct? |
|---|---|---|
onClick |
Path 1 — explicit special case | ✓ |
onScroll |
Path 1 — explicit special case | ✓ |
onDrag |
Path 2 — delegated synthetic event | ✓ |
ondrag |
Path 3 — direct addEventListener('drag', fn) |
✗ Use onDrag |
onClose |
Path 2 — matches registry, but never fires (non-delegated, no wiring for custom elements) | ✗ Use onclose |
onclose |
Path 3 — direct addEventListener('close', fn) |
✓ |
oncustomevent |
Path 3 — direct addEventListener('customevent', fn) |
✓ |
onMyCamelCasedEvent |
Path 3 — addEventListener('MyCamelCasedEvent', fn) (capital M) |
✗ Use onmyCamelCasedEvent |
Test Suite
The tests in src/__tests__/custom-element-events.test.jsx illustrate each path with passing assertions.
npm install
npm test| Path | Example prop | What happens |
|---|---|---|
| 1 | onClick, onScroll |
Explicit case in setPropOnCustomElement |
| 2 | onDrag, onChange |
registrationNameDependencies lookup → synthetic event system |
| 2 (anti-pattern) | ondrag |
Misses registry (case-sensitive), falls to path 3 |
| 2 (non-delegated trap) | onClose |
In registry but never wired on custom elements — silently never fires |
| 3 | oncustomevent |
Fall-through → direct addEventListener (native Event) |
| 3 | onclose |
Correct way to handle non-delegated events on custom elements |
ESLint Rules
no-undercased-react-event-on-custom-element
Flags lowercase on* props on custom elements that match a known React delegated synthetic event (e.g. ondrag, onclick, onchange). Auto-fixable via eslint --fix.
Non-delegated events (onclose, oncancel, ontoggle, etc.) are intentionally excluded — see no-camelcase-nondelegated-event-on-custom-element below.
// ✗ ERROR (auto-fixed) — ondrag → onDrag
<my-el ondrag={handler} />
<my-el onclick={handler} />
<my-el onchange={handler} />
<my-el ondoubleclick={handler} />no-suspicious-camelcase-event-on-custom-element
Warns when an unknown camelCased on* prop is used on a custom element. onMyCamelCasedEvent would listen for MyCamelCasedEvent (capital M), which is almost certainly not the intended event name. Provides a suggestion (not an autofix) to lowercase the first letter.
// ⚠ WARNING (suggestion) — onMyCamelCasedEvent → onmyCamelCasedEvent
<my-el onMyCamelCasedEvent={handler} />
<my-el onWidgetToggle={handler} />no-camelcase-nondelegated-event-on-custom-element
Flags camelCase non-delegated event props on custom elements. React never calls listenToNonDelegatedEvent() for custom elements, so onClose silently never fires. The lowercase form (onclose) is the correct usage — it routes through path 3 (direct addEventListener). Auto-fixable via eslint --fix.
Affected events: close, cancel, toggle, beforetoggle, load, error, scroll, scrollend, invalid, and all media events (play, pause, ended, timeupdate, etc.).
// ✗ ERROR (auto-fixed) — onClose → onclose
<my-el onClose={handler} /> // never fires — React has no non-delegated listener for custom elements
<my-el onCancel={handler} />
<my-el onToggle={handler} />
<my-el onLoad={handler} />
<my-el onError={handler} />
<my-el onPlay={handler} />
<my-el onEnded={handler} />
// ✓ Correct — lowercase routes through path 3 (direct addEventListener)
<my-el onclose={handler} />
<my-el oncancel={handler} />
<my-el onplay={handler} />What no rule flags
// ✓ Correct camelCase — known React delegated event, uses synthetic event system
<my-el onDrag={handler} />
<my-el onClick={handler} />
<my-el onChange={handler} />
// ✓ Non-delegated events in correct lowercase form
<my-el onclose={handler} />
<my-el oncancel={handler} />
<my-el onplay={handler} />
// ✓ Truly custom event — intentional direct listener
<my-el oncustomevent={handler} />
<my-el onslotchange={handler} />
<my-el onmyCamelCasedEvent={handler} />
// ✓ Not a custom element — out of scope
<div ondrag={handler} />
// ✓ SVG/MathML elements excluded by React's own isCustomElement() check
<annotation-xml ondrag={handler} />
<color-profile onchange={handler} />Project structure
scripts/
generate-event-names.mjs — parses installed react-dom → writes both data files
src/
data/
reactEventNames.json — generated; 180 mappings (including Capture variants)
nonDelegatedEventNames.json — generated; 32 mappings of non-delegated events
__tests__/
custom-element-events.test.jsx — behavioral tests (paths 1–3 + non-delegated)
lint-rule.test.js — RuleTester: no-undercased-react-event-on-custom-element
lint-rule-camelcase.test.js — RuleTester: no-suspicious-camelcase-event-on-custom-element
lint-rule-nondelegated.test.js — RuleTester: no-camelcase-nondelegated-event-on-custom-element
lint-rule-integration.test.js — cross-rule conflict tests
rules/
no-undercased-react-event-on-custom-element.js
no-suspicious-camelcase-event-on-custom-element.js
no-camelcase-nondelegated-event-on-custom-element.js
index.js — ESLint plugin export (all three rules)
.github/workflows/
check-react-update.yml — scheduled: auto-publish on React event registry changes
publish.yml — tag-triggered: publish on v* tags