Skip to content

Commit 5213c8f

Browse files
authored
fix: cancel zombie touch state on ScrollView pointer capture loss (#3) (#16100)
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.
1 parent 2269a19 commit 5213c8f

2 files changed

Lines changed: 49 additions & 22 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "prerelease",
3+
"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",
4+
"packageName": "react-native-windows",
5+
"email": "gordomacmaster@gmail.com",
6+
"dependentChangeType": "patch"
7+
}

vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,6 +1116,25 @@ void CompositionEventHandler::onPointerCaptureLost(
11161116

11171117
m_pointerCapturingComponentTag = -1;
11181118
}
1119+
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();
1130+
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+
}
1137+
}
11191138
}
11201139

11211140
void CompositionEventHandler::onPointerMoved(
@@ -1614,16 +1633,6 @@ bool CompositionEventHandler::IsPointerWithinInitialTree(const ActiveTouch &acti
16141633
currentView = currentView.Parent();
16151634
}
16161635

1617-
// Fallback: if the pointer drifted spatially but the original target
1618-
// is still structurally within the initial tree, honor the tap.
1619-
// This provides touch-device tolerance for finger drift.
1620-
auto targetView = viewRegistry.componentViewDescriptorWithTag(activeTouch.touch.target).view;
1621-
while (targetView) {
1622-
if (targetView.Tag() == initialTag)
1623-
return true;
1624-
targetView = targetView.Parent();
1625-
}
1626-
16271636
return false;
16281637
}
16291638

@@ -1685,7 +1694,15 @@ void CompositionEventHandler::DispatchTouchEvent(
16851694

16861695
facebook::react::TouchEvent event;
16871696

1688-
size_t index = 0;
1697+
// First pass: build changedTouches and the set of unique emitters from every active
1698+
// touch. The per-pointer PointerEvent dispatch (onPointerDown/Move/Up/Cancel/Click) is
1699+
// fired only for the touch whose state actually changed — non-changed touches contribute
1700+
// to the W3C TouchEvent's touches/targetTouches sets in the loops below but must not
1701+
// re-fire pointer events of their own. Previously we dispatched the per-pointer event
1702+
// for every entry in m_activeTouches, which produced duplicated onPointerMove on
1703+
// non-moving fingers and replayed onPointerUp/onClick on stale targets after the OS
1704+
// reclaimed a pointer (e.g. ScrollView manipulation redirect leaving a zombie touch).
1705+
const ActiveTouch *changedTouch = nullptr;
16891706
for (const auto &pair : m_activeTouches) {
16901707
const auto &activeTouch = pair.second;
16911708

@@ -1694,14 +1711,17 @@ void CompositionEventHandler::DispatchTouchEvent(
16941711
}
16951712

16961713
if (pair.first == pointerId) {
1714+
changedTouch = &activeTouch;
16971715
event.changedTouches.insert(activeTouch.touch);
16981716
}
16991717
uniqueEventEmitters.insert(activeTouch.eventEmitter);
1718+
}
17001719

1701-
facebook::react::PointerEvent pointerEvent = CreatePointerEventFromActiveTouch(activeTouch, eventType);
1720+
if (changedTouch) {
1721+
facebook::react::PointerEvent pointerEvent = CreatePointerEventFromActiveTouch(*changedTouch, eventType);
17021722

17031723
winrt::Microsoft::ReactNative::ComponentView targetView{nullptr};
1704-
bool shouldLeave = (eventType == TouchEventType::End && activeTouch.shouldLeaveWhenReleased) ||
1724+
bool shouldLeave = (eventType == TouchEventType::End && changedTouch->shouldLeaveWhenReleased) ||
17051725
eventType == TouchEventType::Cancel;
17061726
if (!shouldLeave) {
17071727
auto *rootViewForHit = RootComponentView();
@@ -1716,29 +1736,29 @@ void CompositionEventHandler::DispatchTouchEvent(
17161736
}
17171737
}
17181738

1719-
auto handler = [this, &activeTouch, eventType, &pointerEvent](
1739+
auto handler = [this, changedTouch, eventType, &pointerEvent](
17201740
std::vector<winrt::Microsoft::ReactNative::ComponentView> &eventPathViews) {
17211741
switch (eventType) {
17221742
case TouchEventType::Start:
1723-
activeTouch.eventEmitter->onPointerDown(pointerEvent);
1743+
changedTouch->eventEmitter->onPointerDown(pointerEvent);
17241744
break;
17251745
case TouchEventType::Move: {
1726-
activeTouch.eventEmitter->onPointerMove(pointerEvent);
1746+
changedTouch->eventEmitter->onPointerMove(pointerEvent);
17271747
break;
17281748
}
17291749
case TouchEventType::End:
1730-
activeTouch.eventEmitter->onPointerUp(pointerEvent);
1750+
changedTouch->eventEmitter->onPointerUp(pointerEvent);
17311751
if (pointerEvent.isPrimary && pointerEvent.button == 0) {
1732-
if (IsPointerWithinInitialTree(activeTouch)) {
1733-
activeTouch.eventEmitter->onClick(pointerEvent);
1752+
if (IsPointerWithinInitialTree(*changedTouch)) {
1753+
changedTouch->eventEmitter->onClick(pointerEvent);
17341754
}
1735-
} else if (IsPointerWithinInitialTree(activeTouch)) {
1736-
activeTouch.eventEmitter->onAuxClick(pointerEvent);
1755+
} else if (IsPointerWithinInitialTree(*changedTouch)) {
1756+
changedTouch->eventEmitter->onAuxClick(pointerEvent);
17371757
}
17381758
break;
17391759
case TouchEventType::Cancel:
17401760
case TouchEventType::CaptureLost:
1741-
activeTouch.eventEmitter->onPointerCancel(pointerEvent);
1761+
changedTouch->eventEmitter->onPointerCancel(pointerEvent);
17421762
break;
17431763
}
17441764
};

0 commit comments

Comments
 (0)