From 3d11b40220412773d606f960fde557006720f250 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Fri, 17 Jul 2026 01:56:28 +0530 Subject: [PATCH 1/4] regression: tooltips not reappearing on subsequent hovers Signed-off-by: Abhinav Kumar --- .../src/providers/TooltipProvider.spec.tsx | 90 +++++++++++++++++++ .../src/providers/TooltipProvider.tsx | 46 +++++----- 2 files changed, 114 insertions(+), 22 deletions(-) create mode 100644 packages/ui-client/src/providers/TooltipProvider.spec.tsx diff --git a/packages/ui-client/src/providers/TooltipProvider.spec.tsx b/packages/ui-client/src/providers/TooltipProvider.spec.tsx new file mode 100644 index 0000000000000..123eacb98bfc4 --- /dev/null +++ b/packages/ui-client/src/providers/TooltipProvider.spec.tsx @@ -0,0 +1,90 @@ +/* eslint-disable testing-library/prefer-user-event */ +import { act, fireEvent, render, screen } from '@testing-library/react'; + +import TooltipProvider from './TooltipProvider'; + +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +const setup = () => { + render( + + + , + ); + + return { + anchor: screen.getByRole('button', { name: 'anchor' }), + }; +}; + +const waitForTooltipDebounce = () => { + act(() => { + jest.advanceTimersByTime(300); + }); +}; + +it('should show the tooltip on hover and stash the title attribute', () => { + const { anchor } = setup(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Hello'); + expect(anchor).toHaveAttribute('title', ''); + expect(anchor).toHaveAttribute('data-title', 'Hello'); +}); + +it('should restore the title attribute on unhover without depending on timers', () => { + const { anchor } = setup(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + fireEvent.mouseLeave(anchor); + + // the title must be restored synchronously on unmount, before any timer runs; otherwise a still-attached + // MutationObserver can blank it again, permanently suppressing the tooltip for this element + expect(screen.queryByRole('tooltip', { hidden: true })).not.toBeInTheDocument(); + expect(anchor).toHaveAttribute('title', 'Hello'); + expect(anchor).not.toHaveAttribute('data-title'); +}); + +it('should show the tooltip again after a full close and reopen cycle', () => { + const { anchor } = setup(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + fireEvent.mouseLeave(anchor); + act(() => { + jest.runOnlyPendingTimers(); + }); + expect(screen.queryByRole('tooltip', { hidden: true })).not.toBeInTheDocument(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Hello'); +}); + +it('should update the tooltip and re-stash the title when it changes while open', async () => { + const { anchor } = setup(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + + // the MutationObserver callback runs as a microtask, so the update must be flushed asynchronously + await act(async () => { + anchor.setAttribute('title', 'World'); + }); + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('World'); + expect(anchor).toHaveAttribute('title', ''); + expect(anchor).toHaveAttribute('data-title', 'World'); +}); diff --git a/packages/ui-client/src/providers/TooltipProvider.tsx b/packages/ui-client/src/providers/TooltipProvider.tsx index ab935bdb1bb0a..a279fa53f7607 100644 --- a/packages/ui-client/src/providers/TooltipProvider.tsx +++ b/packages/ui-client/src/providers/TooltipProvider.tsx @@ -1,7 +1,7 @@ import { useDebouncedState, useMediaQuery } from '@rocket.chat/fuselage-hooks'; import { TooltipContext } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; -import { useEffect, useMemo, useRef, memo, useCallback, useState } from 'react'; +import { useEffect, useMemo, useRef, memo, useState } from 'react'; import { TooltipComponent } from '../components/TooltipComponent'; @@ -10,21 +10,28 @@ export type TooltipProviderProps = { ownerDocument?: Document; }; +const stashAnchorTitle = (anchor: HTMLElement, title: string): void => { + anchor.setAttribute('data-title', title); + anchor.setAttribute('title', ''); +}; + +const restoreAnchorTitle = (anchor: HTMLElement): void => { + if (!anchor.getAttribute('title')) { + anchor.setAttribute('title', anchor.getAttribute('data-title') ?? ''); + anchor.removeAttribute('data-title'); + } +}; + +const restoreAnchorTitleDeferred = (anchor: HTMLElement): void => { + setTimeout(() => restoreAnchorTitle(anchor), 0); +}; + const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipProviderProps) => { const lastAnchor = useRef(undefined); const hasHover = !useMediaQuery('(hover: none)'); const [tooltip, setTooltip] = useDebouncedState(null, 300); - const restoreTitle = useCallback((previousAnchor: HTMLElement | undefined): void => { - setTimeout(() => { - if (previousAnchor && !previousAnchor.getAttribute('title')) { - previousAnchor.setAttribute('title', previousAnchor.getAttribute('data-title') ?? ''); - previousAnchor.removeAttribute('data-title'); - } - }, 0); - }, []); - const contextValue = useMemo( () => ({ open: (tooltip: ReactNode, anchor: HTMLElement): void => { @@ -32,7 +39,7 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP setTooltip(); lastAnchor.current = anchor; if (previousAnchor) { - restoreTitle(previousAnchor); + restoreAnchorTitleDeferred(previousAnchor); } }, close: (): void => { @@ -41,7 +48,7 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP setTooltip.flush(); lastAnchor.current = undefined; if (previousAnchor) { - restoreTitle(previousAnchor); + restoreAnchorTitleDeferred(previousAnchor); } }, dismiss: (): void => { @@ -49,7 +56,7 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP setTooltip.flush(); }, }), - [setTooltip, restoreTitle], + [setTooltip], ); useEffect(() => { @@ -85,10 +92,7 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP const [state, setState] = useState(title); useEffect(() => { const close = (): void => contextValue.close(); - // store the title in a data attribute - anchor.setAttribute('data-title', title); - // Removes the title attribute to prevent the browser's tooltip from showing - anchor.setAttribute('title', ''); + stashAnchorTitle(anchor, title); anchor.addEventListener('mouseleave', close); @@ -99,11 +103,7 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP return; } - // store the title in a data attribute - anchor.setAttribute('data-title', title); - // Removes the title attribute to prevent the browser's tooltip from showing - anchor.setAttribute('title', ''); - + stashAnchorTitle(anchor, title); setState(title); }); @@ -114,7 +114,9 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP return () => { anchor.removeEventListener('mouseleave', close); + // the observer must be disconnected before restoring the title, otherwise it would stash it again observer.disconnect(); + restoreAnchorTitle(anchor); }; }, []); return <>{state}; From 453d4c49377eb68f9e4a46583a11ed533807647d Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Fri, 17 Jul 2026 22:04:05 +0530 Subject: [PATCH 2/4] refactor: remove redundant deferred title restores in TooltipProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The effect cleanup now restores the anchor title synchronously on unmount, so the setTimeout-based restores in open/close are dead code — their only observable effect was re-triggering the MutationObserver while the previous tooltip was still mounted. Signed-off-by: Abhinav Kumar --- packages/ui-client/src/providers/TooltipProvider.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/ui-client/src/providers/TooltipProvider.tsx b/packages/ui-client/src/providers/TooltipProvider.tsx index a279fa53f7607..77d77f753d7e0 100644 --- a/packages/ui-client/src/providers/TooltipProvider.tsx +++ b/packages/ui-client/src/providers/TooltipProvider.tsx @@ -22,10 +22,6 @@ const restoreAnchorTitle = (anchor: HTMLElement): void => { } }; -const restoreAnchorTitleDeferred = (anchor: HTMLElement): void => { - setTimeout(() => restoreAnchorTitle(anchor), 0); -}; - const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipProviderProps) => { const lastAnchor = useRef(undefined); const hasHover = !useMediaQuery('(hover: none)'); @@ -35,21 +31,13 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP const contextValue = useMemo( () => ({ open: (tooltip: ReactNode, anchor: HTMLElement): void => { - const previousAnchor = lastAnchor.current; setTooltip(); lastAnchor.current = anchor; - if (previousAnchor) { - restoreAnchorTitleDeferred(previousAnchor); - } }, close: (): void => { - const previousAnchor = lastAnchor.current; setTooltip(null); setTooltip.flush(); lastAnchor.current = undefined; - if (previousAnchor) { - restoreAnchorTitleDeferred(previousAnchor); - } }, dismiss: (): void => { setTooltip(null); From 8a4fcf25253bbed903876bd40a1c448bbf5e3345 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Fri, 17 Jul 2026 23:42:27 +0530 Subject: [PATCH 3/4] fixes Signed-off-by: Abhinav Kumar --- .../src/providers/TooltipProvider.spec.tsx | 95 +++++++++++++++++++ .../src/providers/TooltipProvider.tsx | 28 +++++- 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/packages/ui-client/src/providers/TooltipProvider.spec.tsx b/packages/ui-client/src/providers/TooltipProvider.spec.tsx index 123eacb98bfc4..d272dd18c2462 100644 --- a/packages/ui-client/src/providers/TooltipProvider.spec.tsx +++ b/packages/ui-client/src/providers/TooltipProvider.spec.tsx @@ -88,3 +88,98 @@ it('should update the tooltip and re-stash the title when it changes while open' expect(anchor).toHaveAttribute('title', ''); expect(anchor).toHaveAttribute('data-title', 'World'); }); + +describe('click-dismiss while still hovering', () => { + it('should keep the title stashed after a click-dismiss while the cursor is still on the anchor', () => { + const { anchor } = setup(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Hello'); + + fireEvent.click(anchor); + + expect(screen.queryByRole('tooltip', { hidden: true })).not.toBeInTheDocument(); + expect(anchor).toHaveAttribute('title', ''); + expect(anchor).toHaveAttribute('data-title', 'Hello'); + }); + + it('should restore the title when the cursor leaves after a click-dismiss', () => { + const { anchor } = setup(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + fireEvent.click(anchor); + + fireEvent.mouseLeave(anchor); + + expect(anchor).toHaveAttribute('title', 'Hello'); + expect(anchor).not.toHaveAttribute('data-title'); + }); + + it('should show the tooltip again after a click-dismiss and mouseleave cycle', () => { + const { anchor } = setup(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + fireEvent.click(anchor); + fireEvent.mouseLeave(anchor); + act(() => { + jest.runOnlyPendingTimers(); + }); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Hello'); + }); +}); + +describe('with a `data-tooltip`-only anchor', () => { + const setupDataTooltip = () => { + render( + + + , + ); + + return { + anchor: screen.getByRole('button', { name: 'anchor' }), + }; + }; + + it('should show the tooltip without ever adding a title attribute', () => { + const { anchor } = setupDataTooltip(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Hello'); + expect(anchor).not.toHaveAttribute('title'); + expect(anchor).not.toHaveAttribute('data-title'); + + fireEvent.mouseLeave(anchor); + + // the anchor must not gain a native title on close; that would leak the tooltip text to the browser's tooltip + expect(screen.queryByRole('tooltip', { hidden: true })).not.toBeInTheDocument(); + expect(anchor).not.toHaveAttribute('title'); + expect(anchor).not.toHaveAttribute('data-title'); + }); + + it('should update the tooltip when `data-tooltip` changes while open', async () => { + const { anchor } = setupDataTooltip(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + + // the MutationObserver callback runs as a microtask, so the update must be flushed asynchronously + await act(async () => { + anchor.setAttribute('data-tooltip', 'World'); + }); + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('World'); + expect(anchor).not.toHaveAttribute('title'); + }); +}); diff --git a/packages/ui-client/src/providers/TooltipProvider.tsx b/packages/ui-client/src/providers/TooltipProvider.tsx index 77d77f753d7e0..46a08da80e737 100644 --- a/packages/ui-client/src/providers/TooltipProvider.tsx +++ b/packages/ui-client/src/providers/TooltipProvider.tsx @@ -11,19 +11,26 @@ export type TooltipProviderProps = { }; const stashAnchorTitle = (anchor: HTMLElement, title: string): void => { + if (!anchor.hasAttribute('title')) { + return; + } anchor.setAttribute('data-title', title); anchor.setAttribute('title', ''); }; const restoreAnchorTitle = (anchor: HTMLElement): void => { + if (!anchor.hasAttribute('data-title')) { + return; + } if (!anchor.getAttribute('title')) { anchor.setAttribute('title', anchor.getAttribute('data-title') ?? ''); - anchor.removeAttribute('data-title'); } + anchor.removeAttribute('data-title'); }; const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipProviderProps) => { const lastAnchor = useRef(undefined); + const dismissedAnchor = useRef(undefined); const hasHover = !useMediaQuery('(hover: none)'); const [tooltip, setTooltip] = useDebouncedState(null, 300); @@ -40,8 +47,23 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP lastAnchor.current = undefined; }, dismiss: (): void => { + const anchor = lastAnchor.current; setTooltip(null); setTooltip.flush(); + + if (anchor?.hasAttribute('data-title')) { + dismissedAnchor.current = anchor; + const restoreOnLeave = (): void => { + restoreAnchorTitle(anchor); + if (dismissedAnchor.current === anchor) { + dismissedAnchor.current = undefined; + } + if (lastAnchor.current === anchor) { + lastAnchor.current = undefined; + } + }; + anchor.addEventListener('mouseleave', restoreOnLeave, { once: true }); + } }, }), [setTooltip], @@ -104,7 +126,9 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP anchor.removeEventListener('mouseleave', close); // the observer must be disconnected before restoring the title, otherwise it would stash it again observer.disconnect(); - restoreAnchorTitle(anchor); + if (dismissedAnchor.current !== anchor) { + restoreAnchorTitle(anchor); + } }; }, []); return <>{state}; From 475e81732c618246898b1c7b7a820915a111e47d Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Sat, 18 Jul 2026 00:30:00 +0530 Subject: [PATCH 4/4] fix Signed-off-by: Abhinav Kumar --- .../src/providers/TooltipProvider.spec.tsx | 18 ++++++++++++++++++ .../src/providers/TooltipProvider.tsx | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/ui-client/src/providers/TooltipProvider.spec.tsx b/packages/ui-client/src/providers/TooltipProvider.spec.tsx index d272dd18c2462..da477ca34f307 100644 --- a/packages/ui-client/src/providers/TooltipProvider.spec.tsx +++ b/packages/ui-client/src/providers/TooltipProvider.spec.tsx @@ -182,4 +182,22 @@ describe('with a `data-tooltip`-only anchor', () => { expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('World'); expect(anchor).not.toHaveAttribute('title'); }); + + it('should show the tooltip again after a click-dismiss and mouseleave cycle', () => { + const { anchor } = setupDataTooltip(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + fireEvent.click(anchor); + fireEvent.mouseLeave(anchor); + act(() => { + jest.runOnlyPendingTimers(); + }); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Hello'); + expect(anchor).not.toHaveAttribute('title'); + }); }); diff --git a/packages/ui-client/src/providers/TooltipProvider.tsx b/packages/ui-client/src/providers/TooltipProvider.tsx index 46a08da80e737..2aba6f8d9d9d0 100644 --- a/packages/ui-client/src/providers/TooltipProvider.tsx +++ b/packages/ui-client/src/providers/TooltipProvider.tsx @@ -51,7 +51,7 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP setTooltip(null); setTooltip.flush(); - if (anchor?.hasAttribute('data-title')) { + if (anchor) { dismissedAnchor.current = anchor; const restoreOnLeave = (): void => { restoreAnchorTitle(anchor);