Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tooltip-window-refocus.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 35 additions & 0 deletions packages/react/tooltip/src/tooltip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.Provider>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>Tooltip Trigger</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content>Tooltip Content</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>,
);

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
Expand Down
16 changes: 16 additions & 0 deletions packages/react/tooltip/src/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,12 +276,22 @@ const TooltipTrigger = React.forwardRef<TooltipTriggerElement, TooltipTriggerPro
const composedRefs = useComposedRefs(forwardedRef, ref, context.onTriggerChange);
const isPointerDownRef = React.useRef(false);
const hasPointerMoveOpenedRef = React.useRef(false);
const isWindowBlurredRef = React.useRef(false);
const handlePointerUp = React.useCallback(() => (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 (
<PopperPrimitive.Anchor asChild {...popperScope}>
<Primitive.button
Expand Down Expand Up @@ -313,6 +323,12 @@ const TooltipTrigger = React.forwardRef<TooltipTriggerElement, TooltipTriggerPro
document.addEventListener('pointerup', handlePointerUp, { once: true });
})}
onFocus={composeEventHandlers(props.onFocus, () => {
// 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)}
Expand Down
Loading