Skip to content

Commit eaa70f9

Browse files
fix: Virtualizer, useCloseOnScroll are able to work in the shadow DOM (#10093)
1 parent 30a5dca commit eaa70f9

11 files changed

Lines changed: 321 additions & 16 deletions

File tree

packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {getEventTarget, nodeContains} from 'react-aria/private/utils/shadowdom/DOMFunctions';
13+
import {addEvent} from 'react-aria/private/utils/domHelpers';
14+
import {
15+
getEventTarget,
16+
getEventTargets,
17+
nodeContains
18+
} from 'react-aria/private/utils/shadowdom/DOMFunctions';
1419
import {RefObject} from '@react-types/shared';
1520
import {useEffect} from 'react';
1621

@@ -63,9 +68,6 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void {
6368
}
6469
};
6570

66-
window.addEventListener('scroll', onScroll, true);
67-
return () => {
68-
window.removeEventListener('scroll', onScroll, true);
69-
};
71+
return addEvent(getEventTargets(window, triggerRef.current), 'scroll', onScroll, true);
7072
}, [isOpen, onClose, triggerRef]);
7173
}

packages/@react-types/shared/src/events.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@
1313
import {FocusableElement} from './dom';
1414
import {FocusEvent, MouseEvent, KeyboardEvent as ReactKeyboardEvent, SyntheticEvent} from 'react';
1515

16+
// Type helper to extract the target element type from an event
17+
export type EventTargetType<T> = T extends SyntheticEvent<infer E, any> ? E : EventTarget;
18+
19+
// Type helper to extract the event map from a target
20+
export type EventMapType<T extends EventTarget> = T extends Window
21+
? WindowEventMap
22+
: T extends Document
23+
? DocumentEventMap
24+
: T extends Element
25+
? HTMLElementEventMap
26+
: T extends VisualViewport
27+
? VisualViewportEventMap
28+
: GlobalEventHandlersEventMap;
29+
1630
// Event bubbling can be problematic in real-world applications, so the default for React Spectrum components
1731
// is not to propagate. This can be overridden by calling continuePropagation() on the event.
1832
export type BaseEvent<T extends SyntheticEvent> = T & {
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
// Regression tests for https://github.com/adobe/react-spectrum/issues/10093
14+
// Verifies that overlays close when a scrollable ancestor scrolls, both in
15+
// light DOM and inside a shadow DOM (where scroll events have composed: false).
16+
//
17+
// Uses ComboBox which sets isNonModal: true so its Popover registers a
18+
// window.addEventListener('scroll', ...) via useCloseOnScroll — the same
19+
// hook that is affected by the shadow DOM scroll propagation bug.
20+
21+
import {Button} from '../src/Button';
22+
import {ComboBox} from '../src/ComboBox';
23+
import {createRoot} from 'react-dom/client';
24+
import {enableShadowDOM} from 'react-stately/private/flags/flags';
25+
import {expect, it} from 'vitest';
26+
import {Input} from '../src/Input';
27+
import {Label} from '../src/Label';
28+
import {ListBox, ListBoxItem} from '../src/ListBox';
29+
import {Popover} from '../src/Popover';
30+
import React from 'react';
31+
import {User} from '@react-aria/test-utils';
32+
33+
function TestComboBox() {
34+
return (
35+
<ComboBox aria-label="Favorite Animal">
36+
<Label>Favorite Animal</Label>
37+
<Input />
38+
<Button></Button>
39+
<Popover>
40+
<ListBox>
41+
<ListBoxItem id="cat">Cat</ListBoxItem>
42+
<ListBoxItem id="dog">Dog</ListBoxItem>
43+
<ListBoxItem id="kangaroo">Kangaroo</ListBoxItem>
44+
</ListBox>
45+
</Popover>
46+
</ComboBox>
47+
);
48+
}
49+
50+
function makeScrollableContainer() {
51+
let scrollable = document.createElement('div');
52+
scrollable.style.cssText = 'height: 100px; overflow-y: auto;';
53+
let inner = document.createElement('div');
54+
inner.style.height = '500px';
55+
scrollable.appendChild(inner);
56+
let mountPoint = document.createElement('div');
57+
inner.appendChild(mountPoint);
58+
return {scrollable, mountPoint};
59+
}
60+
61+
it('overlay closes when a scrollable light DOM ancestor scrolls', async () => {
62+
let testUtilUser = new User();
63+
let {scrollable, mountPoint} = makeScrollableContainer();
64+
document.body.appendChild(scrollable);
65+
66+
let root = createRoot(mountPoint);
67+
root.render(<TestComboBox />);
68+
await new Promise<void>(resolve => setTimeout(resolve, 100));
69+
70+
let comboboxTester = testUtilUser.createTester('ComboBox', {root: scrollable});
71+
await comboboxTester.open();
72+
73+
// ComboBox listbox renders into document.body via portal.
74+
expect(comboboxTester.getListbox()).not.toBeNull();
75+
76+
// Scroll the ancestor that contains the trigger — window capturing listener should close the overlay.
77+
scrollable.dispatchEvent(new Event('scroll'));
78+
await new Promise<void>(resolve => setTimeout(resolve, 100));
79+
80+
expect(comboboxTester.getListbox()).toBeNull();
81+
82+
root.unmount();
83+
document.body.removeChild(scrollable);
84+
});
85+
86+
describe('Shadow DOM', () => {
87+
/**
88+
* EnableShadowDOM must be called before mounting.
89+
*
90+
* Cannot be turned off, so should be called after light-dom tests.
91+
*/
92+
enableShadowDOM();
93+
94+
it('overlay closes when a scrollable shadow DOM ancestor scrolls', async () => {
95+
let testUtilUser = new User();
96+
let outerHost = document.createElement('div');
97+
document.body.appendChild(outerHost);
98+
let shadowRoot = outerHost.attachShadow({mode: 'open'});
99+
100+
let {scrollable, mountPoint} = makeScrollableContainer();
101+
shadowRoot.appendChild(scrollable);
102+
103+
let root = createRoot(mountPoint);
104+
root.render(<TestComboBox />);
105+
await new Promise<void>(resolve => setTimeout(resolve, 100));
106+
107+
let comboboxTester = testUtilUser.createTester('ComboBox', {root: scrollable});
108+
await comboboxTester.open();
109+
110+
// Listbox renders into document.body via portal even in shadow DOM mode.
111+
expect(comboboxTester.getListbox()).not.toBeNull();
112+
113+
// Scroll inside the shadow root.
114+
// Without the fix, window never sees this event (composed: false).
115+
// With the fix (getEventTargets + addEvent), the shadow root listener closes the overlay.
116+
scrollable.dispatchEvent(new Event('scroll'));
117+
await new Promise<void>(resolve => setTimeout(resolve, 100));
118+
119+
expect(comboboxTester.getListbox()).toBeNull();
120+
121+
root.unmount();
122+
document.body.removeChild(outerHost);
123+
});
124+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
// Regression test for https://github.com/adobe/react-spectrum/issues/10093
14+
15+
import {createRoot} from 'react-dom/client';
16+
import {enableShadowDOM} from 'react-stately/private/flags/flags';
17+
import {expect, it} from 'vitest';
18+
import {ListLayout} from 'react-stately/useVirtualizerState';
19+
import React from 'react';
20+
import {Tree, TreeItem, TreeItemContent} from '../src/Tree';
21+
import {Virtualizer} from '../src/Virtualizer';
22+
23+
// Mirror what the reproduction does — must be set before mounting.
24+
enableShadowDOM();
25+
26+
const ROW_HEIGHT = 30;
27+
const CONTAINER_HEIGHT = 300;
28+
const items = Array.from({length: 50}, (_, i) => ({id: `item-${i}`, name: `Item ${i}`}));
29+
30+
function VirtualizedTree() {
31+
return (
32+
<Virtualizer layout={ListLayout} layoutOptions={{rowHeight: ROW_HEIGHT}}>
33+
<Tree
34+
aria-label="Shadow DOM tree"
35+
items={items}
36+
style={{
37+
display: 'block',
38+
height: `${CONTAINER_HEIGHT}px`,
39+
width: '300px',
40+
overflow: 'auto'
41+
}}>
42+
{(item: any) => (
43+
<TreeItem id={item.id} textValue={item.name}>
44+
<TreeItemContent>{item.name}</TreeItemContent>
45+
</TreeItem>
46+
)}
47+
</Tree>
48+
</Virtualizer>
49+
);
50+
}
51+
52+
it('virtualizer inside shadow DOM updates visible items on scroll', async () => {
53+
let host = document.createElement('div');
54+
document.body.appendChild(host);
55+
let shadowRoot = host.attachShadow({mode: 'open'});
56+
let mountPoint = document.createElement('div');
57+
shadowRoot.appendChild(mountPoint);
58+
59+
let root = createRoot(mountPoint);
60+
root.render(<VirtualizedTree />);
61+
// Wait for initial render, ResizeObserver measurement, and ScrollView's size update.
62+
await new Promise<void>(resolve => setTimeout(() => resolve(), 200));
63+
64+
// The scrollport is the treegrid element (Tree's outer div with overflow: auto).
65+
// The [role="presentation"] div is the inner content container, not the scrollport.
66+
let scrollport = shadowRoot.querySelector<HTMLElement>('[role="treegrid"]');
67+
expect(scrollport).not.toBeNull();
68+
expect(scrollport!.scrollHeight).toBeGreaterThan(CONTAINER_HEIGHT);
69+
70+
let rows = shadowRoot.querySelectorAll('[role="row"]');
71+
expect(rows.length).toBeGreaterThan(0);
72+
// Only a subset of items should be visible (not all 50) due to virtualization.
73+
expect(rows.length).toBeLessThan(items.length);
74+
expect(Array.from(rows).some(r => r.textContent?.includes('Item 0'))).toBe(true);
75+
76+
// Scroll past 20 items (20 × 30px) so Item 0 is outside any extra items the layout may buffer.
77+
scrollport!.scrollTop = ROW_HEIGHT * 20;
78+
await new Promise<void>(resolve => setTimeout(() => resolve(), 200));
79+
80+
let updatedRows = shadowRoot.querySelectorAll('[role="row"]');
81+
expect(Array.from(updatedRows).some(r => r.textContent?.includes('Item 0'))).toBe(false);
82+
expect(Array.from(updatedRows).some(r => r.textContent?.includes('Item 20'))).toBe(true);
83+
84+
root.unmount();
85+
document.body.removeChild(host);
86+
});
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
1-
export {getOwnerDocument, getOwnerWindow, isShadowRoot} from '../../../src/utils/domHelpers';
1+
export {
2+
addEvent,
3+
getOwnerDocument,
4+
getOwnerWindow,
5+
isShadowRoot
6+
} from '../../../src/utils/domHelpers';

packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export {
22
getEventTarget,
3+
getEventTargets,
34
nodeContains,
45
isFocusWithin,
56
getActiveElement

packages/react-aria/src/overlays/useCloseOnScroll.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions';
13+
import {addEvent} from '../utils/domHelpers';
14+
import {getEventTarget, getEventTargets, nodeContains} from '../utils/shadowdom/DOMFunctions';
1415
import {RefObject} from '@react-types/shared';
1516
import {useEffect} from 'react';
1617

@@ -60,9 +61,6 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void {
6061
}
6162
};
6263

63-
window.addEventListener('scroll', onScroll, true);
64-
return () => {
65-
window.removeEventListener('scroll', onScroll, true);
66-
};
64+
return addEvent(getEventTargets(window, triggerRef.current), 'scroll', onScroll, true);
6765
}, [isOpen, onClose, triggerRef]);
6866
}

packages/react-aria/src/utils/domHelpers.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type {EventMapType} from '@react-types/shared';
2+
13
export const getOwnerDocument = (el: Element | null | undefined): Document => {
24
return el?.ownerDocument ?? document;
35
};
@@ -32,3 +34,36 @@ function isNode(value: unknown): value is Node {
3234
export function isShadowRoot(node: Node | null): node is ShadowRoot {
3335
return isNode(node) && node.nodeType === Node.DOCUMENT_FRAGMENT_NODE && 'host' in node;
3436
}
37+
38+
/**
39+
* Attaches an event listener on target(s) and returns a cleanup function.
40+
*
41+
* Some events are `composed: false` and do not propagate out of shadow roots,
42+
* even in the capture phase — notably `scroll` and `scrollend`. For those, a
43+
* listener on `window`/`document` alone will miss scrolls that happen inside a
44+
* shadow root. Pass the array from `getEventTargets(global, refNode)`
45+
* (react-aria/private/utils/shadowdom/DOMFunctions) as `target` so the listener
46+
* is attached to the global target *and* every enclosing shadow root.
47+
*/
48+
export function addEvent<T extends EventTarget, K extends keyof EventMapType<Exclude<T, null>>>(
49+
target: T | EventTarget[] | null,
50+
event: Extract<K, string> | (string & {}),
51+
listener?: (this: T, ev: EventMapType<Exclude<T, null>>[K]) => any,
52+
options?: boolean | AddEventListenerOptions
53+
): () => void {
54+
if (listener == null || target == null) {
55+
return () => {};
56+
}
57+
58+
let eventTargets = Array.isArray(target) ? target : [target];
59+
60+
for (let eventTarget of eventTargets) {
61+
eventTarget.addEventListener(event, listener as EventListener, options);
62+
}
63+
64+
return () => {
65+
for (let eventTarget of eventTargets) {
66+
eventTarget.removeEventListener(event, listener as EventListener, options);
67+
}
68+
};
69+
}

packages/react-aria/src/utils/shadowdom/DOMFunctions.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,41 @@ export function getEventTarget<T extends Event | SyntheticEvent>(event: T): Even
8383
return event.target as EventTargetType<T>;
8484
}
8585

86+
/**
87+
* Returns the set of event targets a listener must be attached to for an event
88+
* to be observed at `global`, given an element `refNode` that may live inside
89+
* one or more shadow roots.
90+
*
91+
* Returns `[global, ...shadowRoots]`, where shadowRoots are the ShadowRoots
92+
* enclosing `refNode` that lie between it and `global`. Needed for events that
93+
* don't compose (e.g. scroll, scrollend), which do not propagate out of shadow
94+
* roots even in the capture phase. Pass the result straight to `addEvent`.
95+
*/
96+
export function getEventTargets(
97+
global: EventTarget,
98+
refNode: Node | null | undefined
99+
): EventTarget[] {
100+
let targets: EventTarget[] = [global];
101+
if (!shadowDOM() || refNode == null) {
102+
return targets;
103+
}
104+
105+
// The root `global` itself lives in. The event already reaches `global` once
106+
// it is inside this root, so we must NOT collect this root or anything above
107+
// it — only the shadow roots strictly between `refNode` and `global`.
108+
// `window` has no getRootNode; its boundary is the document, which the walk
109+
// reaches naturally (the document is not a ShadowRoot, so the loop exits).
110+
let globalRoot = 'getRootNode' in global ? (global as Node).getRootNode() : null;
111+
let current: Node | null = refNode.getRootNode() ?? null;
112+
113+
while (isShadowRoot(current) && current !== globalRoot) {
114+
targets.push(current);
115+
current = current.host.getRootNode();
116+
}
117+
118+
return targets;
119+
}
120+
86121
/**
87122
* ShadowDOM safe fast version of node.contains(document.activeElement).
88123
*

0 commit comments

Comments
 (0)