Skip to content

Fix tooltip positioning for ContentIsland and dismiss on scroll#15644

Open
Nitin-100 wants to merge 7 commits intomicrosoft:mainfrom
Nitin-100:nitinc/fix-tooltip-positioning-on-scroll
Open

Fix tooltip positioning for ContentIsland and dismiss on scroll#15644
Nitin-100 wants to merge 7 commits intomicrosoft:mainfrom
Nitin-100:nitinc/fix-tooltip-positioning-on-scroll

Conversation

@Nitin-100
Copy link
Contributor

@Nitin-100 Nitin-100 commented Feb 9, 2026

Description

Fixes tooltip positioning being incorrect (trimmed/offset) when scrolling in Fabric ContentIsland-hosted apps, and ensures tooltips are dismissed immediately when the user scrolls.

Why

In the RNW Gallery app (and any Fabric Composition app), hovering over a component with a tooltip prop and then scrolling causes:

  1. Wrong tooltip position — The tooltip appears at an incorrect screen location because the coordinate conversion used ClientToScreen(parentHwnd), which assumes HWND client coordinates. In ContentIsland hosting, m_pos (from PointerPoint::Position()) is in island-local DIP coordinates, not HWND client coordinates. ClientToScreen produces the wrong screen position.

  2. Stale tooltip on scroll — After scrolling, the tooltip remains visible at the old screen position while the component underneath has moved, making the tooltip appear detached or trimmed.

What

Files Changed

File Change
TooltipService.cpp Replace ClientToScreen with LocalToScreen for correct coordinate conversion; extract ClampTooltipToMonitor helper; add DismissActiveTooltip implementation
TooltipService.h Add DismissActiveTooltip() to TooltipTracker; add DismissAllTooltips() to TooltipService
ScrollViewComponentView.cpp Call TooltipService::DismissAllTooltips() on scroll position change

Changes in Detail

  • TooltipService.cpp — Coordinate fix (core fix)

    • Before: POINT pt = {m_pos.X * scaleFactor, m_pos.Y * scaleFactor}; ClientToScreen(parentHwnd, &pt);
    • After: auto screenPt = selfView->LocalToScreen({m_pos.X, m_pos.Y}); POINT pt = {static_cast<LONG>(screenPt.X), static_cast<LONG>(screenPt.Y)};
    • LocalToScreen follows the correct Fabric conversion chain: ComponentView::LocalToScreen()RootComponentView::ConvertLocalToScreen()ReactNativeIsland::ConvertLocalToScreen() which uses m_island.CoordinateConverter().ConvertLocalToScreen() for ContentIsland hosting, or falls back to ClientToScreen(m_hwnd) for legacy HWND hosting.
  • TooltipService.cpp — Monitor edge clamping (extracted helper)

    • Extracted inline clamping logic into ClampTooltipToMonitor() — clamps tooltip X/Y to the nearest monitor's work area and flips tooltip below cursor if it would go above the screen.
  • TooltipService.h / .cpp — Dismiss API

    • Added TooltipTracker::DismissActiveTooltip() — destroys any active timer and tooltip window (both have null guards, so calling on inactive trackers is a no-op).
    • Added TooltipService::DismissAllTooltips() — iterates all trackers and calls DismissActiveTooltip().
  • ScrollViewComponentView.cpp — Scroll dismiss integration

    • Calls TooltipService::GetCurrent(m_reactContext.Properties())->DismissAllTooltips() at the top of the ScrollPositionChanged callback. This covers all scroll types: mouse wheel, touch drag, keyboard navigation, and programmatic scroll.

How

Root Cause Analysis

PointerPoint::Position() with tag=-1  →  island-local DIP coordinates
                                         (NOT HWND client coordinates)

OLD: m_pos * scaleFactor + ClientToScreen(parentHwnd)  →  WRONG screen position
NEW: selfView->LocalToScreen(m_pos)                    →  CORRECT screen position

The LocalToScreen call properly traverses the Fabric view hierarchy:

ComponentView::LocalToScreen(pt)
  → adjusts for layoutMetrics offset
  → walks up parent chain
  → RootComponentView::ConvertLocalToScreen(pt)
    → ReactNativeIsland::ConvertLocalToScreen(pt)
      → ContentIsland path: m_island.CoordinateConverter().ConvertLocalToScreen(pt)
      → HWND fallback path: pt * scaleFactor + ClientToScreen(m_hwnd)

This handles both hosting models correctly without hardcoding either one.

Scroll Dismissal

ScrollPositionChanged on the IScrollVisual fires for all scroll types (wheel, touch, keyboard, programmatic). Calling DismissAllTooltips() here is the single integration point. Each tracker's DismissActiveTooltip() delegates to DestroyTimer() + DestroyTooltip(), both of which have if (m_timer) / if (m_hwndTip) null guards — so inactive trackers cost just two pointer comparisons.

Testing

  1. Build and launch the playground-composition app
  2. Hover over any component with a tooltip prop until the tooltip appears
  3. Scroll — tooltip should dismiss immediately
  4. Hover again after scrolling — tooltip should appear at the correct position relative to the cursor
  5. Move app to screen edge — tooltip should clamp to monitor work area, not go off-screen
  6. Multi-monitor — tooltip should clamp to the correct monitor

Screenshots

Before

failedtooltip.mp4

After

fixedtooltip.mp4
Microsoft Reviewers: Open in CodeFlow

- Replace ClientToScreen with LocalToScreen for correct coordinate conversion in ContentIsland hosting

- Extract ClampTooltipToMonitor helper for monitor edge clamping

- Add DismissAllTooltips called from ScrollViewComponentView on scroll position change

- Add DismissActiveTooltip to TooltipTracker for external dismissal
@Nitin-100 Nitin-100 requested a review from a team as a code owner February 9, 2026 13:15
@Nitin-100 Nitin-100 force-pushed the nitinc/fix-tooltip-positioning-on-scroll branch from df61636 to 55c9416 Compare February 9, 2026 14:21
// Dismiss any visible tooltips when scroll position changes, since
// scrolling moves child components and the tooltip would be left at
// the wrong position on screen.
TooltipService::GetCurrent(m_reactContext.Properties())->DismissAllTooltips();
Copy link
Contributor

Choose a reason for hiding this comment

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

GetCurrent can return nullptr if the TooltipService hasn't been initialized, right ?

This might lead to crashes

DestroyTooltip();
}

void TooltipTracker::DismissActiveTooltip() noexcept {
Copy link
Contributor

Choose a reason for hiding this comment

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

we should also dismiss the current tracking state, next hover should always new state


// Clamp tooltip position so it stays within the nearest monitor's work area.
// Flips the tooltip below the cursor if it would go above the work area.
void ClampTooltipToMonitor(
Copy link
Contributor

Choose a reason for hiding this comment

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

we should also handle negative or zero tooltip size , in case of integer overflow from bad text metrics

const RECT &workArea = mi.rcWork;

// Clamp horizontally
if (x + tooltipWidth > workArea.right) {
Copy link
Contributor

Choose a reason for hiding this comment

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

in case of very wide tooltip , tooltipWidth > (workArea.right - workArea.left), the first clamp sets x to a value less than workArea.left, and then the second clamp overrides it to workArea.left.

I guess tooltip will overflow on the right

suggestion
could be addressed with x = workArea.left and capping width to monitor width

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

Comments