Skip to content

Commit f06a277

Browse files
authored
[all components] Fix synthetic event target regressions (#4516)
1 parent e456b4b commit f06a277

8 files changed

Lines changed: 292 additions & 11 deletions

File tree

packages/react/src/composite/root/CompositeRoot.test.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,58 @@ describe('Composite', () => {
100100
expect(item1).toHaveFocus();
101101
});
102102

103+
it('keeps native input behavior when the native target differs from the synthetic target', async () => {
104+
render(
105+
<CompositeRoot orientation="horizontal">
106+
<CompositeItem data-testid="1">1</CompositeItem>
107+
<div data-testid="host" />
108+
<CompositeItem data-testid="2">2</CompositeItem>
109+
</CompositeRoot>,
110+
);
111+
112+
const item1 = screen.getByTestId('1');
113+
const item2 = screen.getByTestId('2');
114+
const host = screen.getByTestId('host');
115+
const input = document.createElement('input');
116+
117+
input.type = 'text';
118+
input.value = 'abcd';
119+
input.setSelectionRange(2, 2);
120+
121+
const focusEvent = new FocusEvent('focusin', { bubbles: true });
122+
Object.defineProperty(focusEvent, 'composedPath', {
123+
configurable: true,
124+
value: () => [input, host],
125+
});
126+
127+
fireEvent(host, focusEvent);
128+
129+
// Focusing a native input within a composite selects the whole value so
130+
// the first arrow key returns control to the textbox before moving focus.
131+
expect(input.selectionStart).toBe(0);
132+
expect(input.selectionEnd).toBe(4);
133+
134+
act(() => item1.focus());
135+
136+
input.setSelectionRange(1, 1);
137+
138+
const keyDownEvent = new KeyboardEvent('keydown', {
139+
key: 'ArrowRight',
140+
bubbles: true,
141+
cancelable: true,
142+
});
143+
Object.defineProperty(keyDownEvent, 'composedPath', {
144+
configurable: true,
145+
value: () => [input, host],
146+
});
147+
148+
fireEvent(host, keyDownEvent);
149+
await flushMicrotasks();
150+
151+
expect(item1).toHaveFocus();
152+
expect(item2).not.toHaveFocus();
153+
});
154+
103155
it.skipIf(isJSDOM)('updates the order of items', async () => {
104156
function App(props: { items: string[] }) {
105157
return (

packages/react/src/floating-ui-react/hooks/useHover.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,50 @@ describe.skipIf(!isJSDOM)('useHover', () => {
275275
fireEvent.mouseLeave(button);
276276
});
277277

278+
test('does not treat a synthetic child target as inactive when the native path differs', async () => {
279+
const onOpenChange = vi.fn();
280+
281+
function App() {
282+
const [open, setOpen] = React.useState(true);
283+
const { refs, context } = useFloating({
284+
open,
285+
onOpenChange(nextOpen, details) {
286+
onOpenChange(nextOpen, details);
287+
setOpen(nextOpen);
288+
},
289+
});
290+
const { getReferenceProps, getFloatingProps } = useInteractions([useHover(context)]);
291+
292+
return (
293+
<React.Fragment>
294+
<button ref={refs.setReference} {...getReferenceProps()}>
295+
<span data-testid="child" />
296+
</button>
297+
{open && <div role="tooltip" ref={refs.setFloating} {...getFloatingProps()} />}
298+
</React.Fragment>
299+
);
300+
}
301+
302+
render(<App />);
303+
304+
const child = screen.getByTestId('child');
305+
const event = new MouseEvent('mousemove', { bubbles: true });
306+
307+
// Deliberately skew the native path so `getTarget(nativeEvent)` resolves
308+
// outside the trigger while React's synthetic `event.target` remains `child`.
309+
Object.defineProperty(event, 'composedPath', {
310+
configurable: true,
311+
value: () => [document.body, child.parentElement, child],
312+
});
313+
314+
fireEvent(child, event);
315+
316+
await flushMicrotasks();
317+
318+
expect(onOpenChange).toHaveBeenCalledTimes(0);
319+
expect(screen.queryByRole('tooltip')).not.toBe(null);
320+
});
321+
278322
test('cleans up blockPointerEvents if trigger changes', async () => {
279323
vi.useRealTimers();
280324
const user = userEvent.setup();

packages/react/src/floating-ui-react/hooks/useHover.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,7 @@ export function useHover(
457457
// wasn't used to open the floating element.
458458
const isOverInactiveTrigger =
459459
store.select('domReferenceElement') &&
460-
!contains(store.select('domReferenceElement'), getTarget(nativeEvent) as Element);
460+
!contains(store.select('domReferenceElement'), event.target as Element);
461461

462462
function handleMouseMove() {
463463
if (!blockMouseMoveRef.current && (!store.select('open') || isOverInactiveTrigger)) {

packages/react/src/floating-ui-react/hooks/useHoverReferenceInteraction.test.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,78 @@ describe.skipIf(!isJSDOM)('useHoverReferenceInteraction', () => {
6262
expect(screen.queryByRole('tooltip')).not.toBe(null);
6363
});
6464

65+
it('does not treat a synthetic child target as inactive when the native path differs', async () => {
66+
const onOpenChange = vi.fn();
67+
68+
function App() {
69+
const [open, setOpen] = React.useState(true);
70+
const triggerElementRef = React.useRef<Element | null>(null);
71+
const { refs, context } = useFloating({
72+
open,
73+
onOpenChange(nextOpen, details) {
74+
onOpenChange(nextOpen, details);
75+
setOpen(nextOpen);
76+
},
77+
});
78+
79+
const hoverProps = useHoverReferenceInteraction(context, {
80+
mouseOnly: true,
81+
restMs: 100,
82+
delay: { close: 0 },
83+
move: false,
84+
triggerElementRef,
85+
});
86+
87+
return (
88+
<React.Fragment>
89+
<div data-testid="wrapper" {...hoverProps}>
90+
<button
91+
data-testid="trigger"
92+
ref={(node) => {
93+
refs.setReference(node);
94+
triggerElementRef.current = node;
95+
}}
96+
>
97+
<span data-testid="child" />
98+
</button>
99+
</div>
100+
{open && <div role="tooltip" ref={refs.setFloating} />}
101+
</React.Fragment>
102+
);
103+
}
104+
105+
render(<App />);
106+
107+
const wrapper = screen.getByTestId('wrapper');
108+
const child = screen.getByTestId('child');
109+
110+
fireEvent.pointerEnter(wrapper, { pointerType: 'mouse' });
111+
fireEvent.mouseEnter(wrapper);
112+
113+
const event = new MouseEvent('mousemove', { bubbles: true });
114+
Object.defineProperties(event, {
115+
composedPath: {
116+
configurable: true,
117+
value: () => [document.body, child, wrapper],
118+
},
119+
movementX: {
120+
configurable: true,
121+
value: 10,
122+
},
123+
movementY: {
124+
configurable: true,
125+
value: 0,
126+
},
127+
});
128+
129+
fireEvent(child, event);
130+
131+
await flushMicrotasks();
132+
133+
expect(onOpenChange).toHaveBeenCalledTimes(0);
134+
expect(screen.queryByRole('tooltip')).not.toBe(null);
135+
});
136+
65137
it('treats disabled child trigger as inactive in wrapper fallback mode', async () => {
66138
const onOpenChange = vi.fn();
67139

packages/react/src/floating-ui-react/hooks/useHoverReferenceInteraction.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -386,11 +386,7 @@ export function useHoverReferenceInteraction(
386386

387387
const currentDomReference = store.select('domReferenceElement');
388388
const currentOpen = store.select('open');
389-
const isOverInactive = isOverInactiveTrigger(
390-
currentDomReference,
391-
trigger,
392-
getTarget(nativeEvent),
393-
);
389+
const isOverInactive = isOverInactiveTrigger(currentDomReference, trigger, event.target);
394390

395391
if (mouseOnly && !isMouseLikePointerType(instance.pointerType)) {
396392
return;

packages/react/src/scroll-area/root/ScrollAreaRoot.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { ScrollAreaScrollbarDataAttributes } from '../scrollbar/ScrollAreaScroll
1212
import { styleDisableScrollbar } from '../../utils/styles';
1313
import { useBaseUiId } from '../../utils/useBaseUiId';
1414
import { scrollAreaStateAttributesMapping } from './stateAttributes';
15-
import { contains, getTarget } from '../../floating-ui-react/utils';
15+
import { contains } from '../../floating-ui-react/utils';
1616
import { useCSPContext } from '../../csp-provider/CSPContext';
1717

1818
const DEFAULT_COORDS = { x: 0, y: 0 };
@@ -202,7 +202,7 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot(
202202
handleTouchModalityChange(event);
203203

204204
if (event.pointerType !== 'touch') {
205-
const isTargetRootChild = contains(rootRef.current, getTarget(event.nativeEvent) as Element);
205+
const isTargetRootChild = contains(rootRef.current, event.target as Element);
206206
setHovering(isTargetRootChild);
207207
}
208208
}

packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.test.tsx

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,119 @@ describe('<ScrollArea.Scrollbar />', () => {
7777
});
7878
});
7979

80+
describe('data-hovering attribute', () => {
81+
it('adds [data-hovering] when the synthetic pointer target differs from the native path', async () => {
82+
await render(
83+
<ScrollArea.Root data-testid="root" style={{ width: 200, height: 200 }}>
84+
<ScrollArea.Viewport data-testid="viewport" style={{ width: '100%', height: '100%' }}>
85+
<div style={{ width: 1000, height: 1000 }} />
86+
</ScrollArea.Viewport>
87+
<ScrollArea.Scrollbar orientation="vertical" data-testid="vertical" keepMounted />
88+
</ScrollArea.Root>,
89+
);
90+
91+
const viewport = screen.getByTestId('viewport');
92+
const verticalScrollbar = screen.getByTestId('vertical');
93+
94+
// Real browser runs can start with the viewport already hovered because
95+
// ScrollAreaViewport syncs `:hover` on mount.
96+
fireEvent.pointerLeave(viewport, { pointerType: 'mouse' });
97+
expect(verticalScrollbar).not.toHaveAttribute('data-hovering');
98+
99+
const PointerEventCtor = window.PointerEvent ?? window.Event;
100+
const event = new PointerEventCtor('pointerover', {
101+
bubbles: true,
102+
});
103+
104+
Object.defineProperties(event, {
105+
composedPath: {
106+
configurable: true,
107+
value: () => [document.body, viewport],
108+
},
109+
pointerType: {
110+
configurable: true,
111+
value: 'mouse',
112+
},
113+
});
114+
115+
fireEvent(viewport, event);
116+
117+
expect(verticalScrollbar).toHaveAttribute('data-hovering', '');
118+
119+
fireEvent.pointerLeave(viewport, { pointerType: 'mouse' });
120+
121+
expect(verticalScrollbar).not.toHaveAttribute('data-hovering');
122+
});
123+
});
124+
125+
describe('track pointer down', () => {
126+
it('ignores thumb clicks when the native path differs from the synthetic target', async () => {
127+
await render(
128+
<ScrollArea.Root style={{ width: 200, height: 200 }}>
129+
<ScrollArea.Viewport data-testid="viewport" style={{ width: '100%', height: '100%' }}>
130+
<div style={{ width: 1000, height: 1000 }} />
131+
</ScrollArea.Viewport>
132+
<ScrollArea.Scrollbar orientation="vertical" data-testid="vertical" keepMounted>
133+
<ScrollArea.Thumb data-testid="thumb" />
134+
</ScrollArea.Scrollbar>
135+
</ScrollArea.Root>,
136+
);
137+
138+
const viewport = screen.getByTestId('viewport') as HTMLDivElement;
139+
const verticalScrollbar = screen.getByTestId('vertical');
140+
const thumb = screen.getByTestId('thumb');
141+
142+
Object.defineProperties(viewport, {
143+
clientHeight: {
144+
configurable: true,
145+
value: 200,
146+
},
147+
scrollHeight: {
148+
configurable: true,
149+
value: 1000,
150+
},
151+
scrollTop: {
152+
configurable: true,
153+
writable: true,
154+
value: 0,
155+
},
156+
});
157+
158+
Object.defineProperties(verticalScrollbar, {
159+
offsetHeight: {
160+
configurable: true,
161+
value: 200,
162+
},
163+
getBoundingClientRect: {
164+
configurable: true,
165+
value: () => ({
166+
top: 0,
167+
}),
168+
},
169+
});
170+
171+
Object.defineProperty(thumb, 'offsetHeight', {
172+
configurable: true,
173+
value: 40,
174+
});
175+
176+
const event = new MouseEvent('pointerdown', {
177+
bubbles: true,
178+
button: 0,
179+
clientY: 160,
180+
});
181+
182+
Object.defineProperty(event, 'composedPath', {
183+
configurable: true,
184+
value: () => [thumb, verticalScrollbar],
185+
});
186+
187+
fireEvent(verticalScrollbar, event);
188+
189+
expect(viewport.scrollTop).toBe(0);
190+
});
191+
});
192+
80193
describe.skipIf(isJSDOM)('data overflow attributes (scrollbars)', () => {
81194
const VIEWPORT_SIZE = 200;
82195
const SCROLLABLE_CONTENT_SIZE = 1000;

packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22
import * as React from 'react';
3-
import { getTarget } from '../../floating-ui-react/utils';
43
import type { BaseUIComponentProps, HTMLProps } from '../../utils/types';
4+
import { contains, getTarget } from '../../floating-ui-react/utils';
55
import { useScrollAreaRootContext } from '../root/ScrollAreaRootContext';
66
import { ScrollAreaScrollbarContext } from './ScrollAreaScrollbarContext';
77
import { useRenderElement } from '../../utils/useRenderElement';
@@ -126,8 +126,12 @@ export const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar
126126
return;
127127
}
128128

129-
// Ignore clicks on thumb
130-
if (event.currentTarget !== getTarget(event.nativeEvent)) {
129+
const target = getTarget(event.nativeEvent) as Element | null;
130+
const thumb = orientation === 'vertical' ? thumbYRef.current : thumbXRef.current;
131+
132+
// Ignore clicks on thumb, including cases where React retargets the
133+
// synthetic event to the track host across a shadow boundary.
134+
if (thumb && contains(thumb, target)) {
131135
return;
132136
}
133137

0 commit comments

Comments
 (0)