diff --git a/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts b/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts
index 4470929976e..299396ff6bc 100644
--- a/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts
+++ b/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts
@@ -10,7 +10,11 @@
* governing permissions and limitations under the License.
*/
-import {getEventTarget, nodeContains} from 'react-aria/private/utils/shadowdom/DOMFunctions';
+import {
+ addGlobalScrollListener,
+ getEventTarget,
+ nodeContains
+} from 'react-aria/private/utils/shadowdom/DOMFunctions';
import {RefObject} from '@react-types/shared';
import {useEffect} from 'react';
@@ -63,9 +67,6 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void {
}
};
- window.addEventListener('scroll', onScroll, true);
- return () => {
- window.removeEventListener('scroll', onScroll, true);
- };
+ return addGlobalScrollListener(window, triggerRef.current, onScroll, true);
}, [isOpen, onClose, triggerRef]);
}
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..61df7bc1d8d
--- /dev/null
+++ b/packages/react-aria-components/test/Select.browser.test.tsx
@@ -0,0 +1,131 @@
+/*
+ * 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';
+
+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};
+}
+
+async function openComboBox(container: Element) {
+ let button = container.querySelector('button') as HTMLButtonElement;
+ button.dispatchEvent(
+ new PointerEvent('pointerdown', {bubbles: true, cancelable: true, isPrimary: true})
+ );
+ button.dispatchEvent(
+ new PointerEvent('pointerup', {bubbles: true, cancelable: true, isPrimary: true})
+ );
+ button.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
+ await new Promise(resolve => setTimeout(resolve, 100));
+}
+
+it('overlay closes when a scrollable light DOM ancestor scrolls', async () => {
+ let {scrollable, mountPoint} = makeScrollableContainer();
+ document.body.appendChild(scrollable);
+
+ let root = createRoot(mountPoint);
+ root.render();
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ await openComboBox(scrollable);
+
+ // ComboBox listbox renders into document.body via portal.
+ expect(document.querySelector('[role="listbox"]')).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(document.querySelector('[role="listbox"]')).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 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));
+
+ await openComboBox(scrollable);
+
+ // Listbox renders into document.body via portal even in shadow DOM mode.
+ expect(document.querySelector('[role="listbox"]')).not.toBeNull();
+
+ // Scroll inside the shadow root.
+ // Without the fix, window never sees this event (composed: false).
+ // With the fix (addGlobalScrollListener), the shadow root listener closes the overlay.
+ scrollable.dispatchEvent(new Event('scroll'));
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ expect(document.querySelector('[role="listbox"]')).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/shadowdom/DOMFunctions.ts b/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts
index 8e24abab17f..4905bc908ef 100644
--- a/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts
+++ b/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts
@@ -1,4 +1,5 @@
export {
+ addGlobalScrollListener,
getEventTarget,
nodeContains,
isFocusWithin,
diff --git a/packages/react-aria/src/overlays/useCloseOnScroll.ts b/packages/react-aria/src/overlays/useCloseOnScroll.ts
index 0d7e7698876..667a98e5016 100644
--- a/packages/react-aria/src/overlays/useCloseOnScroll.ts
+++ b/packages/react-aria/src/overlays/useCloseOnScroll.ts
@@ -10,7 +10,11 @@
* governing permissions and limitations under the License.
*/
-import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions';
+import {
+ addGlobalScrollListener,
+ getEventTarget,
+ nodeContains
+} from '../utils/shadowdom/DOMFunctions';
import {RefObject} from '@react-types/shared';
import {useEffect} from 'react';
@@ -60,9 +64,6 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void {
}
};
- window.addEventListener('scroll', onScroll, true);
- return () => {
- window.removeEventListener('scroll', onScroll, true);
- };
+ return addGlobalScrollListener(window, triggerRef.current, onScroll, true);
}, [isOpen, onClose, triggerRef]);
}
diff --git a/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts b/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts
index 6ca0e476f5e..366c2663e2b 100644
--- a/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts
+++ b/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts
@@ -83,6 +83,46 @@ export function getEventTarget(event: T): Even
return event.target as EventTargetType;
}
+/**
+ * Adds a scroll listener to a global target (window or document).
+ * When shadow DOM mode is enabled, also attaches a capturing listener to each
+ * shadow root in the ancestor chain of refElement, since scroll events have
+ * composed: false and do not propagate out of shadow roots.
+ *
+ * Returns a cleanup function that removes all attached listeners.
+ */
+export function addGlobalScrollListener(
+ globalTarget: Window | Document,
+ refElement: Element | null,
+ listener: EventListener,
+ options?: boolean | AddEventListenerOptions
+): () => void {
+ globalTarget.addEventListener('scroll', listener, options);
+
+ let shadowRoots: ShadowRoot[] = [];
+ if (shadowDOM() && refElement) {
+ let node: Node | null = refElement;
+ while (node) {
+ if (isShadowRoot(node)) {
+ shadowRoots.push(node as ShadowRoot);
+ node = (node as ShadowRoot).host;
+ } else {
+ node = node.parentNode;
+ }
+ }
+ for (let root of shadowRoots) {
+ root.addEventListener('scroll', listener, options);
+ }
+ }
+
+ return () => {
+ globalTarget.removeEventListener('scroll', listener, options);
+ for (let root of shadowRoots) {
+ root.removeEventListener('scroll', listener, options);
+ }
+ };
+}
+
/**
* 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..48fe43aefa1 100644
--- a/packages/react-aria/src/virtualizer/ScrollView.tsx
+++ b/packages/react-aria/src/virtualizer/ScrollView.tsx
@@ -10,9 +10,13 @@
* governing permissions and limitations under the License.
*/
-// @ts-ignore
+import {
+ addGlobalScrollListener,
+ getEventTarget,
+ nodeContains
+} from '../utils/shadowdom/DOMFunctions';
+
import {flushSync} from 'react-dom';
-import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions';
import {getScrollLeft} from './utils';
import {Point, Rect, Size} from 'react-stately/useVirtualizerState';
import React, {
@@ -218,10 +222,11 @@ 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]);
+ return addGlobalScrollListener(document, ref.current, onScroll, true);
+ }, [onScroll, ref]);
useEffect(() => {
return () => {
diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts
index 96a154b7e1b..ba10dc1b634 100644
--- a/vitest.browser.config.ts
+++ b/vitest.browser.config.ts
@@ -226,6 +226,11 @@ export default defineConfig({
},
testTimeout: 20000
},
+ define: {
+ // Enable real size measurement in browser tests so virtualizer components
+ // use actual clientWidth/clientHeight instead of Infinity.
+ 'process.env.VIRT_ON': JSON.stringify('1')
+ },
resolve: {
conditions: ['source', 'import', 'module', 'default'],
extensions: ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json', '.svg'],