From 4ce0cf37b2604f31fd54eb3803796c7a97390d43 Mon Sep 17 00:00:00 2001 From: Om Singhal Date: Wed, 14 Oct 2026 22:34:55 -0400 Subject: [PATCH] fix: set `relatedTarget` when the pointer moves between elements `mouseout`, `mouseleave`, `pointerout` and `pointerleave` now carry the element the pointer moved to, and `mouseover`, `mouseenter`, `pointerover` and `pointerenter` carry the element it came from. React reads `relatedTarget` on `mouseout` to decide which components get `onMouseLeave`. With `null` it treated every move between elements as the pointer leaving the page, so moving from a parent into its child called the parent's `onMouseLeave`. If the element the pointer came from has been removed from the document, `relatedTarget` stays `null`. React ignores a `mouseover` whose `relatedTarget` is a node it rendered and waits for the matching `mouseout`, but the `mouseout` of a removed node never reaches React, so `onMouseEnter` would be lost. Fixes #1187 Fixes #1182 --- src/system/pointer/mouse.ts | 17 ++++++++++--- src/system/pointer/pointer.ts | 13 +++++++--- tests/convenience/hover.ts | 2 ++ tests/pointer/move.ts | 30 ++++++++++++++++++++++ tests/react/index.tsx | 47 +++++++++++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 8 deletions(-) diff --git a/src/system/pointer/mouse.ts b/src/system/pointer/mouse.ts index 885c742b..ec980c8d 100644 --- a/src/system/pointer/mouse.ts +++ b/src/system/pointer/mouse.ts @@ -89,14 +89,23 @@ export class Mouse { return { leave: () => { if (prevTarget !== nextTarget) { - instance.dispatchUIEvent(prevTarget, 'mouseout', init) - leave.forEach(el => instance.dispatchUIEvent(el, 'mouseleave', init)) + const leaveInit = {...init, relatedTarget: nextTarget} + instance.dispatchUIEvent(prevTarget, 'mouseout', leaveInit) + leave.forEach(el => + instance.dispatchUIEvent(el, 'mouseleave', leaveInit), + ) } }, enter: () => { if (prevTarget !== nextTarget) { - instance.dispatchUIEvent(nextTarget, 'mouseover', init) - enter.forEach(el => instance.dispatchUIEvent(el, 'mouseenter', init)) + const enterInit = { + ...init, + relatedTarget: prevTarget.isConnected ? prevTarget : null, + } + instance.dispatchUIEvent(nextTarget, 'mouseover', enterInit) + enter.forEach(el => + instance.dispatchUIEvent(el, 'mouseenter', enterInit), + ) } }, move: () => { diff --git a/src/system/pointer/pointer.ts b/src/system/pointer/pointer.ts index feed1c37..337102e7 100644 --- a/src/system/pointer/pointer.ts +++ b/src/system/pointer/pointer.ts @@ -65,9 +65,10 @@ export class Pointer { leave: () => { if (hasPointerEvents(instance, prevTarget)) { if (prevTarget !== nextTarget) { - instance.dispatchUIEvent(prevTarget, 'pointerout', init) + const leaveInit = {...init, relatedTarget: nextTarget} + instance.dispatchUIEvent(prevTarget, 'pointerout', leaveInit) leave.forEach(el => - instance.dispatchUIEvent(el, 'pointerleave', init), + instance.dispatchUIEvent(el, 'pointerleave', leaveInit), ) } } @@ -76,9 +77,13 @@ export class Pointer { assertPointerEvents(instance, nextTarget) if (prevTarget !== nextTarget) { - instance.dispatchUIEvent(nextTarget, 'pointerover', init) + const enterInit = { + ...init, + relatedTarget: prevTarget.isConnected ? prevTarget : null, + } + instance.dispatchUIEvent(nextTarget, 'pointerover', enterInit) enter.forEach(el => - instance.dispatchUIEvent(el, 'pointerenter', init), + instance.dispatchUIEvent(el, 'pointerenter', enterInit), ) } }, diff --git a/tests/convenience/hover.ts b/tests/convenience/hover.ts index fedd7b16..e5448839 100644 --- a/tests/convenience/hover.ts +++ b/tests/convenience/hover.ts @@ -15,6 +15,8 @@ describe.each([ for (const type of events) { expect(getEvents(`pointer${type}`)).toHaveLength(1) expect(getEvents(`mouse${type}`)).toHaveLength(1) + expect(getEvents(`pointer${type}`)[0].relatedTarget).toBe(document.body) + expect(getEvents(`mouse${type}`)[0].relatedTarget).toBe(document.body) } }) diff --git a/tests/pointer/move.ts b/tests/pointer/move.ts index 24aabead..6d8542ae 100644 --- a/tests/pointer/move.ts +++ b/tests/pointer/move.ts @@ -70,6 +70,36 @@ test('hover inside element', async () => { `) }) +test('set relatedTarget when moving between elements', async () => { + const {elements, getEvents, clearEventCalls, user} = setup(`

`) + + await user.pointer({target: elements[0]}) + clearEventCalls() + await user.pointer({target: elements[1]}) + + for (const type of ['pointer', 'mouse'] as const) { + expect(getEvents(`${type}out`)[0].relatedTarget).toBe(elements[1]) + expect(getEvents(`${type}leave`)[0].relatedTarget).toBe(elements[1]) + expect(getEvents(`${type}over`)[0].relatedTarget).toBe(elements[0]) + expect(getEvents(`${type}enter`)[0].relatedTarget).toBe(elements[0]) + } +}) + +test('set relatedTarget to null when the previous element was removed', async () => { + const {elements, getEvents, clearEventCalls, user} = setup(`

`) + const [a, p] = Array.from(elements) + + await user.pointer({target: a}) + a.remove() + clearEventCalls() + await user.pointer({target: p}) + + for (const type of ['pointer', 'mouse'] as const) { + expect(getEvents(`${type}over`)[0].relatedTarget).toBe(null) + expect(getEvents(`${type}enter`)[0].relatedTarget).toBe(null) + } +}) + test('move touch over elements', async () => { const {element, getEventSnapshot, user} = setup(`

`) diff --git a/tests/react/index.tsx b/tests/react/index.tsx index 0ba2e7b0..374f18cd 100644 --- a/tests/react/index.tsx +++ b/tests/react/index.tsx @@ -223,3 +223,50 @@ test('change select with delayed state update', async () => { expect(screen.getByRole('listbox')).toHaveValue(['Chrome', 'Firefox']) }) }) + +test('click a child that is only rendered while its parent is hovered', async () => { + const onClick = mocks.fn() + const onMouseLeave = mocks.fn() + + function Card() { + const [hovered, setHovered] = useState(false) + const handleLeave = () => { + onMouseLeave() + setHovered(false) + } + + return ( +
setHovered(true)} onMouseLeave={handleLeave}> + Card + {hovered && } +
+ ) + } + + render() + const user = userEvent.setup() + + await user.hover(screen.getByText('Card')) + await user.click(screen.getByRole('button')) + + expect(onMouseLeave).not.toHaveBeenCalled() + expect(onClick).toHaveBeenCalledTimes(1) + expect(screen.getByRole('button')).toBeInTheDocument() +}) + +test('call onMouseEnter after the previous hover target was removed', async () => { + const onMouseEnter = mocks.fn() + render( +
+ a + b +
, + ) + const user = userEvent.setup() + + await user.hover(screen.getByText('a')) + screen.getByText('a').remove() + await user.hover(screen.getByText('b')) + + expect(onMouseEnter).toHaveBeenCalledTimes(1) +})