fix(linter/jsx-a11y/mouse-events-have-key-events): handle nullish event handlers (#27306)

`mouse-events-have-key-events` reports absent hover handlers such as
`onMouseOver={undefined}` and misses active hover handlers paired with
`onFocus={null}`. The same issue affects hover-out/blur and configured
handler lists.

Use one local predicate to skip nullish hover values, reject nullish
keyboard values, and continue to a later configured active hover
handler. Preserve existing component, spread and bare-attribute
behavior. Bare attributes retain Oxc's prior behavior, which differs
from [the upstream
rule](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.10.2/src/rules/mouse-events-have-key-events.js).

Validation:
- The new regression test demonstrated five false-positive cases and
four false-negative cases before the fix.
- Both focused rule tests pass, with 22 new regression/control cases
including default/configured handlers, ordering, dynamic functions,
components and bare attributes.
- Independent AI code review found no blocking issues; suggested
bare-attribute coverage was added.
- Full post-commit `just ready` passed: 98 test targets, 5,061 passed /
161 ignored / 0 failed; formatting, code generation, checks, Clippy and
documentation completed with a clean worktree.

Fixes #27305

AI-assisted with Codex.

---------

Co-authored-by: Pybsama <294532706+Pybsama@users.noreply.github.com>
Co-authored-by: Cameron Clark <cameron.clark@hey.com>
This commit is contained in:
authored and GitHub committed 2026-10-04 09:18:20 +00:00
1 parent 4e09837d57
commit 7d381c322f
2 files changed
+124 -25

No files matched your search

@@ -1,4 +1,7 @@
use oxc_ast::{AstKind, ast::JSXAttributeValue};
use oxc_ast::{
AstKind,
ast::{Expression, JSXAttributeItem, JSXAttributeValue},
};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};
@@ -96,22 +99,12 @@ impl Rule for MouseEventsHaveKeyEvents {
for handler in &self.0.hover_in_handlers {
if let Some(jsx_attr) = has_jsx_prop(jsx_opening_el, handler) {
if get_prop_value(jsx_attr).is_none() {
if !has_handler_value(jsx_attr) {
continue;
}
match has_jsx_prop(jsx_opening_el, "onFocus").and_then(get_prop_value) {
Some(JSXAttributeValue::ExpressionContainer(container)) => {
if let Some(expr) = container.expression.as_expression()
&& expr.is_undefined()
{
ctx.diagnostic(miss_on_focus(jsx_attr.span(), handler));
}
}
None => {
ctx.diagnostic(miss_on_focus(jsx_attr.span(), handler));
}
_ => {}
if !has_jsx_prop(jsx_opening_el, "onFocus").is_some_and(has_handler_value) {
ctx.diagnostic(miss_on_focus(jsx_attr.span(), handler));
}
break;
@@ -120,20 +113,12 @@ impl Rule for MouseEventsHaveKeyEvents {
for handler in &self.0.hover_out_handlers {
if let Some(jsx_attr) = has_jsx_prop(jsx_opening_el, handler) {
if get_prop_value(jsx_attr).is_none() {
if !has_handler_value(jsx_attr) {
continue;
}
match has_jsx_prop(jsx_opening_el, "onBlur").and_then(get_prop_value) {
Some(JSXAttributeValue::ExpressionContainer(container))
if container.expression.is_undefined() =>
{
ctx.diagnostic(miss_on_blur(jsx_attr.span(), handler));
}
None => {
ctx.diagnostic(miss_on_blur(jsx_attr.span(), handler));
}
_ => {}
if !has_jsx_prop(jsx_opening_el, "onBlur").is_some_and(has_handler_value) {
ctx.diagnostic(miss_on_blur(jsx_attr.span(), handler));
}
break;
@@ -142,10 +127,29 @@ impl Rule for MouseEventsHaveKeyEvents {
}
}
fn has_handler_value(attribute: &JSXAttributeItem<'_>) -> bool {
match get_prop_value(attribute) {
Some(JSXAttributeValue::ExpressionContainer(container)) => {
let Some(expression) = container.expression.as_expression() else {
return true;
};
let expression = expression.get_inner_expression();
!expression.is_undefined() && !matches!(expression, Expression::NullLiteral(_))
}
Some(_) => true,
None => false,
}
}
#[test]
fn test() {
use crate::tester::Tester;
let configured = Some(serde_json::json!([{
"hoverInHandlers": ["onMouseOver", "onMouseEnter"],
"hoverOutHandlers": ["onMouseOut", "onMouseLeave"]
}]));
let pass = vec![
("<div onMouseOver={() => void 0} onFocus={() => void 0} />;", None),
("<div onMouseOver={() => void 0} onFocus={() => void 0} {...props} />;", None),
@@ -197,6 +201,21 @@ fn test() {
"<div onMouseLeave={() => {}} />",
Some(serde_json::json!([{ "hoverOutHandlers": ["onPointerLeave"] }])),
),
("<div onMouseOver={undefined} />", None),
("<div onMouseOut={undefined} />", None),
("<div onMouseOver={null} />", None),
("<div onMouseOut={null} />", None),
("<div onMouseOver={undefined} onMouseOut={null} />", None),
("<div onMouseOver />", None),
("<div onMouseOut />", None),
("<div onMouseOver={handler} onFocus={handler} />", None),
("<div onMouseOut={handler} onBlur={handler} />", None),
("<div onMouseOver={null} onMouseEnter={handler} onFocus={handler} />", configured.clone()),
(
"<div onMouseOut={undefined} onMouseLeave={handler} onBlur={handler} />",
configured.clone(),
),
("<Custom onMouseOver={handler} onFocus={null} />", None),
];
let fail = vec![
@@ -234,6 +253,16 @@ fn test() {
"<div onPointerLeave={() => {}} />",
Some(serde_json::json!([{ "hoverOutHandlers": ["onPointerLeave"] }])),
),
("<div onMouseOver={handler} onFocus={null} />", None),
("<div onMouseOut={handler} onBlur={null} />", None),
("<div onMouseOver={handler} onFocus={undefined} />", None),
("<div onMouseOut={handler} onBlur={undefined} />", None),
("<div onMouseOver={handler} onFocus />", None),
("<div onMouseOut={handler} onBlur />", None),
("<div onMouseOver={null} onMouseEnter={handler} />", configured.clone()),
("<div onMouseOut={undefined} onMouseLeave={handler} />", configured.clone()),
("<div onMouseOver={null} onMouseEnter={handler} onFocus={null} />", configured.clone()),
("<div onMouseOut={undefined} onMouseLeave={handler} onBlur={null} />", configured),
];
Tester::new(MouseEventsHaveKeyEvents::NAME, MouseEventsHaveKeyEvents::PLUGIN, pass, fail)
@@ -99,3 +99,73 @@ source: crates/oxc_linter/src/tester.rs
· ─────────────────────────
╰────
help: Try to add `onBlur`.
⚠ jsx-a11y(mouse-events-have-key-events): `onMouseOver` must be accompanied by `onFocus` for accessibility.
╭─[mouse_events_have_key_events.tsx:1:6]
1 │ <div onMouseOver={handler} onFocus={null} />
· ─────────────────────
╰────
help: Try to add `onFocus`.
⚠ jsx-a11y(mouse-events-have-key-events): `onMouseOut` must be accompanied by `onBlur` for accessibility.
╭─[mouse_events_have_key_events.tsx:1:6]
1 │ <div onMouseOut={handler} onBlur={null} />
· ────────────────────
╰────
help: Try to add `onBlur`.
⚠ jsx-a11y(mouse-events-have-key-events): `onMouseOver` must be accompanied by `onFocus` for accessibility.
╭─[mouse_events_have_key_events.tsx:1:6]
1 │ <div onMouseOver={handler} onFocus={undefined} />
· ─────────────────────
╰────
help: Try to add `onFocus`.
⚠ jsx-a11y(mouse-events-have-key-events): `onMouseOut` must be accompanied by `onBlur` for accessibility.
╭─[mouse_events_have_key_events.tsx:1:6]
1 │ <div onMouseOut={handler} onBlur={undefined} />
· ────────────────────
╰────
help: Try to add `onBlur`.
⚠ jsx-a11y(mouse-events-have-key-events): `onMouseOver` must be accompanied by `onFocus` for accessibility.
╭─[mouse_events_have_key_events.tsx:1:6]
1 │ <div onMouseOver={handler} onFocus />
· ─────────────────────
╰────
help: Try to add `onFocus`.
⚠ jsx-a11y(mouse-events-have-key-events): `onMouseOut` must be accompanied by `onBlur` for accessibility.
╭─[mouse_events_have_key_events.tsx:1:6]
1 │ <div onMouseOut={handler} onBlur />
· ────────────────────
╰────
help: Try to add `onBlur`.
⚠ jsx-a11y(mouse-events-have-key-events): `onMouseEnter` must be accompanied by `onFocus` for accessibility.
╭─[mouse_events_have_key_events.tsx:1:25]
1 │ <div onMouseOver={null} onMouseEnter={handler} />
· ──────────────────────
╰────
help: Try to add `onFocus`.
⚠ jsx-a11y(mouse-events-have-key-events): `onMouseLeave` must be accompanied by `onBlur` for accessibility.
╭─[mouse_events_have_key_events.tsx:1:29]
1 │ <div onMouseOut={undefined} onMouseLeave={handler} />
· ──────────────────────
╰────
help: Try to add `onBlur`.
⚠ jsx-a11y(mouse-events-have-key-events): `onMouseEnter` must be accompanied by `onFocus` for accessibility.
╭─[mouse_events_have_key_events.tsx:1:25]
1 │ <div onMouseOver={null} onMouseEnter={handler} onFocus={null} />
· ──────────────────────
╰────
help: Try to add `onFocus`.
⚠ jsx-a11y(mouse-events-have-key-events): `onMouseLeave` must be accompanied by `onBlur` for accessibility.
╭─[mouse_events_have_key_events.tsx:1:29]
1 │ <div onMouseOut={undefined} onMouseLeave={handler} onBlur={null} />
· ──────────────────────
╰────
help: Try to add `onBlur`.