Skip to content

fix(tooltip): update refs#3258

Merged
tylermorrisford merged 2 commits into
mainfrom
fec-1010-tooltip-hover-bugfix
Jun 12, 2026
Merged

fix(tooltip): update refs#3258
tylermorrisford merged 2 commits into
mainfrom
fec-1010-tooltip-hover-bugfix

Conversation

@tylermorrisford

Copy link
Copy Markdown
Contributor

follows on from #3256

fix(tooltip): close reliably when mouseleave is missed or animationend never fires

Problem

Image-preview tooltips in Merchant Center (Product → Variants) can get permanently
stuck open after hover. The enlarged preview appears and never dismisses, requiring
a full page reload to clear.

Four root causes were identified:

1. Opened-wedge (confirmed in production — the decisive bug)
A tooltip can get stuck in the opened state because handleLeave is never called.
React 19's synthetic event delegation can silently drop the mouseleave event for a
specific instance. Once in opened, the component had no recovery path: no scheduled
timer, no animationend listener, nothing. The tooltip stayed visible indefinitely.

Confirmed via DevTools on a live Variants page:

  • Single wrapper in DOM (querySelectorAll('[data-testid=...]').length === 1)
  • Wrapper stuck in opened state with growIn animation, opacity: 1
  • getEventListeners(wrapper) returned {} — no close listener was ever attached

2. Exiting-wedge
Even when handleLeave did run and schedule the exiting transition, the tooltip
could get stuck in exiting forever if animationend never fired (e.g. a style
override suppressed growOut, or prefers-reduced-motion was active). There was no
fallback — if the event was missed, the tooltip never unmounted.

3. Unscoped and accumulating animationend listener
The listener accepted any bubbled animationend — including descendant animations
from consumers (e.g. the show keyframe in MC's ImageContainer) — and accumulated
across enter/leave cycles with no cleanup. Whether the tooltip closed depended on a
non-deterministic race between growOut, the consumer's keyframe, and a null-escape
hatch.

4. Exit animation interrupted by late-arriving leave events
Any handleLeave call that arrived while growOut was already playing (a delayed
React synthetic mouseleave, or a mouseleave from the tooltip body) would call
handleClose() immediately, aborting the animation mid-play. The abrupt unmount
could then trigger a stray mouseover on the trigger, reopening the tooltip and
producing a visible double-animation on close.


Fix

Native mouseleave fallback while opened

When state is opened, a native mouseleave listener is attached directly to the
wrapper DOM node via popperInstance.reference. This bypasses React's event delegation
and fires regardless of whether the synthetic onMouseLeave was delivered. The effect
is keyed on state, so the listener is removed the moment state transitions away from
opened.

The effect also attaches a mirrored mouseleave listener on the popper element itself
(popperInstance.popper) so that image previews remain open while the user hovers
over them. Moving the mouse from the trigger onto the preview suppresses the close;
moving off both elements triggers it.

Note: use-popper uses callback refs internally, so reference.ref.current and
popper.ref.current are always undefined at runtime — DOM nodes are read from
popperInstance.reference / popperInstance.popper instead.

animationend listener moved to a useEffect with scoped handling and fallback

The querySelector + addEventListener call is removed from handleLeave and
replaced with a dedicated useEffect that runs when state === 'exiting'. The
listener now checks event.target === tooltipElement to ignore bubbled events from
descendant animations. A setTimeout(close, 100) fallback ensures the tooltip always
unmounts even if animationend is never delivered. A closed guard prevents
double-close. The effect's cleanup removes the listener and clears the timer, so
nothing accumulates across cycles.

handleLeave guarded against interrupting the exit animation

Added else if (state !== 'exiting') to handleLeave's fallback branch so that
late-arriving leave events (from the popper listener or a delayed synthetic event)
cannot call handleClose() while growOut is in progress.


Files changed

  • packages/components/tooltip/src/tooltip.tsx
  • packages/components/tooltip/src/tooltip.spec.js — updated closeAndValidateTooltip
    to use waitFor instead of waitForTimeout + manual fireEvent.animationEnd. In
    jsdom there is no real Popper instance, so the exiting effect calls handleClose()
    directly; no animation event needs to be dispatched manually.

Backward compatibility

  • No changes to public props or component API.
  • The animationend-based close path is preserved for real browsers where Popper and
    the DOM are fully set up; the fallback only fires when the event is missed.
  • Controlled tooltips (isOpen prop) are unaffected — the native fallback guards on
    !isControlled.

@tylermorrisford tylermorrisford self-assigned this Jun 12, 2026
@tylermorrisford tylermorrisford requested a review from a team as a code owner June 12, 2026 16:53
@changeset-bot

changeset-bot Bot commented Jun 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7dbcaef

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 97 packages
Name Type
@commercetools-uikit/tooltip Patch
@commercetools-frontend/ui-kit Patch
@commercetools-uikit/design-system Patch
@commercetools-uikit/calendar-time-utils Patch
@commercetools-uikit/calendar-utils Patch
@commercetools-uikit/hooks Patch
@commercetools-uikit/i18n Patch
@commercetools-uikit/localized-utils Patch
@commercetools-uikit/utils Patch
@commercetools-uikit/accessible-hidden Patch
@commercetools-uikit/avatar Patch
@commercetools-uikit/card Patch
@commercetools-uikit/collapsible-motion Patch
@commercetools-uikit/collapsible-panel Patch
@commercetools-uikit/collapsible Patch
@commercetools-uikit/constraints Patch
@commercetools-uikit/data-table-manager Patch
@commercetools-uikit/data-table Patch
@commercetools-uikit/field-errors Patch
@commercetools-uikit/field-label Patch
@commercetools-uikit/field-warnings Patch
@commercetools-uikit/filters Patch
@commercetools-uikit/grid Patch
@commercetools-uikit/icons Patch
@commercetools-uikit/label Patch
@commercetools-uikit/link Patch
@commercetools-uikit/loading-spinner Patch
@commercetools-uikit/messages Patch
@commercetools-uikit/notifications Patch
@commercetools-uikit/pagination Patch
@commercetools-uikit/primary-action-dropdown Patch
@commercetools-uikit/progress-bar Patch
@commercetools-uikit/quick-filters Patch
@commercetools-uikit/stamp Patch
@commercetools-uikit/tag Patch
@commercetools-uikit/text Patch
@commercetools-uikit/view-switcher Patch
@commercetools-uikit/accessible-button Patch
@commercetools-uikit/flat-button Patch
@commercetools-uikit/icon-button Patch
@commercetools-uikit/link-button Patch
@commercetools-uikit/primary-button Patch
@commercetools-uikit/secondary-button Patch
@commercetools-uikit/secondary-icon-button Patch
@commercetools-uikit/dropdown-menu Patch
@commercetools-uikit/async-creatable-select-field Patch
@commercetools-uikit/async-select-field Patch
@commercetools-uikit/creatable-select-field Patch
@commercetools-uikit/date-field Patch
@commercetools-uikit/date-range-field Patch
@commercetools-uikit/date-time-field Patch
@commercetools-uikit/localized-multiline-text-field Patch
@commercetools-uikit/localized-text-field Patch
@commercetools-uikit/money-field Patch
@commercetools-uikit/multiline-text-field Patch
@commercetools-uikit/number-field Patch
@commercetools-uikit/password-field Patch
@commercetools-uikit/radio-field Patch
@commercetools-uikit/search-select-field Patch
@commercetools-uikit/select-field Patch
@commercetools-uikit/text-field Patch
@commercetools-uikit/time-field Patch
@commercetools-uikit/async-creatable-select-input Patch
@commercetools-uikit/async-select-input Patch
@commercetools-uikit/checkbox-input Patch
@commercetools-uikit/creatable-select-input Patch
@commercetools-uikit/date-input Patch
@commercetools-uikit/date-range-input Patch
@commercetools-uikit/date-time-input Patch
@commercetools-uikit/input-utils Patch
@commercetools-uikit/localized-money-input Patch
@commercetools-uikit/localized-multiline-text-input Patch
@commercetools-uikit/localized-rich-text-input Patch
@commercetools-uikit/localized-text-input Patch
@commercetools-uikit/money-input Patch
@commercetools-uikit/multiline-text-input Patch
@commercetools-uikit/number-input Patch
@commercetools-uikit/password-input Patch
@commercetools-uikit/radio-input Patch
@commercetools-uikit/rich-text-input Patch
@commercetools-uikit/rich-text-utils Patch
@commercetools-uikit/search-select-input Patch
@commercetools-uikit/search-text-input Patch
@commercetools-uikit/select-input Patch
@commercetools-uikit/select-utils Patch
@commercetools-uikit/selectable-search-input Patch
@commercetools-uikit/text-input Patch
@commercetools-uikit/time-input Patch
@commercetools-uikit/toggle-input Patch
@commercetools-uikit/spacings-inline Patch
@commercetools-uikit/spacings-inset-squish Patch
@commercetools-uikit/spacings-inset Patch
@commercetools-uikit/spacings-stack Patch
@commercetools-uikit/buttons Patch
@commercetools-uikit/fields Patch
@commercetools-uikit/inputs Patch
@commercetools-uikit/spacings Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jun 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ui-kit Ready Ready Preview, Comment Jun 12, 2026 4:55pm

Request Review

@ByronDWall ByronDWall left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cursed, but now maybe workably cursed?

@tylermorrisford tylermorrisford merged commit 958d1a0 into main Jun 12, 2026
9 checks passed
@tylermorrisford tylermorrisford deleted the fec-1010-tooltip-hover-bugfix branch June 12, 2026 17:58
@ct-changesets ct-changesets Bot mentioned this pull request Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants