From edc4f2cace29fb5fd169301c4e298b50eb787776 Mon Sep 17 00:00:00 2001 From: Paul Zaczkiewicz Date: Thu, 11 Jun 2026 09:53:45 -0400 Subject: [PATCH 1/2] fix: Virtualizer, useCloseOnScroll are able to work in the shadow DOM (#10093) --- .../src/menu/useCloseOnScroll.ts | 7 +- .../test/Select.browser.test.tsx | 131 ++++++++++++++++++ .../test/Tree.browser.test.tsx | 81 +++++++++++ .../private/utils/shadowdom/DOMFunctions.ts | 1 + .../src/overlays/useCloseOnScroll.ts | 7 +- .../src/utils/shadowdom/DOMFunctions.ts | 40 ++++++ .../react-aria/src/virtualizer/ScrollView.tsx | 15 +- vitest.browser.config.ts | 5 + 8 files changed, 272 insertions(+), 15 deletions(-) create mode 100644 packages/react-aria-components/test/Select.browser.test.tsx create mode 100644 packages/react-aria-components/test/Tree.browser.test.tsx diff --git a/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts b/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts index 4470929976e..26270532ea9 100644 --- a/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts +++ b/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts @@ -10,7 +10,7 @@ * 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 +63,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..0e30222ed4e --- /dev/null +++ b/packages/react-aria-components/test/Tree.browser.test.tsx @@ -0,0 +1,81 @@ +/* + * 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..09229274a0e 100644 --- a/packages/react-aria/src/overlays/useCloseOnScroll.ts +++ b/packages/react-aria/src/overlays/useCloseOnScroll.ts @@ -10,7 +10,7 @@ * 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 +60,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'], From 40d96bf40f41e322e638043f28f35daeaf680df6 Mon Sep 17 00:00:00 2001 From: Paul Zaczkiewicz Date: Fri, 12 Jun 2026 10:21:19 -0400 Subject: [PATCH 2/2] chore: fix prettier formatting for lint CI check Co-Authored-By: Claude Sonnet 4.6 --- .../@adobe/react-spectrum/src/menu/useCloseOnScroll.ts | 6 +++++- packages/react-aria-components/test/Tree.browser.test.tsx | 7 ++++++- packages/react-aria/src/overlays/useCloseOnScroll.ts | 6 +++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts b/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts index 26270532ea9..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 {addGlobalScrollListener, 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'; diff --git a/packages/react-aria-components/test/Tree.browser.test.tsx b/packages/react-aria-components/test/Tree.browser.test.tsx index 0e30222ed4e..5e5c2c49404 100644 --- a/packages/react-aria-components/test/Tree.browser.test.tsx +++ b/packages/react-aria-components/test/Tree.browser.test.tsx @@ -33,7 +33,12 @@ function VirtualizedTree() { + style={{ + display: 'block', + height: `${CONTAINER_HEIGHT}px`, + width: '300px', + overflow: 'auto' + }}> {(item: any) => ( {item.name} diff --git a/packages/react-aria/src/overlays/useCloseOnScroll.ts b/packages/react-aria/src/overlays/useCloseOnScroll.ts index 09229274a0e..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 {addGlobalScrollListener, getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; +import { + addGlobalScrollListener, + getEventTarget, + nodeContains +} from '../utils/shadowdom/DOMFunctions'; import {RefObject} from '@react-types/shared'; import {useEffect} from 'react';