@@ -255,25 +255,13 @@ const Tooltip = ({
255255
256256 if ( closeAfter && state === 'opened' ) {
257257 leaveTimer . current = setTimeout ( ( ) => {
258- const tooltipElement = popperInstance ?. popper . querySelector (
259- '[data-testid="tooltip-message-wrapper"]'
260- ) as HTMLElement ;
261-
262- if ( tooltipElement ) {
263- tooltipElement . addEventListener ( 'animationend' , ( ) =>
264- handleClose ( )
265- ) ;
266- } else {
267- handleClose ( ) ;
268- }
269-
270258 setState ( 'exiting' ) ;
271259 } , closeAfter ) ;
272260 } else {
273261 handleClose ( event ) ;
274262 }
275263 } ,
276- [ closeAfter , onBlur , onMouseLeave , handleClose , state , popperInstance ]
264+ [ closeAfter , onBlur , onMouseLeave , handleClose , state ]
277265 ) ;
278266
279267 useEffect ( ( ) => {
@@ -290,6 +278,60 @@ const Tooltip = ({
290278 }
291279 } , [ off , closeAfter , handleClose , state ] ) ;
292280
281+ // When entering 'exiting' state, drive the close via animationend on the
282+ // wrapper itself (scoped to event.target to ignore bubbled descendant events)
283+ // with a hard fallback so a missed/blocked animationend never wedges the tooltip.
284+ useEffect ( ( ) => {
285+ if ( state !== 'exiting' ) return ;
286+
287+ const tooltipElement = popperInstance ?. popper . querySelector (
288+ '[data-testid="tooltip-message-wrapper"]'
289+ ) as HTMLElement | null ;
290+
291+ if ( ! tooltipElement ) {
292+ handleClose ( ) ;
293+ return ;
294+ }
295+
296+ let closed = false ;
297+ const close = ( ) => {
298+ if ( closed ) return ;
299+ closed = true ;
300+ handleClose ( ) ;
301+ } ;
302+
303+ const onAnimEnd = ( event : AnimationEvent ) => {
304+ if ( event . target === tooltipElement ) close ( ) ;
305+ } ;
306+
307+ tooltipElement . addEventListener ( 'animationend' , onAnimEnd ) ;
308+ // growOut runs for 80 ms; 100 ms gives a small buffer before forcing close
309+ const fallback = setTimeout ( close , 100 ) ;
310+
311+ return ( ) => {
312+ tooltipElement . removeEventListener ( 'animationend' , onAnimEnd ) ;
313+ clearTimeout ( fallback ) ;
314+ } ;
315+ } , [ state , popperInstance , handleClose ] ) ;
316+
317+ // Native mouseleave fallback: if React's synthetic onMouseLeave is never
318+ // delivered (React 19 event-delegation timing), the tooltip would stay in
319+ // 'opened' indefinitely. A direct DOM listener on the wrapper catches the
320+ // same browser event that React missed and triggers the normal close path.
321+ useEffect ( ( ) => {
322+ if ( state !== 'opened' || off || isControlled ) return ;
323+
324+ const wrapperEl = reference . ref . current as HTMLElement | null ;
325+ if ( ! wrapperEl ) return ;
326+
327+ const onNativeLeave = ( event : MouseEvent ) => {
328+ handleLeave ( event as unknown as ChangeEvent ) ;
329+ } ;
330+
331+ wrapperEl . addEventListener ( 'mouseleave' , onNativeLeave ) ;
332+ return ( ) => wrapperEl . removeEventListener ( 'mouseleave' , onNativeLeave ) ;
333+ } , [ state , off , isControlled , reference . ref , handleLeave ] ) ;
334+
293335 const childrenProps = {
294336 // don't pass event listeners to children
295337 onFocus : null ,
0 commit comments