Skip to content

Commit 7dfdfa0

Browse files
authored
fix: map Windows touch pointer IDs to small JS-safe identifiers in CompositionEventHandler (#16081)
* fix: map Windows touch pointer IDs to small JS-safe identifiers in CompositionEventHandler (#1) * fix: map Windows touch pointer IDs to small JS-safe identifiers in CompositionEventHandler Windows touch input can assign arbitrarily large pointer IDs (e.g. 2233). The Fabric CompositionEventHandler was forwarding these directly as React Native touch identifiers via `activeTouch.touch.identifier = pointerId`. React Native's JS touch handler uses identifiers as direct array indices and hard-caps them at 20 — large values caused the JS layer to back-fill a 2000+ element sparse array, corrupting touch tracking state. The symptom: after scrolling a ScrollView/FlatList on a touch screen, Pressables and TouchableOpacities would remain stuck in a pressed state. Taps would fire at the coordinates where the scroll began rather than where the finger lifted. Fix: add `AllocateTouchIdentifier()` which cycles through identifiers [0, 19], skipping any slot already claimed by a live touch in `m_activeTouches`. This mirrors the approach iOS uses (cycling modulo RCTMaxTouches = 11 in RCTTouchHandler.m). The existing but unused `m_touchId` field is repurposed as the cycling base. Updated all dependent lookups that previously relied on `touch.identifier == pointerId` (which only worked accidentally) to use the `m_activeTouches` map key directly. Fixes: #16047 * fix: prevent unbounded m_touchId growth in CompositionEventHandler fallback path When all 20 touch slots are occupied, the fallback return in AllocateTouchIdentifier used post-increment (`m_touchId++ % kMaxTouchIdentifier`), which stored the raw incremented value back into m_touchId without a modulo wrap. Repeated hits would grow m_touchId past 19, breaking the cycling logic and eventually causing signed-integer overflow (UB). Fix by computing the wrapped return value first, then storing the next wrapped value back: captures fallback = m_touchId % kMaxTouchIdentifier, then m_touchId = (m_touchId + 1) % kMaxTouchIdentifier. * Create react-native-windows-2c040593-f202-44fc-a55c-5d523c493705.json * Reserve 1 for mouse * yarn format
1 parent 1afd5d7 commit 7dfdfa0

3 files changed

Lines changed: 54 additions & 4 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: map Windows touch pointer IDs to small JS-safe identifiers in CompositionEventHandler",
4+
"packageName": "react-native-windows",
5+
"email": "gordomacmaster@gmail.com",
6+
"dependentChangeType": "patch"
7+
}

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

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,6 +1214,37 @@ void CompositionEventHandler::onPointerExited(
12141214
}
12151215
}
12161216

1217+
// Windows touch pointer IDs can be arbitrarily large (e.g. 2233). React Native's JS
1218+
// touch handler uses identifiers as array indices and warns/misbehaves for values > 20.
1219+
// This function maps each live Windows pointer to a small identifier in [0, 19] by
1220+
// scanning m_activeTouches for in-use slots and cycling from the last assigned index.
1221+
// Identifier MOUSE_POINTER_ID (1) is permanently reserved for mouse and never returned here.
1222+
int CompositionEventHandler::AllocateTouchIdentifier() noexcept {
1223+
constexpr int kMaxTouchIdentifier = 20;
1224+
for (int i = 0; i < kMaxTouchIdentifier; i++) {
1225+
int candidate = (m_touchId + i) % kMaxTouchIdentifier;
1226+
if (candidate == static_cast<int>(MOUSE_POINTER_ID)) {
1227+
continue; // reserved for mouse
1228+
}
1229+
bool inUse = std::any_of(m_activeTouches.begin(), m_activeTouches.end(), [candidate](const auto &pair) {
1230+
return pair.second.touch.identifier == candidate;
1231+
});
1232+
if (!inUse) {
1233+
m_touchId = (candidate + 1) % kMaxTouchIdentifier;
1234+
return candidate;
1235+
}
1236+
}
1237+
// All non-mouse slots occupied (> 19 simultaneous touch/pen points) — wrap anyway,
1238+
// skipping the mouse-reserved slot.
1239+
int fallback = m_touchId;
1240+
m_touchId = (m_touchId + 1) % kMaxTouchIdentifier;
1241+
if (fallback == static_cast<int>(MOUSE_POINTER_ID)) {
1242+
fallback = m_touchId;
1243+
m_touchId = (m_touchId + 1) % kMaxTouchIdentifier;
1244+
}
1245+
return fallback;
1246+
}
1247+
12171248
void CompositionEventHandler::onPointerPressed(
12181249
const winrt::Microsoft::ReactNative::Composition::Input::PointerPoint &pointerPoint,
12191250
winrt::Windows::System::VirtualKeyModifiers keyModifiers) noexcept {
@@ -1322,11 +1353,18 @@ void CompositionEventHandler::onPointerPressed(
13221353
UpdateActiveTouch(activeTouch, ptScaled, ptLocal);
13231354

13241355
activeTouch.isPrimary = pointerId == 1;
1325-
activeTouch.touch.identifier = pointerId;
1356+
// Map the Windows pointer ID to a small identifier (0–19) safe for use as a JS array index.
1357+
// Windows touch IDs can be arbitrarily large (e.g. 2233), which causes React Native to warn
1358+
// and corrupts touch state, leaving Pressables stuck after a scroll.
1359+
// Mouse pointer ID is always 1 (MOUSE_POINTER_ID), which is already within the safe range —
1360+
// use it directly to preserve stable, predictable identifier assignment for mouse input.
1361+
activeTouch.touch.identifier = (pointerPoint.PointerDeviceType() == Composition::Input::PointerDeviceType::Mouse)
1362+
? static_cast<int>(MOUSE_POINTER_ID)
1363+
: AllocateTouchIdentifier();
13261364

13271365
// If the pointer has not been marked as hovering over views before the touch started, we register
13281366
// that the activeTouch should not maintain its hovered state once the pointer has been lifted.
1329-
auto currentlyHoveredTags = m_currentlyHoveredViewsPerPointer.find(activeTouch.touch.identifier);
1367+
auto currentlyHoveredTags = m_currentlyHoveredViewsPerPointer.find(pointerId);
13301368
if (currentlyHoveredTags == m_currentlyHoveredViewsPerPointer.end() || currentlyHoveredTags->second.empty()) {
13311369
activeTouch.shouldLeaveWhenReleased = true;
13321370
}
@@ -1641,7 +1679,7 @@ void CompositionEventHandler::DispatchTouchEvent(
16411679
continue;
16421680
}
16431681

1644-
if (activeTouch.touch.identifier == pointerId) {
1682+
if (pair.first == pointerId) {
16451683
event.changedTouches.insert(activeTouch.touch);
16461684
}
16471685
uniqueEventEmitters.insert(activeTouch.eventEmitter);

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,13 @@ class CompositionEventHandler : public std::enable_shared_from_this<CompositionE
162162
void UpdateCursor() noexcept;
163163
void SetCursor(facebook::react::Cursor cursor, HCURSOR hcur) noexcept;
164164

165+
// Allocates a small touch identifier (0–19) that is safe to use as a JS array index.
166+
// Windows pointer IDs can be arbitrarily large, which causes React Native to warn and
167+
// back-fill huge arrays, corrupting touch state after scrolling.
168+
int AllocateTouchIdentifier() noexcept;
169+
165170
std::map<PointerId, ActiveTouch> m_activeTouches; // iOS is map of touch event args to ActiveTouch..?
166-
PointerId m_touchId = 0;
171+
int m_touchId = 0; // cycling base used by AllocateTouchIdentifier
167172

168173
std::map<PointerId, std::vector<ReactTaggedView>> m_currentlyHoveredViewsPerPointer;
169174
winrt::weak_ref<winrt::Microsoft::ReactNative::ReactNativeIsland> m_wkRootView;

0 commit comments

Comments
 (0)