diff --git a/.changeset/tooltip-window-refocus.md b/.changeset/tooltip-window-refocus.md new file mode 100644 index 0000000000..7ae2c496bb --- /dev/null +++ b/.changeset/tooltip-window-refocus.md @@ -0,0 +1,6 @@ +--- +'@radix-ui/react-tooltip': patch +'radix-ui': patch +--- + +Fixed a bug where the tooltip opened when switching back to the browser tab while its trigger was focused. diff --git a/packages/react/tooltip/src/tooltip.test.tsx b/packages/react/tooltip/src/tooltip.test.tsx index 215e0d6d88..c31a966975 100644 --- a/packages/react/tooltip/src/tooltip.test.tsx +++ b/packages/react/tooltip/src/tooltip.test.tsx @@ -83,6 +83,41 @@ describe('Tooltip', () => { }); }); + // Regression test for https://github.com/radix-ui/primitives/issues/1800 + // Switching away from and back to the browser tab refocuses the previously-focused + // trigger, firing a synthetic `focus` event that must not reopen the tooltip. + it('does not open on focus caused by the window regaining focus', () => { + render( + + + Tooltip Trigger + + Tooltip Content + + + , + ); + + const trigger = screen.getByText('Tooltip Trigger'); + + // A genuine focus opens the tooltip. + act(() => void fireEvent.focus(trigger)); + expect(trigger).toHaveAttribute('data-state', 'instant-open'); + act(() => void fireEvent.blur(trigger)); + expect(trigger).toHaveAttribute('data-state', 'closed'); + + // Simulate the window losing focus, then the tab regaining focus and refocusing + // the trigger. The synthetic refocus must NOT reopen the tooltip. + act(() => void window.dispatchEvent(new FocusEvent('blur'))); + act(() => void fireEvent.focus(trigger)); + expect(trigger).toHaveAttribute('data-state', 'closed'); + expect(screen.queryByText('Tooltip Content')).not.toBeInTheDocument(); + + // A subsequent genuine focus still opens it (the guard is consumed once). + act(() => void fireEvent.focus(trigger)); + expect(trigger).toHaveAttribute('data-state', 'instant-open'); + }); + // Regression test for https://github.com/radix-ui/primitives/issues/2375 // Hovering a trigger inside a shared `TooltipProvider` must not re-render // sibling tooltips. The provider keeps its "open delayed" flag in a ref so diff --git a/packages/react/tooltip/src/tooltip.tsx b/packages/react/tooltip/src/tooltip.tsx index 42d432f103..fbb98d8a17 100644 --- a/packages/react/tooltip/src/tooltip.tsx +++ b/packages/react/tooltip/src/tooltip.tsx @@ -276,12 +276,22 @@ const TooltipTrigger = React.forwardRef (isPointerDownRef.current = false), []); React.useEffect(() => { return () => document.removeEventListener('pointerup', handlePointerUp); }, [handlePointerUp]); + // When the window/tab loses focus, the browser refocuses the previously active + // element once focus returns, firing a `focus` event that would otherwise reopen + // the tooltip. Track the blur so we can ignore that synthetic refocus. See #1800. + React.useEffect(() => { + const handleWindowBlur = () => (isWindowBlurredRef.current = true); + window.addEventListener('blur', handleWindowBlur); + return () => window.removeEventListener('blur', handleWindowBlur); + }, []); + return ( { + // Ignore focus caused by the tab/window regaining focus, which would + // otherwise reopen the tooltip on the previously-focused trigger. See #1800. + if (isWindowBlurredRef.current) { + isWindowBlurredRef.current = false; + return; + } if (!isPointerDownRef.current) context.onOpen(); })} onBlur={composeEventHandlers(props.onBlur, context.onClose)}