Skip to content

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

1 participant