From 84be73070d181410b481abb8bcdf49e68f0bf51c Mon Sep 17 00:00:00 2001 From: Gordon MacMaster <31481849+gmacmaster@users.noreply.github.com> Date: Thu, 7 May 2026 22:01:32 -0400 Subject: [PATCH 1/2] fix: cancel zombie touch state on ScrollView pointer capture loss (#3) When a touch-screen user scrolls a ScrollView, the OS redirects the pointer to the InteractionTracker via TryRedirectForManipulation and fires PointerCaptureLost. The existing handler only cleaned up touches when JS-level CapturePointer was active (m_pointerCapturingComponentTag != -1), which ScrollView never uses. This left a zombie entry in m_activeTouches that kept Pressables visually stuck in a pressed state and caused subsequent taps to replay events against the original target. Three changes: 1. Extend onPointerCaptureLost to unconditionally cancel the active touch for the specific pointer that lost capture, regardless of whether JS-level CapturePointer was ever issued. 2. Remove the always-true fallback in IsPointerWithinInitialTree that walked from activeTouch.touch.target (always the initial view) back to initialTag, returning true on iteration 1 and bypassing the correct W3C hit-test check. This caused onClick to fire even when the pointer was released over a different target. 3. Scope per-pointer event dispatch in DispatchTouchEvent to only the pointer that actually changed, instead of iterating every entry in m_activeTouches. The old loop fired onPointerDown/Move/Up/Cancel for all active touches, producing duplicated events in multi-touch scenarios and replaying events on zombie targets. --- ...-c470c550-399c-4439-80d3-4f8f1761cccb.json | 7 ++ .../Composition/CompositionEventHandler.cpp | 66 ++++++++++++------- 2 files changed, 50 insertions(+), 23 deletions(-) create mode 100644 change/react-native-windows-c470c550-399c-4439-80d3-4f8f1761cccb.json diff --git a/change/react-native-windows-c470c550-399c-4439-80d3-4f8f1761cccb.json b/change/react-native-windows-c470c550-399c-4439-80d3-4f8f1761cccb.json new file mode 100644 index 00000000000..97c3f806f53 --- /dev/null +++ b/change/react-native-windows-c470c550-399c-4439-80d3-4f8f1761cccb.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "fix: cancel zombie touch state when ScrollView redirects pointer for manipulation, scope per-pointer events to the changed pointer, and remove always-true IsPointerWithinInitialTree fallback", + "packageName": "react-native-windows", + "email": "gordomacmaster@gmail.com", + "dependentChangeType": "patch" +} diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp index 99890b594eb..b645ec53f9f 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp @@ -1116,6 +1116,25 @@ void CompositionEventHandler::onPointerCaptureLost( m_pointerCapturingComponentTag = -1; } + + // Also cancel any active touch for the specific pointer that lost capture, even + // when no JS-level CapturePointer was ever issued. This handles ScrollView (and + // any other VisualInteractionSource) calling TryRedirectForManipulation: the OS + // reassigns the pointer to the InteractionTracker, fires PointerCaptureLost, and + // then stops delivering PointerMoved/PointerReleased to us. Without this cleanup + // m_activeTouches keeps a zombie entry whose target is the originally-pressed + // Pressable, leaving it visually pressed and causing later taps to be attributed + // to that original target. If the entry was already cleared above (for a JS-level + // capture) or by onPointerReleased running first, the find() is a no-op. + PointerId pointerId = pointerPoint.PointerId(); + auto activeTouch = m_activeTouches.find(pointerId); + if (activeTouch != m_activeTouches.end()) { + ActiveTouch cancelledTouchCopy = std::move(activeTouch->second); + m_activeTouches.erase(activeTouch); + if (cancelledTouchCopy.eventEmitter) { + DispatchSynthesizedTouchCancelForActiveTouch(cancelledTouchCopy, pointerPoint, keyModifiers); + } + } } void CompositionEventHandler::onPointerMoved( @@ -1614,16 +1633,6 @@ bool CompositionEventHandler::IsPointerWithinInitialTree(const ActiveTouch &acti currentView = currentView.Parent(); } - // Fallback: if the pointer drifted spatially but the original target - // is still structurally within the initial tree, honor the tap. - // This provides touch-device tolerance for finger drift. - auto targetView = viewRegistry.componentViewDescriptorWithTag(activeTouch.touch.target).view; - while (targetView) { - if (targetView.Tag() == initialTag) - return true; - targetView = targetView.Parent(); - } - return false; } @@ -1685,7 +1694,15 @@ void CompositionEventHandler::DispatchTouchEvent( facebook::react::TouchEvent event; - size_t index = 0; + // First pass: build changedTouches and the set of unique emitters from every active + // touch. The per-pointer PointerEvent dispatch (onPointerDown/Move/Up/Cancel/Click) is + // fired only for the touch whose state actually changed — non-changed touches contribute + // to the W3C TouchEvent's touches/targetTouches sets in the loops below but must not + // re-fire pointer events of their own. Previously we dispatched the per-pointer event + // for every entry in m_activeTouches, which produced duplicated onPointerMove on + // non-moving fingers and replayed onPointerUp/onClick on stale targets after the OS + // reclaimed a pointer (e.g. ScrollView manipulation redirect leaving a zombie touch). + const ActiveTouch *changedTouch = nullptr; for (const auto &pair : m_activeTouches) { const auto &activeTouch = pair.second; @@ -1694,14 +1711,17 @@ void CompositionEventHandler::DispatchTouchEvent( } if (pair.first == pointerId) { + changedTouch = &activeTouch; event.changedTouches.insert(activeTouch.touch); } uniqueEventEmitters.insert(activeTouch.eventEmitter); + } - facebook::react::PointerEvent pointerEvent = CreatePointerEventFromActiveTouch(activeTouch, eventType); + if (changedTouch) { + facebook::react::PointerEvent pointerEvent = CreatePointerEventFromActiveTouch(*changedTouch, eventType); winrt::Microsoft::ReactNative::ComponentView targetView{nullptr}; - bool shouldLeave = (eventType == TouchEventType::End && activeTouch.shouldLeaveWhenReleased) || + bool shouldLeave = (eventType == TouchEventType::End && changedTouch->shouldLeaveWhenReleased) || eventType == TouchEventType::Cancel; if (!shouldLeave) { auto *rootViewForHit = RootComponentView(); @@ -1716,29 +1736,29 @@ void CompositionEventHandler::DispatchTouchEvent( } } - auto handler = [this, &activeTouch, eventType, &pointerEvent]( + auto handler = [this, changedTouch, eventType, &pointerEvent]( std::vector &eventPathViews) { switch (eventType) { case TouchEventType::Start: - activeTouch.eventEmitter->onPointerDown(pointerEvent); + changedTouch->eventEmitter->onPointerDown(pointerEvent); break; case TouchEventType::Move: { - activeTouch.eventEmitter->onPointerMove(pointerEvent); + changedTouch->eventEmitter->onPointerMove(pointerEvent); break; } case TouchEventType::End: - activeTouch.eventEmitter->onPointerUp(pointerEvent); + changedTouch->eventEmitter->onPointerUp(pointerEvent); if (pointerEvent.isPrimary && pointerEvent.button == 0) { - if (IsPointerWithinInitialTree(activeTouch)) { - activeTouch.eventEmitter->onClick(pointerEvent); + if (IsPointerWithinInitialTree(*changedTouch)) { + changedTouch->eventEmitter->onClick(pointerEvent); } - } /* else if (IsPointerWithinInitialTree(activeTouch)) { - activeTouch.eventEmitter->onAuxClick(pointerEvent); - } */ + } else if (IsPointerWithinInitialTree(*changedTouch)) { + changedTouch->eventEmitter->onAuxClick(pointerEvent); + } break; case TouchEventType::Cancel: case TouchEventType::CaptureLost: - activeTouch.eventEmitter->onPointerCancel(pointerEvent); + changedTouch->eventEmitter->onPointerCancel(pointerEvent); break; } }; From e05b96f49c927bf70eae537a1b51108a4774fed4 Mon Sep 17 00:00:00 2001 From: Gordon MacMaster Date: Sat, 9 May 2026 08:46:19 -0400 Subject: [PATCH 2/2] Fix build error: The onAuxClick call is now commented out again, matching the base branch. --- .../Fabric/Composition/CompositionEventHandler.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp index b645ec53f9f..d8661fb310d 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp @@ -1752,9 +1752,9 @@ void CompositionEventHandler::DispatchTouchEvent( if (IsPointerWithinInitialTree(*changedTouch)) { changedTouch->eventEmitter->onClick(pointerEvent); } - } else if (IsPointerWithinInitialTree(*changedTouch)) { + } /* else if (IsPointerWithinInitialTree(*changedTouch)) { changedTouch->eventEmitter->onAuxClick(pointerEvent); - } + } */ break; case TouchEventType::Cancel: case TouchEventType::CaptureLost: