@@ -224,6 +224,28 @@ void CompositionEventHandler::Initialize() noexcept {
224224 }
225225 });
226226
227+ // Issue #16047: when ScrollView calls VisualInteractionSource::TryRedirectForManipulation
228+ // and the OS hands the pointer over to the InteractionTracker, WinAppSDK
229+ // does not fire PointerCaptureLost on this source — but it does fire
230+ // PointerRoutedAway. Treat it the same way as captureloss: cancel any
231+ // active touch RN is tracking for this pointer so Pressables don't get
232+ // stuck in their pressed state.
233+ m_pointerRoutedAwayToken =
234+ pointerSource.PointerRoutedAway ([wkThis = weak_from_this ()](
235+ winrt::Microsoft::UI::Input::InputPointerSource const &,
236+ winrt::Microsoft::UI::Input::PointerEventArgs const &args) {
237+ if (auto strongThis = wkThis.lock ()) {
238+ if (auto strongRootView = strongThis->m_wkRootView .get ()) {
239+ if (strongThis->SurfaceId () == -1 )
240+ return ;
241+
242+ auto pp = winrt::make<winrt::Microsoft::ReactNative::Composition::Input::implementation::PointerPoint>(
243+ args.CurrentPoint (), strongRootView.ScaleFactor ());
244+ strongThis->onPointerRoutedAway (pp, args.KeyModifiers ());
245+ }
246+ }
247+ });
248+
227249 m_pointerWheelChangedToken =
228250 pointerSource.PointerWheelChanged ([wkThis = weak_from_this ()](
229251 winrt::Microsoft::UI::Input::InputPointerSource const &,
@@ -369,6 +391,7 @@ CompositionEventHandler::~CompositionEventHandler() {
369391 pointerSource.PointerReleased (m_pointerReleasedToken);
370392 pointerSource.PointerMoved (m_pointerMovedToken);
371393 pointerSource.PointerCaptureLost (m_pointerCaptureLostToken);
394+ pointerSource.PointerRoutedAway (m_pointerRoutedAwayToken);
372395 pointerSource.PointerWheelChanged (m_pointerWheelChangedToken);
373396 pointerSource.PointerExited (m_pointerExitedToken);
374397 auto keyboardSource = winrt::Microsoft::UI::Input::InputKeyboardSource::GetForIsland (island);
@@ -1117,24 +1140,49 @@ void CompositionEventHandler::onPointerCaptureLost(
11171140 m_pointerCapturingComponentTag = -1 ;
11181141 }
11191142
1120- // Also cancel any active touch for the specific pointer that lost capture, even
1121- // when no JS-level CapturePointer was ever issued. This handles ScrollView (and
1122- // any other VisualInteractionSource) calling TryRedirectForManipulation: the OS
1123- // reassigns the pointer to the InteractionTracker, fires PointerCaptureLost, and
1124- // then stops delivering PointerMoved/PointerReleased to us. Without this cleanup
1125- // m_activeTouches keeps a zombie entry whose target is the originally-pressed
1126- // Pressable, leaving it visually pressed and causing later taps to be attributed
1127- // to that original target. If the entry was already cleared above (for a JS-level
1128- // capture) or by onPointerReleased running first, the find() is a no-op.
1129- PointerId pointerId = pointerPoint.PointerId ();
1143+ // Defense-in-depth cleanup for the specific pointer that lost capture, even
1144+ // when no JS-level CapturePointer was ever issued. The ScrollView
1145+ // TryRedirectForManipulation path comes in via PointerRoutedAway, not
1146+ // PointerCaptureLost (see onPointerRoutedAway and issue #16047), so this
1147+ // path covers the remaining system-driven losses (focus change, another
1148+ // window stealing input, system back gesture, etc.).
1149+ CancelActiveTouchForPointerInternal (pointerPoint.PointerId (), pointerPoint, keyModifiers);
1150+ }
1151+
1152+ void CompositionEventHandler::onPointerRoutedAway (
1153+ const winrt::Microsoft::ReactNative::Composition::Input::PointerPoint &pointerPoint,
1154+ winrt::Windows::System::VirtualKeyModifiers keyModifiers) noexcept {
1155+ if (SurfaceId () == -1 )
1156+ return ;
1157+
1158+ // Issue #16047: WinAppSDK fires PointerRoutedAway when the OS hands the
1159+ // pointer to another InputPointerSource — most importantly for us, when
1160+ // ScrollView calls VisualInteractionSource::TryRedirectForManipulation and
1161+ // the InteractionTracker takes the gesture for scrolling. We never get
1162+ // PointerMoved / PointerReleased / PointerCaptureLost for that pointer
1163+ // afterwards, so without this cleanup m_activeTouches keeps a zombie entry
1164+ // and the originally-pressed Pressable stays stuck in its pressed state.
1165+ CancelActiveTouchForPointerInternal (pointerPoint.PointerId (), pointerPoint, keyModifiers);
1166+ }
1167+
1168+ bool CompositionEventHandler::CancelActiveTouchForPointerInternal (
1169+ PointerId pointerId,
1170+ const winrt::Microsoft::ReactNative::Composition::Input::PointerPoint &pointerPoint,
1171+ winrt::Windows::System::VirtualKeyModifiers keyModifiers) noexcept {
11301172 auto activeTouch = m_activeTouches.find (pointerId);
1131- if (activeTouch != m_activeTouches.end ()) {
1132- ActiveTouch cancelledTouchCopy = std::move (activeTouch->second );
1133- m_activeTouches.erase (activeTouch);
1134- if (cancelledTouchCopy.eventEmitter ) {
1135- DispatchSynthesizedTouchCancelForActiveTouch (cancelledTouchCopy, pointerPoint, keyModifiers);
1136- }
1173+ if (activeTouch == m_activeTouches.end ()) {
1174+ return false ;
1175+ }
1176+
1177+ ActiveTouch cancelledTouchCopy = std::move (activeTouch->second );
1178+ m_activeTouches.erase (activeTouch);
1179+
1180+ if (!cancelledTouchCopy.eventEmitter ) {
1181+ return false ;
11371182 }
1183+
1184+ DispatchSynthesizedTouchCancelForActiveTouch (cancelledTouchCopy, pointerPoint, keyModifiers);
1185+ return true ;
11381186}
11391187
11401188void CompositionEventHandler::onPointerMoved (
0 commit comments