JSPM

@cision/eslint-plugin-react-custom-element-events

1.1.2
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 131
  • Score
    100M100P100Q108887F
  • License ISC

ESLint plugin and test suite for React 19 custom element event binding

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-events

eslint.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 ondragonDrag (known React event in wrong case)
cision-react-custom-element-events/no-suspicious-camelcase-event-on-custom-element warn suggestion onMyCamelCasedEventonmyCamelCasedEvent (likely wrong casing)
cision-react-custom-element-events/no-camelcase-nondelegated-event-on-custom-element error autofix onCloseonclose (non-delegated event that React never wires on custom elements)

Versioning & event name list

The rules read from two generated data files:

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 (direct addEventListener)

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