-
-
Notifications
You must be signed in to change notification settings - Fork 537
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.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"));
-
Apply this patch to litter
console.log()
everywhere inTooltip.tsx
- Save this as
tooltip.patch
in 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
inlineStyles
andinlineArrowStyles
respectfully- The infinite loop comes from
handleTooltipPosition
called byupdateTooltipPosition
where 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-delay
to0
- 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 aResizeObserver
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
- 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: