Skip to content

FEC-1010: close reliably when mouseleave is missed or animationend never fires#3256

Merged
ByronDWall merged 2 commits into
mainfrom
fix/tooltip-hover-sticking
Jun 11, 2026
Merged

FEC-1010: close reliably when mouseleave is missed or animationend never fires#3256
ByronDWall merged 2 commits into
mainfrom
fix/tooltip-hover-sticking

Conversation

@tylermorrisford

@tylermorrisford tylermorrisford commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

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

closes FEC-1010

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.

Three root causes were identified (in order of severity):

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 (seen in Storybook probing)
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 the growOut animation, or prefers-reduced-motion was active).
There was no timeout fallback — if the event was missed, the tooltip never unmounted.

3. Unscoped and accumulating animationend listener
The listener attached inside handleLeave's setTimeout accepted any bubbled
animationend — including the consumer's own show animation on a descendant —
and accumulated across enter/leave cycles (no once: true, no cleanup). Whether the
tooltip closed depended on a non-deterministic race between growOut, the consumer's
show keyframe, and a querySelector-null escape hatch.


Fix

Fix 0 — Native mouseleave fallback while opened (addresses the wedge)

When state is opened, a native addEventListener('mouseleave', …) is attached
directly to the wrapper DOM node. This bypasses React's event delegation entirely and
fires regardless of whether React's synthetic onMouseLeave was delivered. The
useEffect that manages it is keyed on state, so it is automatically removed the
moment the state transitions away from opened.

useEffect(() => {
  if (state !== 'opened' || off || isControlled) return;
  const wrapperEl = reference.ref.current as HTMLElement | null;
  if (!wrapperEl) return;
  const onNativeLeave = (event: MouseEvent) => {
    handleLeave(event as unknown as ChangeEvent);
  };
  wrapperEl.addEventListener('mouseleave', onNativeLeave);
  return () => wrapperEl.removeEventListener('mouseleave', onNativeLeave);
}, [state, off, isControlled, reference.ref, handleLeave]);

Fix 1 — Hard fallback timeout in exiting (addresses the exiting-wedge)

Added a setTimeout(close, 100) fallback alongside the animationend listener.
The growOut animation runs for 80 ms; 100 ms gives a small buffer. Whichever fires
first wins; the closed guard prevents double-close.

Fix 2+3 — Scoped listener with proper cleanup

Moved the animationend listener setup out of handleLeave's setTimeout into a
dedicated useEffect for state === 'exiting'. The listener now checks
event.target === tooltipElement so bubbled events from consumer descendant
animations (e.g. the show keyframe in MC's ImageContainer) no longer trigger a
premature or phantom close. The useEffect return function removes the listener and
clears the fallback timer, so listeners no longer accumulate.

Ordering note: Fix 1 (fallback timeout) was intentionally landed alongside
Fix 2+3 (listener scoping). Tightening the listener scope without adding the
fallback would have increased the stuck frequency by removing the accidental
rescue that the consumer's bubbled animationend was providing.


Files changed

  • packages/components/tooltip/src/tooltip.tsx — all three fixes above
  • 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 now calls
    handleClose() directly (querySelector returns null); 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 new fallback only fires when the event is missed.
  • Controlled tooltips (isOpen prop) are unaffected — the native fallback guards on
    !isControlled.

@tylermorrisford tylermorrisford requested a review from a team as a code owner June 11, 2026 18:45
@changeset-bot

changeset-bot Bot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 01f9bcd

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 11, 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 11, 2026 7:27pm

Request Review

@valoriecarli valoriecarli 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.

patch changeset?
thanks for grabbing this one.

@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.

Solid fix, good defensive logic, approved

@ByronDWall ByronDWall merged commit bd4ccb7 into main Jun 11, 2026
9 checks passed
@ByronDWall ByronDWall deleted the fix/tooltip-hover-sticking branch June 11, 2026 20:00
@ct-changesets ct-changesets Bot mentioned this pull request Jun 11, 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.

3 participants