diff --git a/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts b/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts index 4470929976e..7aea3d11366 100644 --- a/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts +++ b/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts @@ -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, + getShadowRoots, + nodeContains +} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {RefObject} from '@react-types/shared'; import {useEffect} from 'react'; @@ -63,9 +68,11 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void { } }; - window.addEventListener('scroll', onScroll, true); + let cleanupGlobal = addEvent(window, 'scroll', onScroll, true); + let cleanupShadow = addEvent(getShadowRoots(triggerRef.current), 'scroll', onScroll, true); return () => { - window.removeEventListener('scroll', onScroll, true); + cleanupGlobal(); + cleanupShadow(); }; }, [isOpen, onClose, triggerRef]); } diff --git a/packages/@react-types/shared/src/events.d.ts b/packages/@react-types/shared/src/events.d.ts index 6b98068cd24..628ea1258b0 100644 --- a/packages/@react-types/shared/src/events.d.ts +++ b/packages/@react-types/shared/src/events.d.ts @@ -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 extends SyntheticEvent ? E : EventTarget; + +// Type helper to extract the event map from a target +export type EventMapType = 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 & { diff --git a/packages/react-aria-components/test/Select.browser.test.tsx b/packages/react-aria-components/test/Select.browser.test.tsx new file mode 100644 index 00000000000..ebde4d982d8 --- /dev/null +++ b/packages/react-aria-components/test/Select.browser.test.tsx @@ -0,0 +1,124 @@ +/* + * Copyright 2026 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. + */ + +// Regression tests for https://github.com/adobe/react-spectrum/issues/10093 +// Verifies that overlays close when a scrollable ancestor scrolls, both in +// light DOM and inside a shadow DOM (where scroll events have composed: false). +// +// Uses ComboBox which sets isNonModal: true so its Popover registers a +// window.addEventListener('scroll', ...) via useCloseOnScroll — the same +// hook that is affected by the shadow DOM scroll propagation bug. + +import {Button} from '../src/Button'; +import {ComboBox} from '../src/ComboBox'; +import {createRoot} from 'react-dom/client'; +import {enableShadowDOM} from 'react-stately/private/flags/flags'; +import {expect, it} from 'vitest'; +import {Input} from '../src/Input'; +import {Label} from '../src/Label'; +import {ListBox, ListBoxItem} from '../src/ListBox'; +import {Popover} from '../src/Popover'; +import React from 'react'; +import {User} from '@react-aria/test-utils'; + +function TestComboBox() { + return ( + + + + + + + Cat + Dog + Kangaroo + + + + ); +} + +function makeScrollableContainer() { + let scrollable = document.createElement('div'); + scrollable.style.cssText = 'height: 100px; overflow-y: auto;'; + let inner = document.createElement('div'); + inner.style.height = '500px'; + scrollable.appendChild(inner); + let mountPoint = document.createElement('div'); + inner.appendChild(mountPoint); + return {scrollable, mountPoint}; +} + +it('overlay closes when a scrollable light DOM ancestor scrolls', async () => { + let testUtilUser = new User(); + let {scrollable, mountPoint} = makeScrollableContainer(); + document.body.appendChild(scrollable); + + let root = createRoot(mountPoint); + root.render(); + await new Promise(resolve => setTimeout(resolve, 100)); + + let comboboxTester = testUtilUser.createTester('ComboBox', {root: scrollable}); + await comboboxTester.open(); + + // ComboBox listbox renders into document.body via portal. + expect(comboboxTester.getListbox()).not.toBeNull(); + + // Scroll the ancestor that contains the trigger — window capturing listener should close the overlay. + scrollable.dispatchEvent(new Event('scroll')); + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(comboboxTester.getListbox()).toBeNull(); + + root.unmount(); + document.body.removeChild(scrollable); +}); + +describe('Shadow DOM', () => { + /** + * EnableShadowDOM must be called before mounting. + * + * Cannot be turned off, so should be called after light-dom tests. + */ + enableShadowDOM(); + + it('overlay closes when a scrollable shadow DOM ancestor scrolls', async () => { + let testUtilUser = new User(); + let outerHost = document.createElement('div'); + document.body.appendChild(outerHost); + let shadowRoot = outerHost.attachShadow({mode: 'open'}); + + let {scrollable, mountPoint} = makeScrollableContainer(); + shadowRoot.appendChild(scrollable); + + let root = createRoot(mountPoint); + root.render(); + await new Promise(resolve => setTimeout(resolve, 100)); + + let comboboxTester = testUtilUser.createTester('ComboBox', {root: scrollable}); + await comboboxTester.open(); + + // Listbox renders into document.body via portal even in shadow DOM mode. + expect(comboboxTester.getListbox()).not.toBeNull(); + + // Scroll inside the shadow root. + // Without the fix, window never sees this event (composed: false). + // With the fix (getShadowRoots + addEvent), the shadow root listener closes the overlay. + scrollable.dispatchEvent(new Event('scroll')); + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(comboboxTester.getListbox()).toBeNull(); + + root.unmount(); + document.body.removeChild(outerHost); + }); +}); diff --git a/packages/react-aria-components/test/Tree.browser.test.tsx b/packages/react-aria-components/test/Tree.browser.test.tsx new file mode 100644 index 00000000000..5e5c2c49404 --- /dev/null +++ b/packages/react-aria-components/test/Tree.browser.test.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2026 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. + */ + +// Regression test for https://github.com/adobe/react-spectrum/issues/10093 + +import {createRoot} from 'react-dom/client'; +import {enableShadowDOM} from 'react-stately/private/flags/flags'; +import {expect, it} from 'vitest'; +import {ListLayout} from 'react-stately/useVirtualizerState'; +import React from 'react'; +import {Tree, TreeItem, TreeItemContent} from '../src/Tree'; +import {Virtualizer} from '../src/Virtualizer'; + +// Mirror what the reproduction does — must be set before mounting. +enableShadowDOM(); + +const ROW_HEIGHT = 30; +const CONTAINER_HEIGHT = 300; +const items = Array.from({length: 50}, (_, i) => ({id: `item-${i}`, name: `Item ${i}`})); + +function VirtualizedTree() { + return ( + + + {(item: any) => ( + + {item.name} + + )} + + + ); +} + +it('virtualizer inside shadow DOM updates visible items on scroll', async () => { + let host = document.createElement('div'); + document.body.appendChild(host); + let shadowRoot = host.attachShadow({mode: 'open'}); + let mountPoint = document.createElement('div'); + shadowRoot.appendChild(mountPoint); + + let root = createRoot(mountPoint); + root.render(); + // Wait for initial render, ResizeObserver measurement, and ScrollView's size update. + await new Promise(resolve => setTimeout(() => resolve(), 200)); + + // The scrollport is the treegrid element (Tree's outer div with overflow: auto). + // The [role="presentation"] div is the inner content container, not the scrollport. + let scrollport = shadowRoot.querySelector('[role="treegrid"]'); + expect(scrollport).not.toBeNull(); + expect(scrollport!.scrollHeight).toBeGreaterThan(CONTAINER_HEIGHT); + + let rows = shadowRoot.querySelectorAll('[role="row"]'); + expect(rows.length).toBeGreaterThan(0); + // Only a subset of items should be visible (not all 50) due to virtualization. + expect(rows.length).toBeLessThan(items.length); + expect(Array.from(rows).some(r => r.textContent?.includes('Item 0'))).toBe(true); + + // Scroll past 20 items (20 × 30px) so Item 0 is outside any extra items the layout may buffer. + scrollport!.scrollTop = ROW_HEIGHT * 20; + await new Promise(resolve => setTimeout(() => resolve(), 200)); + + let updatedRows = shadowRoot.querySelectorAll('[role="row"]'); + expect(Array.from(updatedRows).some(r => r.textContent?.includes('Item 0'))).toBe(false); + expect(Array.from(updatedRows).some(r => r.textContent?.includes('Item 20'))).toBe(true); + + root.unmount(); + document.body.removeChild(host); +}); diff --git a/packages/react-aria/exports/private/utils/domHelpers.ts b/packages/react-aria/exports/private/utils/domHelpers.ts index bc415a70891..90f31ad7626 100644 --- a/packages/react-aria/exports/private/utils/domHelpers.ts +++ b/packages/react-aria/exports/private/utils/domHelpers.ts @@ -1 +1,6 @@ -export {getOwnerDocument, getOwnerWindow, isShadowRoot} from '../../../src/utils/domHelpers'; +export { + addEvent, + getOwnerDocument, + getOwnerWindow, + isShadowRoot +} from '../../../src/utils/domHelpers'; diff --git a/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts b/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts index 8e24abab17f..41cc2c586f9 100644 --- a/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts +++ b/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts @@ -1,5 +1,6 @@ export { getEventTarget, + getShadowRoots, nodeContains, isFocusWithin, getActiveElement diff --git a/packages/react-aria/src/overlays/useCloseOnScroll.ts b/packages/react-aria/src/overlays/useCloseOnScroll.ts index 0d7e7698876..97e201d189b 100644 --- a/packages/react-aria/src/overlays/useCloseOnScroll.ts +++ b/packages/react-aria/src/overlays/useCloseOnScroll.ts @@ -10,7 +10,8 @@ * governing permissions and limitations under the License. */ -import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; +import {addEvent} from '../utils/domHelpers'; +import {getEventTarget, getShadowRoots, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {RefObject} from '@react-types/shared'; import {useEffect} from 'react'; @@ -60,9 +61,11 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void { } }; - window.addEventListener('scroll', onScroll, true); + let cleanupGlobal = addEvent(window, 'scroll', onScroll, true); + let cleanupShadow = addEvent(getShadowRoots(triggerRef.current), 'scroll', onScroll, true); return () => { - window.removeEventListener('scroll', onScroll, true); + cleanupGlobal(); + cleanupShadow(); }; }, [isOpen, onClose, triggerRef]); } diff --git a/packages/react-aria/src/utils/domHelpers.ts b/packages/react-aria/src/utils/domHelpers.ts index c0fe8198dd8..d5e69038c67 100644 --- a/packages/react-aria/src/utils/domHelpers.ts +++ b/packages/react-aria/src/utils/domHelpers.ts @@ -1,3 +1,5 @@ +import type {EventMapType} from '@react-types/shared'; + export const getOwnerDocument = (el: Element | null | undefined): Document => { return el?.ownerDocument ?? document; }; @@ -32,3 +34,29 @@ function isNode(value: unknown): value is Node { export function isShadowRoot(node: Node | null): node is ShadowRoot { return isNode(node) && node.nodeType === Node.DOCUMENT_FRAGMENT_NODE && 'host' in node; } + +/** + * Attaches an event listener on target(s) and returns a cleanup function. + */ +export function addEvent>>( + target: T | EventTarget[] | null, + event: Extract | (string & {}), + listener?: (this: T, ev: EventMapType>[K]) => any, + options?: boolean | AddEventListenerOptions +): () => void { + if (listener == null || target == null) { + return () => {}; + } + + let eventTargets = Array.isArray(target) ? target : [target]; + + for (let eventTarget of eventTargets) { + eventTarget.addEventListener(event, listener as EventListener, options); + } + + return () => { + for (let eventTarget of eventTargets) { + eventTarget.removeEventListener(event, listener as EventListener, options); + } + }; +} diff --git a/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts b/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts index 6ca0e476f5e..9b501d64462 100644 --- a/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts +++ b/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts @@ -83,6 +83,28 @@ export function getEventTarget(event: T): Even return event.target as EventTargetType; } +/** + * Collects the enclosing ShadowRoots between a node and the document. + * + * Useful for attaching listeners for events that don't compose (e.g. scroll), + * since those events do not propagate out of shadow roots even in the capture phase. + */ +export function getShadowRoots(node: Node | null | undefined): ShadowRoot[] { + if (!shadowDOM()) { + return []; + } + + let roots: ShadowRoot[] = []; + let current: Node | null = node?.getRootNode() ?? null; + + while (isShadowRoot(current)) { + roots.push(current); + current = current.host.getRootNode(); + } + + return roots; +} + /** * ShadowDOM safe fast version of node.contains(document.activeElement). * diff --git a/packages/react-aria/src/virtualizer/ScrollView.tsx b/packages/react-aria/src/virtualizer/ScrollView.tsx index db2a266b72c..7d1f9346149 100644 --- a/packages/react-aria/src/virtualizer/ScrollView.tsx +++ b/packages/react-aria/src/virtualizer/ScrollView.tsx @@ -10,9 +10,9 @@ * governing permissions and limitations under the License. */ -// @ts-ignore +import {addEvent} from '../utils/domHelpers'; import {flushSync} from 'react-dom'; -import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; +import {getEventTarget, getShadowRoots, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {getScrollLeft} from './utils'; import {Point, Rect, Size} from 'react-stately/useVirtualizerState'; import React, { @@ -218,10 +218,16 @@ export function useScrollView( ); // Attach a document-level capturing scroll listener so we can account for scrollable ancestors. + // When inside a shadow DOM, also attach to each shadow root in the ancestor chain since scroll + // events have composed: false and don't propagate out of shadow roots. useEffect(() => { - document.addEventListener('scroll', onScroll, true); - return () => document.removeEventListener('scroll', onScroll, true); - }, [onScroll]); + let cleanupGlobal = addEvent(document, 'scroll', onScroll, true); + let cleanupShadow = addEvent(getShadowRoots(ref.current), 'scroll', onScroll, true); + return () => { + cleanupGlobal(); + cleanupShadow(); + }; + }, [onScroll, ref]); useEffect(() => { return () => { diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index aeede49a7a6..3dfb572a81e 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -171,6 +171,10 @@ declare module 'vitest/browser' { } export default defineConfig({ + define: { + // run in dev mode so virtualizer and other test-env shortcuts are disabled + 'process.env.NODE_ENV': '"development"' + }, plugins: [ // @ts-expect-error macros.vite(), // Must be first!