Skip to content

Commit cdde876

Browse files
refactor: merge getShadowRoots into getEventTargets, single addEvent per caller
Collapse the per-caller two-addEvent-calls pattern to one. getShadowRoots is replaced by getEventTargets(global, refNode), which returns [global, ...shadowRoots] as a single EventTarget[] (addEvent already accepts arrays and returns one combined cleanup). getEventTargets only collects the shadow roots that lie strictly between refNode and global, stopping at global's own root node so it works correctly even when a non-window/document node is passed as global. addEvent's body stays byte-for-byte identical to #10102; only its JSDoc gains a note about composed:false events (scroll, scrollend) and when to use getEventTargets, so the implementation still auto-merges with #10102. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9795d8c commit cdde876

7 files changed

Lines changed: 39 additions & 34 deletions

File tree

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

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import {addEvent} from 'react-aria/private/utils/domHelpers';
1414
import {
1515
getEventTarget,
16-
getShadowRoots,
16+
getEventTargets,
1717
nodeContains
1818
} from 'react-aria/private/utils/shadowdom/DOMFunctions';
1919
import {RefObject} from '@react-types/shared';
@@ -68,11 +68,6 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void {
6868
}
6969
};
7070

71-
let cleanupGlobal = addEvent(window, 'scroll', onScroll, true);
72-
let cleanupShadow = addEvent(getShadowRoots(triggerRef.current), 'scroll', onScroll, true);
73-
return () => {
74-
cleanupGlobal();
75-
cleanupShadow();
76-
};
71+
return addEvent(getEventTargets(window, triggerRef.current), 'scroll', onScroll, true);
7772
}, [isOpen, onClose, triggerRef]);
7873
}

packages/react-aria-components/test/Select.browser.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ describe('Shadow DOM', () => {
112112

113113
// Scroll inside the shadow root.
114114
// Without the fix, window never sees this event (composed: false).
115-
// With the fix (getShadowRoots + addEvent), the shadow root listener closes the overlay.
115+
// With the fix (getEventTargets + addEvent), the shadow root listener closes the overlay.
116116
scrollable.dispatchEvent(new Event('scroll'));
117117
await new Promise<void>(resolve => setTimeout(resolve, 100));
118118

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export {
22
getEventTarget,
3-
getShadowRoots,
3+
getEventTargets,
44
nodeContains,
55
isFocusWithin,
66
getActiveElement

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

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import {addEvent} from '../utils/domHelpers';
14-
import {getEventTarget, getShadowRoots, nodeContains} from '../utils/shadowdom/DOMFunctions';
14+
import {getEventTarget, getEventTargets, nodeContains} from '../utils/shadowdom/DOMFunctions';
1515
import {RefObject} from '@react-types/shared';
1616
import {useEffect} from 'react';
1717

@@ -61,11 +61,6 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void {
6161
}
6262
};
6363

64-
let cleanupGlobal = addEvent(window, 'scroll', onScroll, true);
65-
let cleanupShadow = addEvent(getShadowRoots(triggerRef.current), 'scroll', onScroll, true);
66-
return () => {
67-
cleanupGlobal();
68-
cleanupShadow();
69-
};
64+
return addEvent(getEventTargets(window, triggerRef.current), 'scroll', onScroll, true);
7065
}, [isOpen, onClose, triggerRef]);
7166
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ export function isShadowRoot(node: Node | null): node is ShadowRoot {
3737

3838
/**
3939
* 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.
4047
*/
4148
export function addEvent<T extends EventTarget, K extends keyof EventMapType<Exclude<T, null>>>(
4249
target: T | EventTarget[] | null,

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

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -84,25 +84,38 @@ export function getEventTarget<T extends Event | SyntheticEvent>(event: T): Even
8484
}
8585

8686
/**
87-
* Collects the enclosing ShadowRoots between a node and the document.
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.
8890
*
89-
* Useful for attaching listeners for events that don't compose (e.g. scroll),
90-
* since those events do not propagate out of shadow roots even in the capture phase.
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`.
9195
*/
92-
export function getShadowRoots(node: Node | null | undefined): ShadowRoot[] {
93-
if (!shadowDOM()) {
94-
return [];
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;
95103
}
96104

97-
let roots: ShadowRoot[] = [];
98-
let current: Node | null = node?.getRootNode() ?? null;
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;
99112

100-
while (isShadowRoot(current)) {
101-
roots.push(current);
113+
while (isShadowRoot(current) && current !== globalRoot) {
114+
targets.push(current);
102115
current = current.host.getRootNode();
103116
}
104117

105-
return roots;
118+
return targets;
106119
}
107120

108121
/**

packages/react-aria/src/virtualizer/ScrollView.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import {addEvent} from '../utils/domHelpers';
1414
import {flushSync} from 'react-dom';
15-
import {getEventTarget, getShadowRoots, nodeContains} from '../utils/shadowdom/DOMFunctions';
15+
import {getEventTarget, getEventTargets, nodeContains} from '../utils/shadowdom/DOMFunctions';
1616
import {getScrollLeft} from './utils';
1717
import {Point, Rect, Size} from 'react-stately/useVirtualizerState';
1818
import React, {
@@ -221,12 +221,7 @@ export function useScrollView(
221221
// When inside a shadow DOM, also attach to each shadow root in the ancestor chain since scroll
222222
// events have composed: false and don't propagate out of shadow roots.
223223
useEffect(() => {
224-
let cleanupGlobal = addEvent(document, 'scroll', onScroll, true);
225-
let cleanupShadow = addEvent(getShadowRoots(ref.current), 'scroll', onScroll, true);
226-
return () => {
227-
cleanupGlobal();
228-
cleanupShadow();
229-
};
224+
return addEvent(getEventTargets(document, ref.current), 'scroll', onScroll, true);
230225
}, [onScroll, ref]);
231226

232227
useEffect(() => {

0 commit comments

Comments
 (0)