Skip to content

[tooltip] Fix flickering when nesting tooltip inside another tooltip's trigger#4501

Open
michaldudak wants to merge 14 commits intomui:masterfrom
michaldudak:claude/condescending-clarke
Open

[tooltip] Fix flickering when nesting tooltip inside another tooltip's trigger#4501
michaldudak wants to merge 14 commits intomui:masterfrom
michaldudak:claude/condescending-clarke

Conversation

@michaldudak
Copy link
Copy Markdown
Member

Prevent the outer tooltip from opening when the cursor is over an enabled nested tooltip trigger. When the outer tooltip is already open and the pointer moves onto a nested trigger, close it via a bubbling custom DOM event so that only the innermost tooltip is visible. Disabled nested triggers are excluded from detection so the parent tooltip still opens.

The close path is gated to hover-opened tooltips only — focus-opened tooltips remain visible until blur.

Closes #4499

…s trigger

Prevent the outer tooltip from opening when the cursor is over an enabled
nested tooltip trigger. When the outer tooltip is already open and the
pointer moves onto a nested trigger, close it via a bubbling custom DOM
event so that only the innermost tooltip is visible. Disabled nested
triggers are excluded from detection so the parent tooltip still opens.

The close path is gated to hover-opened tooltips only — focus-opened
tooltips remain visible until blur.

Closes mui#4499

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@michaldudak michaldudak added the component: tooltip Changes related to the tooltip component. label Apr 1, 2026
@mui-bot
Copy link
Copy Markdown

mui-bot commented Apr 1, 2026

Bundle size report

Bundle Parsed size Gzip size
@base-ui/react 🔺+817B(+0.18%) 🔺+308B(+0.21%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 1, 2026

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit ad7d99f
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/69d4aeb222fb9a0008fa373a
😎 Deploy Preview https://deploy-preview-4501--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 1, 2026

commit: ad7d99f

@michaldudak michaldudak marked this pull request as ready for review April 2, 2026 09:05
@michaldudak
Copy link
Copy Markdown
Member Author

Codex Review

Overview

This PR fixes the nested-tooltip flicker reported in #4499 by preventing a parent tooltip from opening, or staying open, while the pointer is over an enabled nested trigger. It does that by adding a nested-trigger guard in the tooltip hover path, closing hover-opened ancestors on nested trigger entry, and documenting/testing the new behavior.

Findings (None)

No blocking issues found in this patch.

I reviewed the hover-delay path in packages/react/src/floating-ui-react/hooks/useHoverReferenceInteraction.ts, the nested-trigger event handling in packages/react/src/tooltip/trigger/TooltipTrigger.tsx, and the added delayed-hover regression coverage in packages/react/src/tooltip/root/TooltipRoot.test.tsx, and I did not find a remaining correctness issue in the current branch state.

Confidence: 4/5

High confidence. The branch includes targeted coverage for the key delayed-hover sequence where the pointer enters the outer trigger first and then moves onto the nested trigger before the delay expires. Residual risk is limited to the usual complexity around hover timing and composed event flow.

Notes

  • The PR conversation appears to contain only bot comments, so there is no outstanding human review feedback to reconcile.

Copy link
Copy Markdown
Contributor

@atomiks atomiks left a comment

Choose a reason for hiding this comment

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

Codex Review (GPT-5.4)

This is a bug-fix PR that does fix the light-DOM nested-tooltip flicker path, and the targeted TooltipRoot suite plus fresh size snapshots against master back that up. The remaining concern is that the new nested-trigger detection is not shadow-DOM-safe, and the current shape adds roughly +996 B parsed / +326 B gzip to @base-ui/react/tooltip for a tooltip-only edge case.

1. Bugs / Issues

1. 🟡 Nested-trigger detection is not shadow-DOM-safe (non-blocking)

In packages/react/src/tooltip/trigger/TooltipTrigger.tsx, the delayed-open guard finds the hovered nested trigger via document.elementFromPoint(...).closest(...), while the already-open close path depends on a retargeted bubbling DOM event. This is code-based reasoning rather than a local repro, but in shadow-root embeddings closest() does not walk past the shadow boundary to the trigger host, so the ancestor tooltip can still open even though the pointer is over an enabled nested trigger.

From packages/react/src/tooltip/trigger/TooltipTrigger.tsx:

const elementUnderCursor = doc.elementFromPoint(pos.x, pos.y);
if (!elementUnderCursor) {
  return true;
}

const nearestTrigger = elementUnderCursor.closest(ENABLED_TOOLTIP_TRIGGER_SELECTOR);
if (nearestTrigger && nearestTrigger !== triggerEl && contains(triggerEl, nearestTrigger)) {
  return false;
}

This matters because the rest of the floating layer explicitly uses shadow-safe targeting helpers like contains and getTarget, so this branch narrows Tooltip’s composed-tree guarantees relative to adjacent primitives.

Fix: Replace the closest()/custom-event combination with a composed-path-aware signal, or track nested-trigger hover from bubbling pointerover/mouseover state so the guard stays shadow-DOM-safe.

2. Root Cause & Patch Assessment

The branch addresses both halves of the reported bug in normal light DOM: it suppresses a delayed outer open when the pointer is already over the inner trigger, and it closes a hover-opened outer tooltip when the pointer moves onto the nested trigger. The new TooltipRoot tests cover those light-DOM cases well, and I did not find another concrete interaction defect in that path.

The downside is that the fix is split across two mechanisms in TooltipTrigger: cursor-position sampling plus elementFromPoint() for future opens, and a bubbling custom event for already-open ancestors. That makes the patch heavier and harder to reason about than the original bug warrants.

3. Pattern Consistency Assessment

Existing floating utilities in this repo consistently lean on store-based trigger tracking and shadow-safe helpers. This patch is the one place that falls back to raw DOM traversal plus a public marker attribute, so it breaks that pattern instead of extending it.

4. Simplification Opportunities

The biggest simplification opportunity is to keep this tooltip-specific behavior out of useHoverReferenceInteraction. Adding shouldOpen widens a shared hover primitive for a case that only Tooltip uses, which means extra hot-path branching for other consumers as well.

Bundle snapshot results against fresh master:

Entrypoint master PR Delta
@base-ui/react/tooltip 95,381 / 32,223 96,377 / 32,549 +996 parsed / +326 gzip
@base-ui/react 465,728 / 146,125 466,689 / 146,437 +961 parsed / +312 gzip

I would try to collapse this to one local signal in TooltipTrigger instead of the current two-part design, and I would avoid documenting data-tooltip-trigger unless the intent is to support that marker as public API long-term.

6. Test Coverage Assessment

pnpm test:jsdom TooltipRoot --no-watch passed, including the new nested-tooltip cases. Those tests cover the light-DOM happy path, delayed-open cancellation, already-open ancestor close, and the explicit disabled prop case.

The main missing regression case is a shadow-root embedding, which is exactly where the current detection logic looks fragile. If this approach stays, I would add that case before merging.

Merge Confidence Score

Overall merge confidence is 3/5. The reported flicker looks fixed for the newly tested light-DOM scenarios, but I would address issue 1 before merging if Tooltip is expected to preserve the same shadow-DOM behavior as the rest of the floating utilities. Even if that risk is accepted, I would still simplify the implementation so the ~0.3 KB gzip increase is paying for the smallest possible fix.

Copy link
Copy Markdown
Contributor

@atomiks atomiks left a comment

Choose a reason for hiding this comment

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

Follow-up on the simplification point: I think this can likely be collapsed further than just moving the logic out of useHoverReferenceInteraction.

Right now Tooltip is paying for two separate mechanisms for the same nested-trigger behavior:

  • suppress a future delayed open when the pointer is already over an inner trigger
  • close an already-open outer tooltip when the pointer moves onto that inner trigger

Those could probably be driven from one local TooltipTrigger signal such as “pointer is currently over a nested enabled tooltip trigger”, derived from bubbling pointerover/mouseover state. If that works, you should be able to drop most or all of:

  • lastMousePositionRef
  • elementFromPoint()
  • the custom bubbling DOM event
  • the layout-effect listener for that event
  • the need to surface data-tooltip-trigger as public docs/API

So splitting the generic hover hook would stop other components from paying for this, but Tooltip would still carry most of the current cost unless the duplicated local machinery is collapsed too. My impression is that the real bundle win is not just “make it tooltip-only”, but “make it one local mechanism instead of two.”

I think the one caveat is that Tooltip still needs some private way to cancel a pending delayed open, so there may still be a small pre-open guard somewhere. But that should be materially smaller than the current elementFromPoint() + custom-event combination.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d527ac0ce1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@michaldudak
Copy link
Copy Markdown
Member Author

michaldudak commented Apr 3, 2026

I believe we have to resort to checking the closest() DOM element (with the help of the custom data attribute), as we can't rely on accessing the other Trigger's store from the outside. I improved how it behaves with shadow DOM.

I also simplified the implementation and now use just a single call in onMouseOver to detect a nested trigger.

As for useHoverReferenceInteraction - the shouldOpen is an optional callback with zero overhead when not provided (shouldOpenRef.current?.() !== false short-circuits), aside from a couple of extra bytes in the bundle (less than 30 gzipped for components that don't use it). I believe it's the minimal and most readable way to prevent pending delayed opens before they happen.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1d33beed4a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Apr 6, 2026
@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Apr 7, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ad7d99f6cd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +177 to +179
!store.select('open') &&
getOpenDelay() === 0 &&
triggerEl
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reopen delayed parent tooltip after leaving nested trigger

When the pointer remains over an enabled nested trigger longer than the parent tooltip’s non-zero open delay, the parent’s pending open is suppressed and consumed while nested hover is active. After moving back to the parent area, this branch only reopens when getOpenDelay() === 0, but Tooltip hover uses move: false, so there is no subsequent mouseenter/mousemove path to schedule reopening. In practice, delayed parent tooltips can get stuck closed until the cursor fully leaves and re-enters the trigger.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

@flaviendelangle flaviendelangle left a comment

Choose a reason for hiding this comment

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

PR Review: [tooltip] Fix flickering when nesting tooltip inside another tooltip's trigger

PR: #4501
Author: @michaldudak
Base: master


Summary

This PR prevents the outer tooltip from opening when the cursor is over an enabled nested tooltip trigger. When the outer tooltip is already open and the pointer moves onto a nested trigger, it closes via the bubbling mouseover event so that only the innermost tooltip is visible. Disabled nested triggers are excluded from detection so the parent tooltip still opens. The close path is gated to hover-opened tooltips only -- focus-opened tooltips remain visible until blur.

Files changed: 10 (+651 / -23)


Critical Issues (1)

1. Missing test: focus-opened tooltip should NOT be closed when hovering nested trigger

handleNestedTriggerHover explicitly checks store.select('lastOpenChangeReason') === REASONS.triggerHover before closing the parent. This means a focus-opened outer tooltip should remain open when the pointer moves onto a nested trigger. This is a critical behavioral distinction -- accidentally closing a keyboard user's tooltip would be an accessibility regression. There is no test verifying this path.

A test should: focus the outer trigger to open the outer tooltip, then hover the nested trigger, and assert the outer tooltip remains visible.


Important Issues (4)

2. Missing test: rapid oscillation between nested trigger and parent area

The most common flickering scenario in production is rapid back-and-forth pointer movement between the nested trigger and the parent area. The existing tests cover one-directional movements but not the oscillating case. A test should simulate: hover nested trigger, move to outer area, quickly move back to nested trigger, and verify only the inner tooltip is shown.

3. PR description inaccuracy: "bubbling custom DOM event"

The PR body states "close it via a bubbling custom DOM event." The actual implementation uses the native mouseover event (which naturally bubbles) combined with handleNestedTriggerHover on onMouseOver. There is no custom DOM event (new CustomEvent(...) or dispatchEvent(...)) involved. The description should say something like "close it when the bubbling mouseover event reaches the outer trigger and detects a nested trigger target."

4. Dual state management paths for isNestedTriggerHoveredRef

The ref is updated from two different locations:

  • detectNestedTriggerHover sets it on onMouseOver
  • onMouseLeave unconditionally resets it to false

This creates two separate sources of truth. If the logic for what constitutes "nested trigger hovered" ever changes in detectNestedTriggerHover, the raw reset in onMouseLeave could become inconsistent. Additionally, if a pending open timer fires between the mouseover (which set the ref to true) and the mouseleave (which resets it to false), the tooltip open could be incorrectly suppressed, though this window is likely extremely small.

5. shouldOpen return-value contract is implicit

The shouldOpenRef.current?.() !== false pattern means any falsy-but-not-false value (undefined, null, 0, "") is treated as "should open." If a future contributor modifies shouldOpen to return undefined as a signal for "don't open," this pattern silently allows opening. TypeScript constrains this at compile time, but at runtime a function that accidentally returns undefined (forgot the return statement) would silently allow opening instead of preventing it. A brief inline comment would aid maintainability.


Suggestions (4)

6. Extract checkShouldOpen() helper

The pattern shouldOpenRef.current?.() !== false appears 4 times in useHoverReferenceInteraction.ts. Extracting a local helper:

function checkShouldOpen(): boolean {
  return shouldOpenRef.current?.() !== false;
}

would replace the somewhat cryptic double-negation with a readable call at each site.

7. Pass getOpenDelay directly as restMs

Instead of:

restMs() {
  return getOpenDelay();
},

Since useStableCallback already returns a stable function reference:

restMs: getOpenDelay,

8. Missing test: three levels of nesting

The implementation uses closest() to find the nearest enabled [data-tooltip-trigger] ancestor, then checks nearestTrigger !== triggerEl && contains(triggerEl, nearestTrigger). With three levels (grandparent > parent > child), hovering the child should suppress both grandparent and parent tooltips. A basic smoke test would confirm the closest() traversal works correctly at multiple depths.

9. Missing test: Tooltip.Provider with nested tooltips

The experiment page (nested.tsx) demonstrates nested tooltips wrapped in Tooltip.Provider, but no test exercises this combination. The getOpenDelay function interacts with providerContext?.delay and the group delay logic. A test with <Tooltip.Provider> wrapping nested tooltips would verify the delay computation does not interfere with nesting suppression.


Strengths

  • Solid architecture: The shouldOpen callback is a clean, generic integration point in useHoverReferenceInteraction that doesn't couple the hook to tooltip-specific nesting knowledge. The three-piece chain (detectNestedTriggerHover + isNestedTriggerHoveredRef + shouldOpen) cleanly separates DOM detection from generic open-gating.

  • Shadow DOM support: The new closest() utility correctly traverses shadow boundaries, and there is a dedicated test for it. This is the kind of environment-specific edge case that often gets missed.

  • Well-written tests: 8 tests covering core scenarios with realistic event sequences, behavior-oriented assertions, and proper cleanup (shadow DOM test uses finally block). The "cancel pending open" test directly targets the flickering bug.

  • Good comments: The zero-delay comment in handleNestedTriggerHover explains "why" rather than "what." The shouldOpen JSDoc is well-structured. The closest() JSDoc concisely explains the traversal strategy.

  • Correct handling of disabled nested triggers: The ENABLED_TOOLTIP_TRIGGER_SELECTOR uses :not([data-trigger-disabled]) so disabled nested triggers are transparent to the parent, allowing the parent tooltip to open normally.

  • handleNestedTriggerHover structure: The enter/leave cases are well-paired in a single function with a clear early return. Splitting into separate handlers would duplicate detection logic for no benefit.


Recommended Action

  1. Add the focus-opened tooltip test (critical for accessibility)
  2. Fix the PR description to accurately describe the mechanism
  3. Consider adding the rapid oscillation test
  4. Consider the minor code simplifications (items 6 and 7)

@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Apr 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: tooltip Changes related to the tooltip component. PR: out-of-date The pull request has merge conflicts and can't be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[tooltip] Unexpected behaviour when nesting tootip inside another tooltip's trigger

4 participants