Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@
"react/react-compiler": "error",

"rsp-rules/no-react-key": ["error"],
"rsp-rules/add-event-non-composing": ["error"],
"rsp-rules/no-non-composing-event-listener": ["error"],
"rsp-rules/sort-imports": ["error"],
"rsp-rules/no-non-shadow-contains": ["error"],
"rsp-rules/safe-event-target": ["error"],
Expand Down Expand Up @@ -270,6 +272,8 @@
"rsp-rules/no-react-key": ["error"],
"rsp-rules/act-events-test": "error",
"rsp-rules/no-getByRole-toThrow": "error",
"rsp-rules/add-event-non-composing": "off",
"rsp-rules/no-non-composing-event-listener": "off",
"rsp-rules/no-non-shadow-contains": "off",
"rsp-rules/safe-event-target": "off",
"rsp-rules/shadow-safe-active-element": "off",
Expand Down Expand Up @@ -303,7 +307,9 @@
{
"files": ["**/dev/**", "**/scripts/**"],
"rules": {
"rsp-rules/safe-event-target": "off"
"rsp-rules/safe-event-target": "off",
"rsp-rules/add-event-non-composing": "off",
"rsp-rules/no-non-composing-event-listener": "off"
}
},
{
Expand Down
14 changes: 14 additions & 0 deletions .storybook/custom-addons/shadowDOM/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {enableShadowDOM} from 'react-stately/private/flags/flags';
import React from 'react';

// Read the URL param at module load so the one-way global flag is enabled before
// any story (or react-aria code) reads it. Toggling is handled by a page reload in
// the manager, so on each load we start from a clean state and re-enable if needed.
let params = new URLSearchParams(document.location.search);
if (params.get('shadowDOM') === 'true') {
enableShadowDOM();
}

export const withShadowDOMSwitcher = Story => {
return <Story />;
};
40 changes: 40 additions & 0 deletions .storybook/custom-addons/shadowDOM/manager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {addons, types} from 'storybook/manager-api';
import React, {useState} from 'react';

const ShadowDOMToolBar = ({api}) => {
let shadowDOMParam = api.getQueryParam('shadowDOM');
let [isShadowDOM] = useState(shadowDOMParam === 'true');
let onChange = () => {
let params = new URLSearchParams(window.location.search);
params.set('shadowDOM', String(!isShadowDOM));
// The enableShadowDOM flag is global and can only be set True, so reload the page to
// sync it and so that false can be set.
window.location.search = params.toString();
};

return (
<div style={{display: 'flex', alignItems: 'center', fontSize: '12px'}}>
<div style={{marginRight: '10px'}}>
<label htmlFor="shadowDOM">
ShadowDOM:
<input
type="checkbox"
id="shadowDOM"
name="shadowDOM"
checked={isShadowDOM}
onChange={onChange}
/>
</label>
</div>
</div>
);
};

addons.register('ShadowDOMSwitcher', api => {
addons.add('ShadowDOMSwitcher', {
title: 'Shadow DOM switcher',
type: types.TOOL,
match: ({viewMode}) => !!(viewMode && viewMode.match(/^(story|docs)$/)),
render: () => <ShadowDOMToolBar api={api} />
});
});
3 changes: 2 additions & 1 deletion .storybook/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export default {
localAddon('./custom-addons/descriptions'),
localAddon('./custom-addons/theme'),
localAddon('./custom-addons/strictmode'),
localAddon('./custom-addons/scrolling')
localAddon('./custom-addons/scrolling'),
localAddon('./custom-addons/shadowDOM')
],

typescript: {
Expand Down
2 changes: 2 additions & 0 deletions .storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {DARK_MODE_EVENT_NAME} from '@vueless/storybook-dark-mode';
import React from 'react';
import {withProviderSwitcher} from './custom-addons/provider';
import {withScrollingSwitcher} from './custom-addons/scrolling';
import {withShadowDOMSwitcher} from './custom-addons/shadowDOM';
import {withStrictModeSwitcher} from './custom-addons/strictmode';

// decorator order matters, the last one will be the outer most
Expand Down Expand Up @@ -72,6 +73,7 @@ export const parameters = {

export const decorators = [
withScrollingSwitcher,
withShadowDOMSwitcher,
...(process.env.NODE_ENV !== 'production' ? [withStrictModeSwitcher] : []),
withProviderSwitcher
];
12 changes: 7 additions & 5 deletions packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
* governing permissions and limitations under the License.
*/

import {getEventTarget, nodeContains} from 'react-aria/private/utils/shadowdom/DOMFunctions';
import {addEvent} from 'react-aria/private/utils/domHelpers';
import {
getEventTarget,
getPropagationTargets,
nodeContains
} from 'react-aria/private/utils/shadowdom/DOMFunctions';
import {RefObject} from '@react-types/shared';
import {useEffect} from 'react';

Expand Down Expand Up @@ -63,9 +68,6 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void {
}
};

window.addEventListener('scroll', onScroll, true);
return () => {
window.removeEventListener('scroll', onScroll, true);
};
return addEvent(getPropagationTargets(triggerRef.current), 'scroll', onScroll, true);
}, [isOpen, onClose, triggerRef]);
}
4 changes: 4 additions & 0 deletions packages/@react-spectrum/ai/src/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat(
// TODO: will need some kind of api to programatically set the focused item to
// the newest item in the gridlist in the virtualizer case. this works for
// non-virtualized for now though
// 'scrollend' does not compose across shadow DOM boundaries, but this listener is intentionally
// scoped to this specific scroll container element (not a global target), so shadow root
// propagation does not apply here.
// oxlint-disable-next-line rsp-rules/no-non-composing-event-listener
el.addEventListener(
'scrollend',
() => {
Expand Down
14 changes: 14 additions & 0 deletions packages/@react-types/shared/src/events.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@
import {FocusableElement} from './dom';
import {FocusEvent, MouseEvent, KeyboardEvent as ReactKeyboardEvent, SyntheticEvent} from 'react';

// Type helper to extract the target element type from an event
export type EventTargetType<T> = T extends SyntheticEvent<infer E, any> ? E : EventTarget;

// Type helper to extract the event map from a target
export type EventMapType<T extends EventTarget> = T extends Window
? WindowEventMap
: T extends Document
? DocumentEventMap
: T extends Element
? HTMLElementEventMap
: T extends VisualViewport
? VisualViewportEventMap
: GlobalEventHandlersEventMap;

// Event bubbling can be problematic in real-world applications, so the default for React Spectrum components
// is not to propagate. This can be overridden by calling continuePropagation() on the event.
export type BaseEvent<T extends SyntheticEvent> = T & {
Expand Down
4 changes: 4 additions & 0 deletions packages/dev/eslint-plugin-rsp-rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
*/

import actEventsTest from './rules/act-events-test.js';
import addEventNonComposing from './rules/add-event-non-composing.js';
import fasterNodeContains from './rules/faster-node-contains.js';
import imports from './rules/imports.js';
import noGetByRoleToThrow from './rules/no-getByRole-toThrow.js';
import noNonComposingEventListener from './rules/no-non-composing-event-listener.js';
import noNonShadowContains from './rules/no-non-shadow-contains.js';
import noPackageRootImports from './rules/no-package-root-imports.js';
import noReactKey from './rules/no-react-key.js';
Expand All @@ -25,7 +27,9 @@ import useLayoutEffectRule from './rules/use-layout-effect-rule.js';

const rules = {
'act-events-test': actEventsTest,
'add-event-non-composing': addEventNonComposing,
'no-getByRole-toThrow': noGetByRoleToThrow,
'no-non-composing-event-listener': noNonComposingEventListener,
'no-package-root-imports': noPackageRootImports,
'no-react-key': noReactKey,
'sort-imports': sortImports,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* Copyright 2025 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.
*/

// Events that do not compose across shadow DOM boundaries. A listener attached only to a global
// target (e.g. window/document) will not observe these events when they are fired inside a shadow
// root, so getPropagationTargets must be used to also attach listeners to the relevant shadow roots.
const NON_COMPOSING_EVENTS = new Set([
'scroll',
'scrollend',
'change',
'submit',
'reset',
'select',
'selectstart',
'slotchange'
]);

const plugin = {
meta: {
type: 'problem',
docs: {
description:
'Disallow calling addEvent with a non-composing event unless the target is getPropagationTargets(...), since these events do not cross shadow DOM boundaries',
recommended: true
},
schema: [],
messages: {
nonComposing:
"The '{{event}}' event does not compose across shadow DOM boundaries. Pass getPropagationTargets(...) as the target to addEvent so listeners are attached to the relevant shadow roots too."
}
},
create: context => {
return {
CallExpression(node) {
// Match a call to a function named `addEvent`.
if (node.callee.type !== 'Identifier' || node.callee.name !== 'addEvent') {
return;
}

// Second argument is the event type. Only statically-known string literals can be checked.
const eventArg = node.arguments[1];
if (
!eventArg ||
eventArg.type !== 'Literal' ||
typeof eventArg.value !== 'string' ||
!NON_COMPOSING_EVENTS.has(eventArg.value)
) {
return;
}

// First argument is the target. It's fine if it is a getPropagationTargets(...) call.
let targetArg = node.arguments[0];
if (targetArg && targetArg.type === 'ChainExpression') {
targetArg = targetArg.expression;
}
if (
targetArg &&
targetArg.type === 'CallExpression' &&
targetArg.callee.type === 'Identifier' &&
targetArg.callee.name === 'getPropagationTargets'
) {
return;
}

context.report({
node,
messageId: 'nonComposing',
data: {event: eventArg.value}
});
}
};
}
};

export default plugin;
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* Copyright 2025 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.
*/

// Events that do not compose across shadow DOM boundaries. A listener attached with
// addEventListener will not observe these events when they are fired inside a shadow root the
// listener's target does not contain, so addEvent(getPropagationTargets(...)) should be used instead
// to also attach listeners to the relevant shadow roots.
const NON_COMPOSING_EVENTS = new Set([
'scroll',
'scrollend',
'change',
'submit',
'reset',
'select',
'selectstart',
'slotchange'
]);

// Receivers that are not part of the shadow DOM tree, so getPropagationTargets does not apply to them
// (e.g. visualViewport, or a MediaQueryList returned from matchMedia). Matched by common local names.
const EXEMPT_RECEIVER_NAMES = new Set(['visualViewport', 'mq', 'm']);

const plugin = {
meta: {
type: 'problem',
docs: {
description:
'Disallow addEventListener with a non-composing event, since these events do not cross shadow DOM boundaries; use addEvent(getPropagationTargets(...)) instead',
recommended: true
},
schema: [],
messages: {
nonComposing:
"The '{{event}}' event does not compose across shadow DOM boundaries. Use addEvent(getPropagationTargets(...)) from @react-aria/utils instead of addEventListener so listeners are attached to the relevant shadow roots too."
}
},
create: context => {
return {
CallExpression(node) {
// Match `<receiver>.addEventListener(...)`.
const callee = node.callee;
if (
callee.type !== 'MemberExpression' ||
callee.computed ||
callee.property.type !== 'Identifier' ||
callee.property.name !== 'addEventListener'
) {
return;
}

// First argument is the event type. Only statically-known string literals can be checked.
const eventArg = node.arguments[0];
if (
!eventArg ||
eventArg.type !== 'Literal' ||
typeof eventArg.value !== 'string' ||
!NON_COMPOSING_EVENTS.has(eventArg.value)
) {
return;
}

// Exempt receivers that are not part of the shadow DOM tree (visualViewport, MediaQueryList).
let receiver = callee.object;
if (receiver.type === 'ChainExpression') {
receiver = receiver.expression;
}
if (receiver.type === 'Identifier' && EXEMPT_RECEIVER_NAMES.has(receiver.name)) {
return;
}

context.report({
node,
messageId: 'nonComposing',
data: {event: eventArg.value}
});
}
};
}
};

export default plugin;
Loading
Loading