@cision/eslint-plugin-react-custom-element-events
v1.1.11
Published
ESLint plugin and test suite for React 19 custom element event binding
Downloads
1,623
Readme
eslint-plugin-react-custom-element-events
- ESLint rules that catch custom element event binding mistakes in React 19+ (auto-fixable).
- A test suite reference for how React 19 routes different
on*props on custom elements.
Playground
Use the ESLint plugin to validate any prop/element pair, right from the browser
https://cision.github.io/eslint-plugin-react-custom-element-events
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-camelcase-nondelegated-event-on-custom-element': 'error',
'cision-react-custom-element-events/no-suspicious-camelcase-event-on-custom-element': 'warn',
},
},
];| 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-camelcase-nondelegated-event-on-custom-element | error | autofix | onClose → onclose (non-delegated event that React never wires on custom elements) |
| cision-react-custom-element-events/no-suspicious-camelcase-event-on-custom-element | warn | suggestion | onMyCamelCasedEvent → onmyCamelCasedEvent (likely wrong casing) |
Versioning & event name list
The rules read from two generated data files:
src/data/reactEventNames.json— all known React synthetic events (e.g."ondrag"→"onDrag")src/data/nonDelegatedEventNames.json— 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).
Semver policy: Patch releases are for changes that don't affect the published library (README, comments, refactors, playground changes). Minor releases are for functional changes to the library (new/updated rules, event name data updates). Major releases are for breaking API changes.
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 and non-functional updates 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).
How event props work on custom elements
React 19 handles on* props on custom elements differently depending on the prop name. Getting the casing wrong can mean the bindings either don't work at all, or work but bypass React's synthetic event system.
Why do we care if we skip React's synthetic event system?
When React recognizes a camelCase prop like onDrag, it routes the event through its synthetic event system — a single delegated listener at the root rather than one per element. Two things work correctly only inside that system:
- Batching — state updates triggered inside the handler are batched into a single render. Outside React's system, each
setStatecauses its own render. stopPropagation()— stops propagation through React's virtual tree. On a native-only listener, it only stops real DOM bubbling; React's root listener may still see the event, causing unexpected behavior between React-managed siblings.
camelCase works for standard React events
Test suite proof: must-be-camelcase.test.js · react-integration.test.jsx
The following events work with camelCase props — React routes them through its synthetic event system and your handler receives a SyntheticEvent.
Note: These only work when the event is dispatched with
bubbles: true. React listens at the root of the tree, not on the element itself, so non-bubbling events are never seen.
| Category | Props |
|---|---|
| Mouse | onClick onDoubleClick onAuxClick onContextMenu onMouseDown onMouseUp onMouseMove onMouseEnter onMouseLeave onMouseOver onMouseOut |
| Keyboard | onKeyDown onKeyUp onKeyPress |
| Focus | onFocus onBlur |
| Form / input | onChange onInput onBeforeInput onSelect onSubmit onReset |
| Drag | onDrag onDragStart onDragEnd onDragEnter onDragLeave onDragOver onDragExit onDrop |
| Pointer | onPointerDown onPointerUp onPointerMove onPointerEnter onPointerLeave onPointerOver onPointerOut onPointerCancel onGotPointerCapture onLostPointerCapture |
| Touch | onTouchStart onTouchEnd onTouchMove onTouchCancel |
| Wheel | onWheel |
| Clipboard | onCopy onCut onPaste |
| Composition | onCompositionStart onCompositionEnd onCompositionUpdate |
| Animation | onAnimationStart onAnimationEnd onAnimationIteration |
| Transition | onTransitionStart onTransitionEnd onTransitionRun onTransitionCancel |
All of the above also have a ...Capture variant (e.g. onClickCapture) for the capture phase.
<my-el onDrag={handler} /> // ✓ works
<my-el onClick={handler} /> // ✓ works
<my-el onChange={handler} /> // ✓ worksWatch out for lowercase: ondrag, onclick, onchange look plausible but miss React's registry (the lookup is case-sensitive). The handler still fires, but React treats it as a direct addEventListener — batching and stopPropagation() behave as described above.
For some events, only lowercase works
Test suite proof: must-be-lowercase.test.js · react-integration.test.jsx
The following events require lowercase props on custom elements. Despite appearing in React's event registry, the camelCase form silently never fires.
This is because React wires these directly on specific native elements like <dialog> and <video> — but never for custom elements. The lowercase form works because it bypasses React's registry and lands on a direct addEventListener on the element.
Because this path bypasses the synthetic event system, batching and stopPropagation() behave as described above — but for these events on custom elements, there is no alternative that goes through React's system.
| Category | Use these (lowercase) | Not these (camelCase — silent no-op) |
|---|---|---|
| Dialog | onclose oncancel | onClose onCancel |
| Details / popover | ontoggle onbeforetoggle | onToggle onBeforeToggle |
| Resource loading | onload onerror onabort | onLoad onError onAbort |
| Form | oninvalid | onInvalid |
| Scroll | onscroll onscrollend | onScroll onScrollEnd |
| Resize | onresize | onResize |
| Media | oncanplay oncanplaythrough ondurationchange onemptied onencrypted onended onloadeddata onloadedmetadata onloadstart onpause onplay onplaying onprogress onratechange onseeked onseeking onstalled onsuspend ontimeupdate onvolumechange onwaiting | onCanPlay onCanPlayThrough onDurationChange … |
<my-el onClose={handler} /> // ✗ never fires
<my-el onclose={handler} /> // ✓ works
<my-el onCancel={handler} /> // ✗ never fires
<my-el oncancel={handler} /> // ✓ works
<my-el onPlay={handler} /> // ✗ never fires
<my-el onplay={handler} /> // ✓ worksFor all other custom events, use on + the exact event name
Test suite proof: react-integration.test.jsx
Prop names React doesn't recognize get passed verbatim to addEventListener — the on prefix is stripped and the rest is used as-is, including case.
<my-el oncustomevent={handler} /> // ✓ listens for 'customevent'
<my-el onslotchange={handler} /> // ✓ listens for 'slotchange'
<my-el onmyWidgetToggle={handler} /> // ✓ listens for 'myWidgetToggle'
<my-el onMyWidgetToggle={handler} /> // ✗ listens for 'MyWidgetToggle' — probably wrongIf your element dispatches new CustomEvent('myWidgetToggle'), the correct prop is onmyWidgetToggle (lowercase m). Writing onMyWidgetToggle listens for MyWidgetToggle (capital M) and silently misses the event.
Like the non-delegated path above, this is a direct addEventListener — batching and stopPropagation() behave as described above.
Test Suite
| File | What it covers |
|---|---|
| react-integration.test.jsx | Behavioral: renders React + dispatches real DOM events in jsdom |
| must-be-camelcase.test.js | Rule: no-undercased-react-event-on-custom-element |
| must-be-lowercase.test.js | Rule: no-camelcase-nondelegated-event-on-custom-element |
| must-be-passthrough.test.js | Rule: no-suspicious-camelcase-event-on-custom-element |
| overlapping-cases.test.js | Cross-rule conflict guard |
| data-integrity.test.js | Structural assertions on the generated JSON data files |
npm install
npm testESLint Rules
no-undercased-react-event-on-custom-element
Rule tests: must-be-camelcase.test.js
Flags lowercase on* props that match a known React delegated event — these should be camelCase. Auto-fixable.
// ✗ ERROR (auto-fixed)
<my-el ondrag={handler} /> // → onDrag
<my-el onclick={handler} /> // → onClick
<my-el onchange={handler} /> // → onChange
<my-el ondoubleclick={handler} /> // → onDoubleClickno-camelcase-nondelegated-event-on-custom-element
Rule tests: must-be-lowercase.test.js
Flags camelCase props for events that React never wires on custom elements — these must be lowercase. Auto-fixable.
// ✗ ERROR (auto-fixed)
<my-el onClose={handler} /> // → onclose
<my-el onCancel={handler} /> // → oncancel
<my-el onPlay={handler} /> // → onplay
<my-el onLoad={handler} /> // → onloadno-suspicious-camelcase-event-on-custom-element
Rule tests: must-be-passthrough.test.js
Warns when an unknown camelCased on* prop is used on a custom element — the on prefix is stripped verbatim, so onMyEvent listens for MyEvent (capital M), which is almost certainly wrong. Provides a suggestion (not an autofix) to lowercase the first letter.
// ⚠ WARNING (suggestion)
<my-el onMyWidgetToggle={handler} /> // → onmyWidgetToggle
<my-el onWidgetClose={handler} /> // → onwidgetCloseWhat 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; all React synthetic events (including Capture variants)
nonDelegatedEventNames.json — generated; non-delegated events that must stay lowercase
__tests__/
react-integration.test.jsx — behavioral tests (paths 1–3 + non-delegated events in jsdom)
must-be-camelcase.test.js — RuleTester: no-undercased-react-event-on-custom-element
must-be-passthrough.test.js — RuleTester: no-suspicious-camelcase-event-on-custom-element
must-be-lowercase.test.js — RuleTester: no-camelcase-nondelegated-event-on-custom-element
overlapping-cases.test.js — cross-rule conflict guard
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