Skip to content

[BUG] When using default rt-transition-show-delay, tooltip stays in DOM after visibly closing #1144

@johannkor

Description

@johannkor

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

  1. Clean install this repository (picking up the latest @floating-ui/dom)

  2. Replace index-dev.tsx with 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"));
  3. Apply this patch to litter console.log() everywhere in Tooltip.tsx

    • Save this as tooltip.patch in the root of the repo, then run git 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?.()
  4. Start dev server with yarn dev

  5. Open page

  6. Open devtools' Inspector/Elements tab, expand #app

  7. Press ESC to open console drawer below

  8. Resize your window so that your cursor can move up over the browser's window

  9. Move your mouse ~100px below the red bordered anchor element

  10. Quickly flick your cursor up beyond the browser's window, hitting the anchor element on the way

  11. Notice that the tooltip is not visible, but is found in the DOM

  12. 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
  13. Notice that your console is now full of console.log() from rerenders

  14. 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 inlineStyles and inlineArrowStyles respectfully

    • The infinite loop comes from handleTooltipPosition called by updateTooltipPosition where the floating-ui computePosition function gets repeatedly called by the useEffect(() => 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
  15. Change --rt-transition-show-delay to 0

    • 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/dom v1.4.3 performed some changes to their state handling when fixing a ResizeObserver bug. 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
      

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions