-
Notifications
You must be signed in to change notification settings - Fork 1.5k
fix: shadow DOM scroll events — addGlobalScrollListener utility + close-on-scroll fix #10188
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <ComboBox aria-label="Favorite Animal"> | ||
| <Label>Favorite Animal</Label> | ||
| <Input /> | ||
| <Button>▼</Button> | ||
| <Popover> | ||
| <ListBox> | ||
| <ListBoxItem id="cat">Cat</ListBoxItem> | ||
| <ListBoxItem id="dog">Dog</ListBoxItem> | ||
| <ListBoxItem id="kangaroo">Kangaroo</ListBoxItem> | ||
| </ListBox> | ||
| </Popover> | ||
| </ComboBox> | ||
| ); | ||
| } | ||
|
|
||
| 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<void>(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(<TestComboBox />); | ||
| await new Promise<void>(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<void>(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(<TestComboBox />); | ||
| await new Promise<void>(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<void>(resolve => setTimeout(resolve, 100)); | ||
|
|
||
| expect(document.querySelector('[role="listbox"]')).toBeNull(); | ||
|
|
||
| root.unmount(); | ||
| document.body.removeChild(outerHost); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <Virtualizer layout={ListLayout} layoutOptions={{rowHeight: ROW_HEIGHT}}> | ||
| <Tree | ||
| aria-label="Shadow DOM tree" | ||
| items={items} | ||
| style={{ | ||
| display: 'block', | ||
| height: `${CONTAINER_HEIGHT}px`, | ||
| width: '300px', | ||
| overflow: 'auto' | ||
| }}> | ||
| {(item: any) => ( | ||
| <TreeItem id={item.id} textValue={item.name}> | ||
| <TreeItemContent>{item.name}</TreeItemContent> | ||
| </TreeItem> | ||
| )} | ||
| </Tree> | ||
| </Virtualizer> | ||
| ); | ||
| } | ||
|
|
||
| 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(<VirtualizedTree />); | ||
| // Wait for initial render, ResizeObserver measurement, and ScrollView's size update. | ||
| await new Promise<void>(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<HTMLElement>('[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<void>(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); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| export { | ||
| addGlobalScrollListener, | ||
| getEventTarget, | ||
| nodeContains, | ||
| isFocusWithin, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -83,6 +83,46 @@ export function getEventTarget<T extends Event | SyntheticEvent>(event: T): Even | |
| return event.target as EventTargetType<T>; | ||
| } | ||
|
|
||
| /** | ||
| * 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); | ||
| } | ||
| }; | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should aim for this to be more generic, since it applies to all events which don't compose. #10102 introduces an /**
* Collects the enclosing ShadowRoots between a node and the document.
*/
export function getShadowRoots(node: Node | null | undefined): ShadowRoot[] {
if (!shadowDOM()) {
return [];
}
let roots: ShadowRoot[] = [];
let current: Node | undefined = node?.getRootNode();
while (isShadowRoot(current)) {
roots.push(current);
current = current.host.getRootNode();
}
return roots;
}addEvent(window, 'scroll', listener, true);
addEvent(getShadowRoots(scrollRef.current), 'scroll', listener, true);
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clearly I'm on the bleeding edge given that I need to merge two in-progress PRs into this branch.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just FYI, it's not certain that #10102 will be merged. It's still awaiting review from the maintainer team. If it isn't you can just extract the
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In that case, I'll ignore this comment until/unless #10102 gets merged first.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I decided to take addEvent on anyway. Doing my best to reduce future merge conflicts. My biggest concern is that it would be nice if |
||
| /** | ||
| * ShadowDOM safe fast version of node.contains(document.activeElement). | ||
| * | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FYI, there are also combobox testing utilities available in
@react-aria/test-utils. Seepackages/react-aria-components/test/ComboBox.test.jsfor examples of how to use these.