Repository navigation
[BUG] When using default rt-transition-show-delay, tooltip stays in DOM after visibly closing #1144
Description
Bug description
The default rt-transition-show-delay is 150ms. When flicking the cursor over a tooltip quickly enough, the mouseover event fires, but the CSS transitions never fire, leaving the tooltip in the DOM in a __closing state.
When the tooltip is in this state, its position is still being recalculated when scrolling or resizing. If the element is scrolled out of view (e.g. an overflow: scroll parent), an infinite rerender loop occurs in the Tooltip component.
Version of Package
v5.25.0
To Reproduce
-
Clean install this repository (picking up the latest
@floating-ui/dom) -
Replace
index-dev.tsxwith this:import { TooltipController as Tooltip } from "components/TooltipController"; import React from "react"; import ReactDOM from "react-dom"; import "./tokens.css"; const TOOLTIP_ID = "tooltip"; function App() { return ( <> <div style={{ display: "flex", alignItems: "flex-start", }} > <div style={{ maxHeight: "100vh", overflowY: "scroll", }} > <div style={{ width: "300px", height: "500px", border: "1px solid gray", }} > <span data-tooltip-id={TOOLTIP_ID} data-tooltip-content={TOOLTIP_ID} style={{ border: "1px solid red", fontSize: "30px" }} > anchor </span> </div> </div> <div>sidebar content</div> </div> <Tooltip id={TOOLTIP_ID} /> </> ); } ReactDOM.render(<App />, document.getElementById("app"));
-
Apply this patch to litter
console.log()everywhere inTooltip.tsx- Save this as
tooltip.patchin the root of the repo, then rungit apply tooltip.patch:
diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 3f3b8641..138482c4 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -166,23 +166,33 @@ const Tooltip = ({ } }, []) + const handleShowNth = useRef(0) + const handleShow = (value: boolean) => { + const i = handleShowNth.current++ + console.log(`handleShow(${value}) [i:${i}]`) if (!mounted.current) { + console.log(`handleShow(${value}) [i:${i}]: not mounted so return`) return } if (value) { + console.log(`handleShow(${value}) [i:${i}]: value is truthy -> setRendered(true)`) setRendered(true) } + console.log(`handleShow(${value}) [i:${i}]: setTimeout for 10ms`) /** * wait for the component to render and calculate position * before actually showing */ setTimeout(() => { if (!mounted.current) { + console.log(`handleShow(${value}) [i:${i}], timeout: not mounted so return`) return } + console.log(`handleShow(${value}) [i:${i}], timeout: setIsOpen?.(${value})`) setIsOpen?.(value) if (isOpen === undefined) { + console.log(`handleShow(${value}) [i:${i}], isOpen is undefined so setShow(${value})`) setShow(value) } }, 10) @@ -197,6 +207,7 @@ const Tooltip = ({ return () => null } if (isOpen) { + console.log('useEffect[isOpen]: !!isOpen -> setRendered(true)') setRendered(true) } const timeout = setTimeout(() => { @@ -227,6 +238,7 @@ const Tooltip = ({ } tooltipShowDelayTimerRef.current = setTimeout(() => { + console.log('handleShowTooltipDelayed -> handleShow(true)') handleShow(true) }, delay) } @@ -240,6 +252,7 @@ const Tooltip = ({ if (hoveringTooltip.current) { return } + console.log('handleHideTooltipDelayed -> handleShow(false)') handleShow(false) }, delay) } @@ -259,8 +272,10 @@ const Tooltip = ({ return } if (delayShow) { + console.log('handleShowTooltip -> handleShowTooltipDelayed()') handleShowTooltipDelayed() } else { + console.log('handleShowTooltip -> handleShow(true)') handleShow(true) } setActiveAnchor(target) @@ -274,10 +289,13 @@ const Tooltip = ({ const handleHideTooltip = () => { if (clickable) { // allow time for the mouse to reach the tooltip, in case there's a gap + console.log('handleHideTooltip clickable -> handleHideTooltipDelayed(delayHide || 100)') handleHideTooltipDelayed(delayHide || 100) } else if (delayHide) { + console.log('handleHideTooltip delayHide -> handleHideTooltipDelayed()') handleHideTooltipDelayed() } else { + console.log('handleHideTooltip -> handleShow(false)') handleShow(false) } @@ -287,6 +305,7 @@ const Tooltip = ({ } const handleTooltipPosition = ({ x, y }: IPosition) => { + console.log(`handleTooltipPosition({x: ${x}, y: ${y}})`) const virtualElement = { getBoundingClientRect() { return { @@ -347,6 +366,7 @@ const Tooltip = ({ if (anchors.some((anchor) => anchor?.contains(target))) { return } + console.log('handleClickOutsideAnchors -> handleShow(false)') handleShow(false) if (tooltipShowDelayTimerRef.current) { clearTimeout(tooltipShowDelayTimerRef.current) @@ -384,6 +404,8 @@ const Tooltip = ({ return } + // console.log(`updateTooltipPosition: recompute tooltip position`) + computeTooltipPosition({ place: imperativeOptions?.place ?? place, offset, @@ -399,9 +421,11 @@ const Tooltip = ({ return } if (Object.keys(computedStylesData.tooltipStyles).length) { + // console.log(`updateTooltipPosition: set inline styles`) setInlineStyles(computedStylesData.tooltipStyles) } if (Object.keys(computedStylesData.tooltipArrowStyles).length) { + // console.log(`updateTooltipPosition: arrrow styles inline styles`) setInlineArrowStyles(computedStylesData.tooltipArrowStyles) } setActualPlacement(computedStylesData.place as PlacesType) @@ -433,6 +457,7 @@ const Tooltip = ({ } const handleScrollResize = () => { + console.log('handleScrollResize -> handleShow(false)') handleShow(false) } @@ -464,6 +489,7 @@ const Tooltip = ({ if (event.key !== 'Escape') { return } + console.log('handleEsc -> handleShow(false)') handleShow(false) } if (actualGlobalCloseEvents.escape) { @@ -497,8 +523,16 @@ const Tooltip = ({ return } if (regularEvents.includes(event)) { - enabledEvents.push({ event, listener: debouncedHandleShowTooltip }) + console.log('enable open handler for event ', event) + enabledEvents.push({ + event, + listener: (e?: Event) => { + console.log(`event ${event} -> debouncedHandleShowTooltip()`) + return (debouncedHandleShowTooltip as any)(e) + }, + }) } else if (clickEvents.includes(event)) { + console.log('enable click open handler for event ', event) enabledEvents.push({ event, listener: handleClickOpenTooltipAnchor }) } else { // never happens @@ -510,8 +544,16 @@ const Tooltip = ({ return } if (regularEvents.includes(event)) { - enabledEvents.push({ event, listener: debouncedHandleHideTooltip }) + console.log('enable close handler for event ', event) + enabledEvents.push({ + event, + listener: (e?: Event) => { + console.log(`event ${event} -> debouncedHandleHideTooltip()`) + return (debouncedHandleHideTooltip as any)(e) + }, + }) } else if (clickEvents.includes(event)) { + console.log('enable click close handler for event ', event) enabledEvents.push({ event, listener: handleClickCloseTooltipAnchor }) } else { // never happens @@ -519,9 +561,13 @@ const Tooltip = ({ }) if (float) { + console.log('enable mousemove event because floating') enabledEvents.push({ event: 'mousemove', - listener: handleMouseMove, + listener: (e?: Event) => { + console.log(`event mousemove (because float) -> handleMouseMove(e)`) + return handleMouseMove(e) + }, }) } @@ -530,6 +576,7 @@ const Tooltip = ({ } const handleMouseLeaveTooltip = () => { hoveringTooltip.current = false + console.log(`handleMouseLeaveTooltip (because clickable) -> handleHideTooltip(e)`) handleHideTooltip() } @@ -547,6 +594,7 @@ const Tooltip = ({ }) return () => { + console.log('remove event handlers') if (actualGlobalCloseEvents.scroll) { window.removeEventListener('scroll', handleScrollResize) anchorScrollParent?.removeEventListener('scroll', handleScrollResize) @@ -634,6 +682,10 @@ const Tooltip = ({ } elements.some((node) => { if (node?.contains?.(activeAnchor)) { + console.log( + 'mutationObserver: node some element contains activeAnchor -> setRendered(false)', + ) + console.log('mutationObserver -> handleShow(false)') setRendered(false) handleShow(false) setActiveAnchor(null) @@ -771,6 +823,7 @@ const Tooltip = ({ if (options?.delay) { handleShowTooltipDelayed(options.delay) } else { + console.log('useImperativeHandle.open() -> handleShow(true)') handleShow(true) } }, @@ -778,6 +831,7 @@ const Tooltip = ({ if (options?.delay) { handleHideTooltipDelayed(options.delay) } else { + console.log('useImperativeHandle.close() -> handleShow(false)') handleShow(false) } }, @@ -786,6 +840,7 @@ const Tooltip = ({ isOpen: Boolean(rendered && !hidden && actualContent && canShow), })) + console.log(`rendered: ${rendered}, hidden: ${hidden}, actualContent: ${actualContent}`) return rendered && !hidden && actualContent ? ( <WrapperElement id={id} @@ -807,9 +862,13 @@ const Tooltip = ({ * @warning if `--rt-transition-closing-delay` is set to 0, * the tooltip will be stuck (but not visible) on the DOM */ + if (event.propertyName === 'opacity') { + console.log('onTransitionEnd, opacity, show: ', show) + } if (show || event.propertyName !== 'opacity') { return } + console.log('onTransitionEnd for real -> setRendered(false)') setRendered(false) setImperativeOptions(null) afterHide?.()
- Save this as
-
Start dev server with
yarn dev -
Open page
-
Open devtools' Inspector/Elements tab, expand
#app -
Press ESC to open console drawer below
-
Resize your window so that your cursor can move up over the browser's window
-
Move your mouse ~100px below the red bordered anchor element
-
Quickly flick your cursor up beyond the browser's window, hitting the anchor element on the way
-
Notice that the tooltip is not visible, but is found in the DOM
-
Now carefully move your cursor into the anchor's scroll container (gray border) and scroll down
- Don't hit the anchor element with your cursor during this
-
Notice that your console is now full of
console.log()from rerenders -
If you grab a profile with React Dev Tools, you'll see that the Tooltip component rerenders due to hooks 6 and 7 changing, which are
inlineStylesandinlineArrowStylesrespectfully- The infinite loop comes from
handleTooltipPositioncalled byupdateTooltipPositionwhere the floating-ui computePosition function gets repeatedly called by theuseEffect(() => updateTooltipPosition()), ...)hook, where floating-ui creates a new IntersectionObserver that gets immediately invoked - The commented out
console.log('updateTooltipPosition ...)lines reveals where it happens, and a debugger breakpoint reveals what happens inside
- The infinite loop comes from
-
Change
--rt-transition-show-delayto0- Now I am unable to reproduce this issue, as the transition is triggered correctly
I am unsure if it is this library, React, or possibly browser which skips the transition if the state changes happen too quickly. Perhaps React batches the quick state updates and the DOM opacity transition never begins?
Expected behavior
I'd expect the tooltip to get removed from the DOM.
Screenshots
Desktop:
- OS: macOS Sonoma
- Browser: Firefox 121.0, Chrome 120
- Frameworks: React 16, React 18 (both non-concurrent and concurrent mode)
@floating-ui/dom:1.5.1
Additional context
-
I don't know if it's related, but
@floating-ui/domv1.4.3 performed some changes to their state handling when fixing aResizeObserverbug. I have not yet tested if this reproduces with an earlier version of that library. -
The console log output for me when I perform this on Firefox is the following:
event mouseenter -> debouncedHandleShowTooltip() handleShowTooltip -> handleShow(true) handleShow(true) [i:0] handleShow(true) [i:0]: value is truthy -> setRendered(true) rendered: true, hidden: false, actualContent: tooltip handleShow(true) [i:0]: setTimeout for 10ms event mouseleave -> debouncedHandleHideTooltip() handleHideTooltip -> handleShow(false) handleShow(false) [i:1] handleShow(false) [i:1]: setTimeout for 10ms remove event handlers enable open handler for event mouseenter enable open handler for event focus enable close handler for event mouseleave enable close handler for event blur rendered: true, hidden: false, actualContent: tooltip (repeated 3 times) handleShow(true) [i:0], timeout: setIsOpen?.(true) handleShow(true) [i:0], isOpen is undefined so setShow(true) rendered: true, hidden: false, actualContent: tooltip remove event handlers enable open handler for event mouseenter enable open handler for event focus enable close handler for event mouseleave enable close handler for event blur rendered: true, hidden: false, actualContent: tooltip (repeated 12 times) handleShow(false) [i:1], timeout: setIsOpen?.(false) handleShow(false) [i:1], isOpen is undefined so setShow(false) rendered: true, hidden: false, actualContent: tooltip remove event handlers enable open handler for event mouseenter enable open handler for event focus enable close handler for event mouseleave enable close handler for event blur rendered: true, hidden: false, actualContent: tooltip (repeated 255 times)- When the DOM does get cleaned up correctly (when I hover for long enough that the opacity transition begins, but not long enough for it to fully complete), the output is the following:
event mouseenter -> debouncedHandleShowTooltip() handleShowTooltip -> handleShow(true) handleShow(true) [i:0] handleShow(true) [i:0]: value is truthy -> setRendered(true) rendered: true, hidden: false, actualContent: tooltip handleShow(true) [i:0]: setTimeout for 10ms remove event handlers enable open handler for event mouseenter enable open handler for event focus enable close handler for event mouseleave enable close handler for event blur rendered: true, hidden: false, actualContent: tooltip (repeated 6 times) handleShow(true) [i:0], timeout: setIsOpen?.(true) handleShow(true) [i:0], isOpen is undefined so setShow(true) rendered: true, hidden: false, actualContent: tooltip remove event handlers enable open handler for event mouseenter enable open handler for event focus enable close handler for event mouseleave enable close handler for event blur rendered: true, hidden: false, actualContent: tooltip (repeated 12 times) event mouseleave -> debouncedHandleHideTooltip() handleHideTooltip -> handleShow(false) handleShow(false) [i:1] handleShow(false) [i:1]: setTimeout for 10ms handleShow(false) [i:1], timeout: setIsOpen?.(false) handleShow(false) [i:1], isOpen is undefined so setShow(false) rendered: true, hidden: false, actualContent: tooltip remove event handlers enable open handler for event mouseenter enable open handler for event focus enable close handler for event mouseleave enable close handler for event blur rendered: true, hidden: false, actualContent: tooltip (repeated 12 times) onTransitionEnd, opacity, show: false onTransitionEnd for real -> setRendered(false) rendered: false, hidden: false, actualContent: tooltip remove event handlers enable open handler for event mouseenter enable open handler for event focus enable close handler for event mouseleave enable close handler for event blur rendered: false, hidden: false, actualContent: tooltip
- When the DOM does get cleaned up correctly (when I hover for long enough that the opacity transition begins, but not long enough for it to fully complete), the output is the following:
Activity
gabrieljablonski commentedon Dec 27, 2023
Although the
.patchyou've provided isn't working (gettingerror: corrupt patch at line 7, if you could check why I'd appreciate it), I've managed to reproduce the tooltip getting "stuck" on the DOM.The obvious reason is the component is "missing" the
mouseleaveevent, leaving the tooltip in a weird state. I'd expect the tooltip to also stay open if that was simply the case, so there has to be something more to this.Thanks for your effort on this, we'll be investigating further and get back to you with any findings.
One side note about point 14, could you elaborate on what you mean by "infinite loop"? As far as I can tell, if we pretend the tooltip should be open (you can just set
isOpen={true}, which is a valid use case)updateTooltipPosition()is getting called as expected, since it has to update the tooltip position on scroll.The reason that happens is because the tooltip is placed using
position: absolute, so by default, it is placed relative to the nearest ancestor with a setpositionattribute. Since in this example there are none, it defaults to the roothtmltag. So, when scrolling, the tooltip has to update itstopandleftCSS attributes.To verify that's the case, try moving the tooltip component directly inside the scrollable element, and setting
position: relativeto it. My guess is there won't be any logs forupdateTooltipPosition()calls.If this is not a correct assessment of what you're describing as "infinite loop", please provide more details.
gabrieljablonski commentedon Dec 27, 2023
@johannkor please take a look at #1145. Also, the beta version
react-tooltip@5.25.1-beta.1145.0should fix this.In summary, we were relying on
onTransitionEndalways firing when the tooltip closes (i.e. opacity changes to 0). When the value forshowchanges too quickly, it doesn't give enough time for the different styles to be applied to the tooltip, so the transition never fires, leaving it stuck on the DOM.Reminder to also take a look at my comment above about your point 14 and what you're calling an "infinite loop", since I might've missed something in your explanation.
gabrieljablonski commentedon Dec 27, 2023
Official release
react-tooltip@5.25.1fixes this and some other stuff.Same deal as #1136, if you feel there's still something to improve here, feel free to reopen.
And thanks again for the effort in setting up the examples! They help a lot with debugging.
johannkor commentedon Dec 28, 2023
I'm in awe of how quickly you've responded to these issues. Thank you very much for your help. I like the library as it saves me a lot of work so it is nice to be able to contribute back to it.
Regarding the patch formatting, here is one that works: https://gist.githubusercontent.com/johannkor/9d23f2366e6646c2fc9eaed61557d07e/raw/515d8f38677c85399ca2afb3b46de7cbb4ae0b00/tooltip.patch - please
git checkout v5.25.0before applying it.Regarding the infinite loop, I mean that the Tooltip component continuously triggers a render, which triggers a useEffect, which triggers a render via a setState, which triggers a useEffect, etc.
This loop continues without any user input. Here's a screen capture.
out.mp4
gabrieljablonski commentedon Dec 28, 2023
I've had some free time the last few days, and the details you've provided made it a lot easier to track down the issue, so thanks for that 😅
And it seems you're right about the infinite loop. Either I wasn't able to reproduce it when testing the other stuff, or I just didn't notice it. I'll reopen this for now, and try again later with the patch you've provided, and we'll see how difficult this will be to fix. Though I should add, this might not be an issue anymore since the "stuck on the DOM" got fixed, but I'll have to make sure.
johannkor commentedon Dec 28, 2023
I've been trying to reproduce the issue with
v5.25.1but the "hidden but stays in DOM" issue appears to have been resolved.I have unfortunately found a different race condition, which leaves the tooltip both in the DOM and visible. When the tooltip is open in this state, scrolling the scroll container downwards so that the tooltip's element moves up into the overflow's hidden area starts the infinite render loop as well. Scrolling it back down so it's visible stops the loop and the position is updated (and rerendered) only when necessary after a scroll.
This was somewhat tricky to get to reproduce, but I found a somewhat reproducible cursor movement pattern. I rendered a list of items with the anchor being the red boxes, and moved my cursor somewhat like this very quickly:
The tooltip then stayed open:
I sprinkled in more logging this time. The log is in this gist, but I took a screenshot of it which is in the expandable (►) block below. The log is color coded like this:

The number on the left is "milliseconds since module initialization" to help understand where things happened synchronously and where after a delay.
expand log screenshot
I did not reason through the log yet, as I wanted to share any details as quickly as possible.
I have generated a .patch which adds all this logging, and changes the React version to 18 (to check this isn't just a React 16 thing) here: https://gist.githubusercontent.com/johannkor/92e6b94884cc182ca70034bff931d93f/raw/f2a2fa5115f85ce4ab63ba9e575bdd306f9d3e74/0001-log-debug-patch.patch
To apply:
v5.25.1, ensure working directory is clean and has no changes (git reset --hardorgit checkout -- .)0001-log-debug-patch.patchinto the repo's directory, it is a text file you can inspect with an editorgit apply --verbose 0001-log-debug-patch.patchgit statusandgit diffto see what changedyarnto pick up the new react version, then justyarn devjohannkor commentedon Dec 28, 2023
The rerender loop appears to be a separate issue from the issue above. I was able to reproduce it with just
tooltipRef.current.open()and scrolling and thus have opened a separate issue with an easier reproducible example here: #1146Would you like me to move the "Tooltip stays visible" issue (described above) to a separate issue?
[-][BUG] When using default rt-transition-show-delay, tooltip stays in DOM and scrolling or resizing causes infinite rerender loop[/-][+][BUG] When using default rt-transition-show-delay, tooltip stays in DOM[/+][-][BUG] When using default rt-transition-show-delay, tooltip stays in DOM[/-][+][BUG] When using default rt-transition-show-delay, tooltip stays in DOM after visibly closing[/+]johannkor commentedon Dec 28, 2023
Adding to my larger reproduction steps with the log files: this is easier to reproduce if you set CPU throttling to 6x in Chrome's devtools.
johannkor commentedon Dec 28, 2023
It seems that the crux of the issue is the following:
debouncedHandleHideTooltipcallshandleHideTooltip()immediately and starts a 50ms debouncehandleHideTooltip()call never gets called because it occurred during the debounce.Here is a script you can paste into your console to reproduce the issue almost reliably, when using Chrome and 6x CPU throttling (DevTools' Performance tab -> cog wheel icon to the right) with the patch I offered earlier.
It dispatches
mouseenterandmouseleaveevents into[data-tooltip-content="tooltip 0"]and[data-tooltip-content="tooltip 1"]with specific times between the events. The key there appears to be to wait for the tooltip to be fully open on one element, then quickly enter&leave another anchor.gabrieljablonski commentedon Dec 28, 2023
Leave it as is. Creating #1146 to isolate the render loop issue should be fine for now.
This is something I'll keep in mind for all future tests. Great suggestion for performance testing.
I'll test it out myself later, but taking a quick look at how
debounce()is being used, I'm 95% sure your analysis is correct.I'd appreciate any suggestions on how to fix this, but my first thought was to extend
debounce()to return a "clear" function as a second return value, which can be used to clear the debounce timeout for that function. Then, clearing the show debounce on hide, and clearing the hide debounce on show should fix it.It'll probably be a while until I have some more time to get back to these new issues (I'm guessing only after new year's), but since you're so eager in helping out (my genuine thanks again for that 😅), this specific issue with the tooltip staying open feels pretty much done after figuring out the best fix for the debounce function (and a proper way to use it), so you can probably leave it be for now (unless you want to work on the solution, which you're totally welcome to by opening a PR).
johannkor commentedon Dec 28, 2023
That was exactly my thought - lodash provides a
cancelmethod on functions created with itsdebounceutility. I've opened #1147 which provides a similar API (just namedresetbecause I forgot to look up the name lodash used)gabrieljablonski commentedon Dec 28, 2023
Good to know it's an already existing design, which means it's probably not the worst idea. We'll be reviewing it soon. Thanks for the contribution, and happy holidays!
gabrieljablonski commentedon Jan 15, 2024
Fix available on official version
react-tooltip@5.25.2