@@ -257,7 +257,11 @@ const Tooltip = ({
257257 leaveTimer . current = setTimeout ( ( ) => {
258258 setState ( 'exiting' ) ;
259259 } , closeAfter ) ;
260- } else {
260+ } else if ( state !== 'exiting' ) {
261+ // Don't interrupt growOut if the animation is already in progress.
262+ // onPopperLeave and a delayed synthetic mouseleave can both reach here
263+ // while exiting; calling handleClose() would abort the animation and
264+ // risk a stray mouseover re-opening the tooltip immediately after.
261265 handleClose ( event ) ;
262266 }
263267 } ,
@@ -318,19 +322,56 @@ const Tooltip = ({
318322 // delivered (React 19 event-delegation timing), the tooltip would stay in
319323 // 'opened' indefinitely. A direct DOM listener on the wrapper catches the
320324 // same browser event that React missed and triggers the normal close path.
325+ //
326+ // When the tooltip body is rendered in a portal (outside the React root),
327+ // moving the mouse from the wrapper onto the portal fires mouseleave on the
328+ // wrapper. We must not start the close timer in that case — otherwise the
329+ // tooltip closes, the portal disappears, the browser re-fires mouseenter on
330+ // the wrapper, and the tooltip reopens in a continuous flicker loop.
331+ //
332+ // use-popper uses callback refs (functions, not {current} objects), so
333+ // popper.ref.current / reference.ref.current are always undefined at runtime.
334+ // The DOM nodes are accessible via the Popper.js instance instead.
321335 useEffect ( ( ) => {
322336 if ( state !== 'opened' || off || isControlled ) return ;
323337
324- const wrapperEl = reference . ref . current as HTMLElement | null ;
338+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
339+ const inst = popperInstance as any ;
340+ const wrapperEl = inst ?. reference as HTMLElement | null ;
325341 if ( ! wrapperEl ) return ;
342+ const popperEl = inst ?. popper as HTMLElement | null ;
326343
327344 const onNativeLeave = ( event : MouseEvent ) => {
345+ // If the mouse moved into the popper/tooltip body, suppress the close so
346+ // the tooltip stays open while the user hovers over the preview.
347+ if (
348+ popperEl &&
349+ event . relatedTarget instanceof Node &&
350+ popperEl . contains ( event . relatedTarget )
351+ ) {
352+ return ;
353+ }
354+ handleLeave ( event as unknown as ChangeEvent ) ;
355+ } ;
356+
357+ // Mirror: close when the mouse leaves the tooltip body itself.
358+ const onPopperLeave = ( event : MouseEvent ) => {
359+ if (
360+ wrapperEl . contains ( event . relatedTarget as Node | null ) ||
361+ popperEl ?. contains ( event . relatedTarget as Node | null )
362+ ) {
363+ return ;
364+ }
328365 handleLeave ( event as unknown as ChangeEvent ) ;
329366 } ;
330367
331368 wrapperEl . addEventListener ( 'mouseleave' , onNativeLeave ) ;
332- return ( ) => wrapperEl . removeEventListener ( 'mouseleave' , onNativeLeave ) ;
333- } , [ state , off , isControlled , reference . ref , handleLeave ] ) ;
369+ popperEl ?. addEventListener ( 'mouseleave' , onPopperLeave ) ;
370+ return ( ) => {
371+ wrapperEl . removeEventListener ( 'mouseleave' , onNativeLeave ) ;
372+ popperEl ?. removeEventListener ( 'mouseleave' , onPopperLeave ) ;
373+ } ;
374+ } , [ state , off , isControlled , popperInstance , handleLeave ] ) ;
334375
335376 const childrenProps = {
336377 // don't pass event listeners to children
0 commit comments