From c3b17a2270a7bebabe2bd23e30c7ab4c85aec09c Mon Sep 17 00:00:00 2001 From: Gordon MacMaster <31481849+gmacmaster@users.noreply.github.com> Date: Tue, 5 May 2026 19:31:00 -0400 Subject: [PATCH] fix: cancel active touches in onPointerCaptureLost to prevent zombie touch state (#16086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: cancel active touches in onPointerCaptureLost to prevent zombie touch state (#2) * fix: cancel active touches in onPointerCaptureLost to prevent zombie touch state When the OS reclaims pointer capture mid-gesture (system back swipe, Alt+Tab, another window coming foreground), onPointerCaptureLost fired but never removed entries from m_activeTouches or dispatched a touchcancel to React Native JS. The result was a zombie active touch — RN JS believed a finger was still down, leaving Pressables and TouchableOpacities stuck in a pressed state until the next touch happened to reuse the same pointer ID and trigger the stale-touch cleanup in onPointerPressed. Fix: for each captured pointer in the lost-capture set, copy and erase the m_activeTouches entry before calling DispatchSynthesizedTouchCancelForActiveTouch, mirroring the pattern already used in onPointerPressed and onPointerReleased. The erase-before-dispatch ordering is required because DispatchSynthesizedTouchCancelForActiveTouch iterates m_activeTouches internally. * match the m_pointerCapturingComponentTag != -1 check already used in CapturePointer * Create react-native-windows-f47bdf2a-0807-483f-b63e-3c2c086bee92.json --- ...ows-f47bdf2a-0807-483f-b63e-3c2c086bee92.json | 7 +++++++ .../Composition/CompositionEventHandler.cpp | 16 +++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 change/react-native-windows-f47bdf2a-0807-483f-b63e-3c2c086bee92.json diff --git a/change/react-native-windows-f47bdf2a-0807-483f-b63e-3c2c086bee92.json b/change/react-native-windows-f47bdf2a-0807-483f-b63e-3c2c086bee92.json new file mode 100644 index 00000000000..541f74718ee --- /dev/null +++ b/change/react-native-windows-f47bdf2a-0807-483f-b63e-3c2c086bee92.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "fix: cancel active touches in onPointerCaptureLost to prevent zombie touch state", + "packageName": "react-native-windows", + "email": "gordomacmaster@gmail.com", + "dependentChangeType": "patch" +} \ No newline at end of file diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp index 928ee52b407..99890b594eb 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp @@ -1092,12 +1092,26 @@ void CompositionEventHandler::onPointerCaptureLost( if (SurfaceId() == -1) return; - if (m_pointerCapturingComponentTag) { + if (m_pointerCapturingComponentTag != -1) { // copy array to avoid iterator being invalidated during deletion std::unordered_set capturedPointers = m_capturedPointers; for (auto pointerId : capturedPointers) { releasePointerCapture(pointerId, m_pointerCapturingComponentTag); + + // Cancel any active touch for this pointer so React Native is notified that + // the touch ended. Without this, m_activeTouches retains a zombie entry and + // RN JS is never told the touch is gone — leaving Pressables stuck in a + // pressed state after a system-interrupted gesture (e.g. system back swipe, + // Alt+Tab, another window coming foreground). + 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); + } + } } m_pointerCapturingComponentTag = -1;