Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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]);
}
131 changes: 131 additions & 0 deletions packages/react-aria-components/test/Select.browser.test.tsx
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);
});
});
86 changes: 86 additions & 0 deletions packages/react-aria-components/test/Tree.browser.test.tsx
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,
Expand Down
11 changes: 6 additions & 5 deletions packages/react-aria/src/overlays/useCloseOnScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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]);
}
40 changes: 40 additions & 0 deletions packages/react-aria/src/utils/shadowdom/DOMFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};
}

/**
* ShadowDOM safe fast version of node.contains(document.activeElement).
*
Expand Down
15 changes: 10 additions & 5 deletions packages/react-aria/src/virtualizer/ScrollView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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 () => {
Expand Down
5 changes: 5 additions & 0 deletions vitest.browser.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down