Skip to content

fix: shadow DOM scroll events — addGlobalScrollListener utility + close-on-scroll fix#10188

Open
pzaczkiewicz-athenahealth wants to merge 1 commit into
adobe:mainfrom
pzaczkiewicz-athenahealth:Issue-10093-virtualizer-shadow-dom
Open

fix: shadow DOM scroll events — addGlobalScrollListener utility + close-on-scroll fix#10188
pzaczkiewicz-athenahealth wants to merge 1 commit into
adobe:mainfrom
pzaczkiewicz-athenahealth:Issue-10093-virtualizer-shadow-dom

Conversation

@pzaczkiewicz-athenahealth

@pzaczkiewicz-athenahealth pzaczkiewicz-athenahealth commented Jun 11, 2026

Copy link
Copy Markdown

Summary

Fixes #10093. The `scroll` DOM event has `composed: false`, meaning it does not propagate out of shadow roots — not even in the capturing phase. This silently broke two behaviours when `enableShadowDOM()` is in use:

  1. Virtualizer / ScrollView — `document.addEventListener('scroll', onScroll, true)` never fires for elements inside a shadow root, so the virtualizer never updates which items are visible during scroll.
  2. `useCloseOnScroll` (overlays) — `window.addEventListener('scroll', onScroll, true)` never fires for scrollable ancestors inside a shadow root, so combobox/popover overlays do not close when their ancestor scrolls.

Approach

Added `addGlobalScrollListener` to `DOMFunctions.ts` — the established home for shadow-DOM-safe DOM wrappers. When `shadowDOM()` is off the function attaches only to the global target, identical to the original code. When `shadowDOM()` is on it additionally walks the ancestor chain from a reference element, collects every `ShadowRoot` found, and attaches a capturing `scroll` listener to each.

Updated all affected callers to use this utility:

  • `packages/react-aria/src/virtualizer/ScrollView.tsx` (replaced manual shadow-root-walking code from an earlier partial fix)
  • `packages/react-aria/src/overlays/useCloseOnScroll.ts`
  • `packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts`

Callers that attach scroll listeners directly to a local element (`TabPanelCarousel.tsx`, `Pagination.tsx`) and the `visualViewport` listener in `useOverlayPosition.ts` are unaffected — they don't rely on event propagation crossing shadow boundaries.

`vitest.browser.config.ts` has a new `define: { 'process.env.VIRT_ON': '1' }` entry required for the Tree browser test: without it, the virtualizer treats `NODE_ENV=test` as a signal to use `Infinity` for viewport dimensions and skips virtualization, making the scroll test meaningless.

Pull Request Checklist

  • Identified root cause: `scroll` events are `composed: false` and do not propagate out of shadow roots, even in the capture phase on `document`/`window`
  • Audited all `addEventListener('scroll'/'scrollend')` usages in library code; confirmed which rely on global propagation vs. direct element attachment
  • Created `addGlobalScrollListener` in `DOMFunctions.ts`, gated on `shadowDOM()` flag — light-DOM behaviour is unchanged when the flag is off
  • Updated `ScrollView.tsx` to use the new utility (removed manual ancestor-walking code)
  • Updated both copies of `useCloseOnScroll.ts` to use the new utility
  • Types compile cleanly (`yarn check-types:tsc`)
  • New browser tests written, confirmed to fail before the fix and pass after (see Test Instructions)
  • 296 existing unit tests pass across Tree, VirtualizedMenu, and all overlay suites (1 pre-existing skip)

Test Instructions

Automated browser tests (Chromium)

`packages/react-aria-components/test/Tree.browser.test.tsx` — Virtualizer / ScrollView:

yarn vitest --config vitest.browser.config.ts run packages/react-aria-components/test/Tree.browser.test.tsx --project=chromium-desktop

Mounts a 50-item virtualized Tree inside a shadow root, scrolls the treegrid, and asserts Item 0 leaves the DOM while Item 20 appears.

`packages/react-aria-components/test/Select.browser.test.tsx` — useCloseOnScroll:

yarn vitest --config vitest.browser.config.ts run packages/react-aria-components/test/Select.browser.test.tsx --project=chromium-desktop
  • Light DOM test: Opens a ComboBox inside a scrollable div, fires a scroll event, confirms the popover closes — regression guard, passes before and after.
  • Shadow DOM test: Same setup inside a shadow root — fails before the fix, passes after.

Existing unit tests

yarn jest packages/react-aria-components/test/Tree.test.tsx packages/react-aria-components/test/VirtualizedMenu.test.tsx
yarn jest packages/react-aria/test/overlays/

🤖 Generated with Claude Code

Comment thread vitest.browser.config.ts Outdated
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')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll rebase off of #10190 so that whatever strategy you choose for your own PR gets automatically applied to mine.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, that JSON.stringify is an embarrassing Claude silliness that I missed on my review. I admittedly paid more attention to the implementation than the config.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opted not to rebase given that conflated the diff for this PR with #10190. Instead, I adopted the same vitest.browser.config.ts changes so that git would be able to auto-resolve the merge conflict.

Comment on lines +94 to +125
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);
}
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 addEvent utility, wich accepts an array of event targets. We could use that in combination with a helper like this:

/**
 * 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);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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.

@nwidynski nwidynski Jun 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 addEvent utility.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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.

@pzaczkiewicz-athenahealth pzaczkiewicz-athenahealth force-pushed the Issue-10093-virtualizer-shadow-dom branch from eb278c5 to a2dabe0 Compare June 22, 2026 15:15
return {scrollable, mountPoint};
}

async function openComboBox(container: Element) {

Copy link
Copy Markdown
Contributor

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. See packages/react-aria-components/test/ComboBox.test.js for examples of how to use these.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tree rendered inside Virtualizer from react-aria-components does not correctly virtualize when mounted inside a shadow root

2 participants