From d36342d00377888030790b10f52117f77aee2c7f Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Mon, 16 Feb 2026 13:03:53 +1100 Subject: [PATCH 1/2] fix: global focus event listeners --- eslint.config.mjs | 2 + .../@react-aria/interactions/src/utils.ts | 54 ++++++++++----- packages/dev/eslint-plugin-rsp-rules/index.js | 2 + .../rules/safe-root-focus-listener.js | 64 +++++++++++++++++ .../safe-root-focus-listener.test-lint.js | 68 +++++++++++++++++++ 5 files changed, 172 insertions(+), 18 deletions(-) create mode 100644 packages/dev/eslint-plugin-rsp-rules/rules/safe-root-focus-listener.js create mode 100644 packages/dev/eslint-plugin-rsp-rules/test/safe-root-focus-listener.test-lint.js diff --git a/eslint.config.mjs b/eslint.config.mjs index d7c27a8dbee..1c461123186 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -253,6 +253,7 @@ export default [{ "rsp-rules/safe-event-target": [ERROR], "rsp-rules/shadow-safe-active-element": [ERROR], "rsp-rules/faster-node-contains": [ERROR], + "rsp-rules/safe-root-focus-listener": [ERROR], "rulesdir/imports": [ERROR], "rulesdir/useLayoutEffectRule": [ERROR], "rulesdir/pure-render": [ERROR], @@ -436,6 +437,7 @@ export default [{ "rsp-rules/safe-event-target": OFF, "rsp-rules/shadow-safe-active-element": OFF, "rsp-rules/faster-node-contains": OFF, + "rsp-rules/safe-root-focus-listener": OFF, "rulesdir/imports": OFF, "monorepo/no-internal-import": OFF, "jsdoc/require-jsdoc": OFF diff --git a/packages/@react-aria/interactions/src/utils.ts b/packages/@react-aria/interactions/src/utils.ts index 10eeca42bf5..c82a61122a8 100644 --- a/packages/@react-aria/interactions/src/utils.ts +++ b/packages/@react-aria/interactions/src/utils.ts @@ -11,7 +11,7 @@ */ import {FocusableElement} from '@react-types/shared'; -import {focusWithoutScrolling, getActiveElement, getEventTarget, getOwnerWindow, isFocusable, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, getActiveElement, getEventTarget, getOwnerWindow, isFocusable, isShadowRoot, nodeContains, useLayoutEffect} from '@react-aria/utils'; import {FocusEvent as ReactFocusEvent, SyntheticEvent, useCallback, useRef} from 'react'; // Turn a native event into a React synthetic event. @@ -110,21 +110,39 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un } let window = getOwnerWindow(target); - let activeElement = window.document.activeElement as FocusableElement | null; + let activeElement = getActiveElement(window.document) as FocusableElement | null; if (!activeElement || activeElement === target) { return; } + // Listen on the target's root (document or shadow root) so we catch focus events inside + // shadow DOM; they do not reach the main window. + let targetRoot = target?.getRootNode(); + let root = + (targetRoot != null && isShadowRoot(targetRoot)) + ? targetRoot + : getOwnerWindow(target); + + // Focus is "moving to target" when it moves to the button or to a descendant of the button + // (e.g. SVG icon) + let isFocusMovingToTarget = (focusTarget: Element | null) => + focusTarget === target || (focusTarget != null && nodeContains(target, focusTarget)); + // Blur/focusout events have their target as the element losing focus. Stop propagation when + // that is the previously focused element (activeElement) or a descendant (e.g. in shadow DOM). + let isBlurFromActiveElement = (eventTarget: Element | null) => + eventTarget === activeElement || + (activeElement != null && eventTarget != null && nodeContains(activeElement, eventTarget)); + ignoreFocusEvent = true; let isRefocusing = false; - let onBlur = (e: FocusEvent) => { - if (getEventTarget(e) === activeElement || isRefocusing) { + let onBlur: EventListener = (e) => { + if (isBlurFromActiveElement(getEventTarget(e) as Element) || isRefocusing) { e.stopImmediatePropagation(); } }; - let onFocusOut = (e: FocusEvent) => { - if (getEventTarget(e) === activeElement || isRefocusing) { + let onFocusOut: EventListener = (e) => { + if (isBlurFromActiveElement(getEventTarget(e) as Element) || isRefocusing) { e.stopImmediatePropagation(); // If there was no focusable ancestor, we don't expect a focus event. @@ -137,14 +155,14 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un } }; - let onFocus = (e: FocusEvent) => { - if (getEventTarget(e) === target || isRefocusing) { + let onFocus: EventListener = (e) => { + if (isFocusMovingToTarget(getEventTarget(e) as Element) || isRefocusing) { e.stopImmediatePropagation(); } }; - let onFocusIn = (e: FocusEvent) => { - if (getEventTarget(e) === target || isRefocusing) { + let onFocusIn: EventListener = (e) => { + if (isFocusMovingToTarget(getEventTarget(e) as Element) || isRefocusing) { e.stopImmediatePropagation(); if (!isRefocusing) { @@ -155,17 +173,17 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un } }; - window.addEventListener('blur', onBlur, true); - window.addEventListener('focusout', onFocusOut, true); - window.addEventListener('focusin', onFocusIn, true); - window.addEventListener('focus', onFocus, true); + root.addEventListener('blur', onBlur, true); + root.addEventListener('focusout', onFocusOut, true); + root.addEventListener('focusin', onFocusIn, true); + root.addEventListener('focus', onFocus, true); let cleanup = () => { cancelAnimationFrame(raf); - window.removeEventListener('blur', onBlur, true); - window.removeEventListener('focusout', onFocusOut, true); - window.removeEventListener('focusin', onFocusIn, true); - window.removeEventListener('focus', onFocus, true); + root.removeEventListener('blur', onBlur, true); + root.removeEventListener('focusout', onFocusOut, true); + root.removeEventListener('focusin', onFocusIn, true); + root.removeEventListener('focus', onFocus, true); ignoreFocusEvent = false; isRefocusing = false; }; diff --git a/packages/dev/eslint-plugin-rsp-rules/index.js b/packages/dev/eslint-plugin-rsp-rules/index.js index e3f40b7b70c..5eab6208106 100644 --- a/packages/dev/eslint-plugin-rsp-rules/index.js +++ b/packages/dev/eslint-plugin-rsp-rules/index.js @@ -16,6 +16,7 @@ import noGetByRoleToThrow from './rules/no-getByRole-toThrow.js'; import noNonShadowContains from './rules/no-non-shadow-contains.js'; import noReactKey from './rules/no-react-key.js'; import safeEventTarget from './rules/safe-event-target.js'; +import safeRootFocusListener from './rules/safe-root-focus-listener.js'; import shadowSafeActiveElement from './rules/shadow-safe-active-element.js'; import sortImports from './rules/sort-imports.js'; @@ -26,6 +27,7 @@ const rules = { 'sort-imports': sortImports, 'no-non-shadow-contains': noNonShadowContains, 'safe-event-target': safeEventTarget, + 'safe-root-focus-listener': safeRootFocusListener, 'shadow-safe-active-element': shadowSafeActiveElement, 'faster-node-contains': fasterNodeContains }; diff --git a/packages/dev/eslint-plugin-rsp-rules/rules/safe-root-focus-listener.js b/packages/dev/eslint-plugin-rsp-rules/rules/safe-root-focus-listener.js new file mode 100644 index 00000000000..1fede62ca16 --- /dev/null +++ b/packages/dev/eslint-plugin-rsp-rules/rules/safe-root-focus-listener.js @@ -0,0 +1,64 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const FOCUS_EVENT_NAMES = new Set(['blur', 'focus', 'focusin', 'focusout']); +const DISALLOWED_TARGETS = new Set(['window', 'document']); + +const plugin = { + meta: { + type: 'problem', + docs: { + description: 'Disallow attaching focus-related listeners (blur, focus, focusin, focusout) to the window or document object. This will not work in shadow DOM as focus and blur do not bubble past the shadow boundary.', + recommended: true + }, + schema: [], + messages: { + noRootFocusListener: 'Do not attach focus listeners (blur, focus, focusin, focusout) to window or document. Use a root element instead for shadow DOM compatibility.' + } + }, + create: (context) => { + return { + CallExpression(node) { + if (node.callee.type !== 'MemberExpression') { + return; + } + const {object, property} = node.callee; + if (object.type !== 'Identifier' || !DISALLOWED_TARGETS.has(object.name)) { + return; + } + if (property.type !== 'Identifier') { + return; + } + const method = property.name; + if (method !== 'addEventListener' && method !== 'removeEventListener') { + return; + } + if (node.arguments.length === 0) { + return; + } + const eventNameArg = node.arguments[0]; + const eventName = eventNameArg.type === 'Literal' && typeof eventNameArg.value === 'string' + ? eventNameArg.value + : null; + if (eventName == null || !FOCUS_EVENT_NAMES.has(eventName)) { + return; + } + context.report({ + node, + messageId: 'noRootFocusListener' + }); + } + }; + } +}; + +export default plugin; diff --git a/packages/dev/eslint-plugin-rsp-rules/test/safe-root-focus-listener.test-lint.js b/packages/dev/eslint-plugin-rsp-rules/test/safe-root-focus-listener.test-lint.js new file mode 100644 index 00000000000..019e919656b --- /dev/null +++ b/packages/dev/eslint-plugin-rsp-rules/test/safe-root-focus-listener.test-lint.js @@ -0,0 +1,68 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {RuleTester} from 'eslint'; +import safeRootFocusListenerRule from '../rules/safe-root-focus-listener.js'; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2015, + sourceType: 'module' + } +}); + +// Throws error if the tests in ruleTester.run() do not pass +ruleTester.run( + 'safe-root-focus-listener', + safeRootFocusListenerRule, + { + // 'valid' checks cases that should pass + valid: [ + { + code: ` + root.addEventListener('blur', onBlur, true); + root.addEventListener('focusout', onFocusOut, true); + root.addEventListener('focusin', onFocusIn, true); + root.addEventListener('focus', onFocus, true); + root.removeEventListener('blur', onBlur, true); + root.removeEventListener('focusout', onFocusOut, true); + root.removeEventListener('focusin', onFocusIn, true); + root.removeEventListener('focus', onFocus, true); +` + } + ], + // 'invalid' checks cases that should not pass + invalid: [ + { + code: ` + window.addEventListener('blur', onBlur, true); + window.addEventListener('focusout', onFocusOut, true); + window.addEventListener('focusin', onFocusIn, true); + window.addEventListener('focus', onFocus, true); + window.removeEventListener('blur', onBlur, true); + window.removeEventListener('focusout', onFocusOut, true); + window.removeEventListener('focusin', onFocusIn, true); + window.removeEventListener('focus', onFocus, true); + document.addEventListener('blur', onBlur, true); + document.addEventListener('focusout', onFocusOut, true); + document.addEventListener('focusin', onFocusIn, true); + document.addEventListener('focus', onFocus, true); + document.removeEventListener('blur', onBlur, true); + document.removeEventListener('focusout', onFocusOut, true); + document.removeEventListener('focusin', onFocusIn, true); + document.removeEventListener('focus', onFocus, true); +`, + errors: 16 + } + ] + } +); From 1968fb68c38e7c7c20437d5fcc7d27db922dac16 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Mon, 16 Feb 2026 13:17:50 +1100 Subject: [PATCH 2/2] turn off this lint rule for s2-docs --- eslint.config.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index 1c461123186..372f70e3663 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -527,6 +527,12 @@ export default [{ rules: { "react/react-in-jsx-scope": OFF, }, +}, { + files: ["packages/dev/s2-docs/**"], + + rules: { + "rsp-rules/safe-root-focus-listener": OFF, + }, }, { files: ["packages/dev/style-macro-chrome-plugin/**"], languageOptions: {