FEC-1010: close reliably when mouseleave is missed or animationend never fires#3256
Merged
Conversation
🦋 Changeset detectedLatest commit: 01f9bcd The changes in this PR will be included in the next version bump. This PR includes changesets to release 97 packages
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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
valoriecarli
approved these changes
Jun 11, 2026
valoriecarli
left a comment
Contributor
There was a problem hiding this comment.
patch changeset?
thanks for grabbing this one.
ByronDWall
approved these changes
Jun 11, 2026
ByronDWall
left a comment
Contributor
There was a problem hiding this comment.
Solid fix, good defensive logic, approved
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(tooltip): close reliably when
mouseleaveis missed oranimationendnever firescloses 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
openedstate becausehandleLeaveis never called.React 19's synthetic event delegation can silently drop the
mouseleaveevent for aspecific instance. Once in
opened, the component had no recovery path: no scheduledtimer, no
animationendlistener, nothing. The tooltip stayed visible indefinitely.Confirmed via DevTools on a live Variants page:
querySelectorAll('[data-testid=...]').length === 1)openedstate withgrowInanimation,opacity: 1getEventListeners(wrapper)returned{}— no close listener was ever attached2. Exiting-wedge (seen in Storybook probing)
Even when
handleLeavedid run and schedule theexitingtransition, the tooltipcould get stuck in
exitingforever ifanimationendnever fired (e.g. a styleoverride suppressed the
growOutanimation, orprefers-reduced-motionwas active).There was no timeout fallback — if the event was missed, the tooltip never unmounted.
3. Unscoped and accumulating
animationendlistenerThe listener attached inside
handleLeave'ssetTimeoutaccepted any bubbledanimationend— including the consumer's ownshowanimation on a descendant —and accumulated across enter/leave cycles (no
once: true, no cleanup). Whether thetooltip closed depended on a non-deterministic race between
growOut, the consumer'sshowkeyframe, and a querySelector-null escape hatch.Fix
Fix 0 — Native
mouseleavefallback whileopened(addresses the wedge)When state is
opened, a nativeaddEventListener('mouseleave', …)is attacheddirectly to the wrapper DOM node. This bypasses React's event delegation entirely and
fires regardless of whether React's synthetic
onMouseLeavewas delivered. TheuseEffectthat manages it is keyed onstate, so it is automatically removed themoment the state transitions away from
opened.Fix 1 — Hard fallback timeout in
exiting(addresses the exiting-wedge)Added a
setTimeout(close, 100)fallback alongside theanimationendlistener.The
growOutanimation runs for 80 ms; 100 ms gives a small buffer. Whichever firesfirst wins; the
closedguard prevents double-close.Fix 2+3 — Scoped listener with proper cleanup
Moved the
animationendlistener setup out ofhandleLeave'ssetTimeoutinto adedicated
useEffectforstate === 'exiting'. The listener now checksevent.target === tooltipElementso bubbled events from consumer descendantanimations (e.g. the
showkeyframe in MC'sImageContainer) no longer trigger apremature or phantom close. The
useEffectreturn function removes the listener andclears the fallback timer, so listeners no longer accumulate.
Files changed
packages/components/tooltip/src/tooltip.tsx— all three fixes abovepackages/components/tooltip/src/tooltip.spec.js— updatedcloseAndValidateTooltipto use
waitForinstead ofwaitForTimeout+ manualfireEvent.animationEnd. Injsdom there is no real Popper instance, so the
exitingeffect now callshandleClose()directly (querySelector returns null); no animation event needs tobe dispatched manually.
Backward compatibility
animationend-based close path is preserved for real browsers where Popper andthe DOM are fully set up; the new fallback only fires when the event is missed.
isOpenprop) are unaffected — the native fallback guards on!isControlled.