From a2dabe0f98cd91ef43834dfb846d533128fe35aa 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 | 11 +- .../test/Select.browser.test.tsx | 131 ++++++++++++++++++ .../test/Tree.browser.test.tsx | 86 ++++++++++++ .../private/utils/shadowdom/DOMFunctions.ts | 1 + .../src/overlays/useCloseOnScroll.ts | 11 +- .../src/utils/shadowdom/DOMFunctions.ts | 40 ++++++ .../react-aria/src/virtualizer/ScrollView.tsx | 15 +- vitest.browser.config.ts | 4 + 8 files changed, 284 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..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 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! From 9795d8ca76a60abf54293f0f60ffa39e48cff251 Mon Sep 17 00:00:00 2001 From: Paul Zaczkiewicz Date: Mon, 22 Jun 2026 13:09:26 -0400 Subject: [PATCH 2/2] refactor: generic getShadowRoots + addEvent for non-composed events Address PR review feedback. Replace the scroll-specific addGlobalScrollListener with a generic getShadowRoots(node) helper plus the addEvent utility, since the shadow-DOM event-propagation problem applies to all non-composed events, not just scroll. addEvent and the EventMapType/EventTargetType type helpers are pulled in from #10102 verbatim and placed at the same locations so the overlap auto-resolves when #10102 lands. Callers (ScrollView, both useCloseOnScroll copies) now use: addEvent(window/document, 'scroll', listener, true) addEvent(getShadowRoots(ref.current), 'scroll', listener, true) Select.browser.test now opens the ComboBox via the @react-aria/test-utils combobox tester instead of dispatching raw PointerEvents. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/menu/useCloseOnScroll.ts | 10 +++- packages/@react-types/shared/src/events.d.ts | 14 ++++++ .../test/Select.browser.test.tsx | 31 +++++------- .../exports/private/utils/domHelpers.ts | 7 ++- .../private/utils/shadowdom/DOMFunctions.ts | 2 +- .../src/overlays/useCloseOnScroll.ts | 14 +++--- packages/react-aria/src/utils/domHelpers.ts | 28 +++++++++++ .../src/utils/shadowdom/DOMFunctions.ts | 48 ++++++------------- .../react-aria/src/virtualizer/ScrollView.tsx | 15 +++--- 9 files changed, 100 insertions(+), 69 deletions(-) diff --git a/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts b/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts index 299396ff6bc..7aea3d11366 100644 --- a/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts +++ b/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts @@ -10,9 +10,10 @@ * governing permissions and limitations under the License. */ +import {addEvent} from 'react-aria/private/utils/domHelpers'; import { - addGlobalScrollListener, getEventTarget, + getShadowRoots, nodeContains } from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {RefObject} from '@react-types/shared'; @@ -67,6 +68,11 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void { } }; - return addGlobalScrollListener(window, triggerRef.current, onScroll, true); + let cleanupGlobal = addEvent(window, 'scroll', onScroll, true); + let cleanupShadow = addEvent(getShadowRoots(triggerRef.current), 'scroll', onScroll, true); + return () => { + 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 index 61df7bc1d8d..ebde4d982d8 100644 --- a/packages/react-aria-components/test/Select.browser.test.tsx +++ b/packages/react-aria-components/test/Select.browser.test.tsx @@ -28,6 +28,7 @@ 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 ( @@ -57,19 +58,8 @@ function makeScrollableContainer() { 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 testUtilUser = new User(); let {scrollable, mountPoint} = makeScrollableContainer(); document.body.appendChild(scrollable); @@ -77,16 +67,17 @@ it('overlay closes when a scrollable light DOM ancestor scrolls', async () => { root.render(); await new Promise(resolve => setTimeout(resolve, 100)); - await openComboBox(scrollable); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: scrollable}); + await comboboxTester.open(); // ComboBox listbox renders into document.body via portal. - expect(document.querySelector('[role="listbox"]')).not.toBeNull(); + 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(document.querySelector('[role="listbox"]')).toBeNull(); + expect(comboboxTester.getListbox()).toBeNull(); root.unmount(); document.body.removeChild(scrollable); @@ -101,6 +92,7 @@ describe('Shadow DOM', () => { 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'}); @@ -112,18 +104,19 @@ describe('Shadow DOM', () => { root.render(); await new Promise(resolve => setTimeout(resolve, 100)); - await openComboBox(scrollable); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: scrollable}); + await comboboxTester.open(); // Listbox renders into document.body via portal even in shadow DOM mode. - expect(document.querySelector('[role="listbox"]')).not.toBeNull(); + expect(comboboxTester.getListbox()).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. + // 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(document.querySelector('[role="listbox"]')).toBeNull(); + expect(comboboxTester.getListbox()).toBeNull(); root.unmount(); document.body.removeChild(outerHost); 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 4905bc908ef..41cc2c586f9 100644 --- a/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts +++ b/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts @@ -1,6 +1,6 @@ export { - addGlobalScrollListener, getEventTarget, + getShadowRoots, nodeContains, isFocusWithin, getActiveElement diff --git a/packages/react-aria/src/overlays/useCloseOnScroll.ts b/packages/react-aria/src/overlays/useCloseOnScroll.ts index 667a98e5016..97e201d189b 100644 --- a/packages/react-aria/src/overlays/useCloseOnScroll.ts +++ b/packages/react-aria/src/overlays/useCloseOnScroll.ts @@ -10,11 +10,8 @@ * governing permissions and limitations under the License. */ -import { - addGlobalScrollListener, - 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'; @@ -64,6 +61,11 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void { } }; - return addGlobalScrollListener(window, triggerRef.current, onScroll, true); + let cleanupGlobal = addEvent(window, 'scroll', onScroll, true); + let cleanupShadow = addEvent(getShadowRoots(triggerRef.current), 'scroll', onScroll, true); + return () => { + 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 366c2663e2b..9b501d64462 100644 --- a/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts +++ b/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts @@ -84,43 +84,25 @@ export function getEventTarget(event: T): Even } /** - * 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. + * Collects the enclosing ShadowRoots between a node and the document. * - * Returns a cleanup function that removes all attached listeners. + * 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 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); - } +export function getShadowRoots(node: Node | null | undefined): ShadowRoot[] { + if (!shadowDOM()) { + return []; } - return () => { - globalTarget.removeEventListener('scroll', listener, options); - for (let root of shadowRoots) { - root.removeEventListener('scroll', listener, options); - } - }; + let roots: ShadowRoot[] = []; + let current: Node | null = node?.getRootNode() ?? null; + + while (isShadowRoot(current)) { + roots.push(current); + current = current.host.getRootNode(); + } + + return roots; } /** diff --git a/packages/react-aria/src/virtualizer/ScrollView.tsx b/packages/react-aria/src/virtualizer/ScrollView.tsx index 48fe43aefa1..7d1f9346149 100644 --- a/packages/react-aria/src/virtualizer/ScrollView.tsx +++ b/packages/react-aria/src/virtualizer/ScrollView.tsx @@ -10,13 +10,9 @@ * governing permissions and limitations under the License. */ -import { - addGlobalScrollListener, - getEventTarget, - nodeContains -} from '../utils/shadowdom/DOMFunctions'; - +import {addEvent} from '../utils/domHelpers'; import {flushSync} from 'react-dom'; +import {getEventTarget, getShadowRoots, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {getScrollLeft} from './utils'; import {Point, Rect, Size} from 'react-stately/useVirtualizerState'; import React, { @@ -225,7 +221,12 @@ export function useScrollView( // 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(() => { - return addGlobalScrollListener(document, ref.current, onScroll, true); + let cleanupGlobal = addEvent(document, 'scroll', onScroll, true); + let cleanupShadow = addEvent(getShadowRoots(ref.current), 'scroll', onScroll, true); + return () => { + cleanupGlobal(); + cleanupShadow(); + }; }, [onScroll, ref]); useEffect(() => {