Skip to content

Preserve pointer identity when replacing unique Fabric pointer moves - #59922

Draft
alleneubank wants to merge 1 commit into
react:mainfrom
alleneubank:fix/p1-eventqueue-pointer-coalescing
Draft

alleneubank wants to merge 1 commit into
react:mainfrom
alleneubank:fix/p1-eventqueue-pointer-coalescing

Conversation

@alleneubank

Copy link
Copy Markdown

Summary:

Fixes #59918

Status: red/green was observed at runtime on source-built RNTester v0.88.1 (iOS and Android, details below). The proposed GoogleTest fixture has not been compiled or run: no exported open-source runner registers ReactCommon core tests, so the Test Plan asks for a maintainer run.

Queued Fabric pointermove events from different contacts on the same native view can replace one another. This affects the typed iOS PointerEvent payload and Android's dynamic pointer payload. Under JS-thread load, one contact can receive no move callbacks even though both contacts moved. This is different from dropping intermediate samples from the same pointer.

Expected: an eligible replacement matches the target, event type, and pointer identity. Two different contacts retain separate moves. Consecutive moves for one contact still coalesce, and the latest same-target event remains an ordering barrier.

Actual: the unique-event replacement compares target and type but omits pointer identity. For one target, queueing primary pointer 2 at x=120 followed by secondary pointer 3 at x=210 yields only pointer 3's move. Reversing their order can instead drop the secondary pointer.

Source and current status

All root-cause links below are pinned to React Native v0.86.0, commit a632f9efe24bac8b3a113c78469948f55bde0f5d:

On 2026-11-04, the same faulty replacement was present in main at 38f6e192c8b0a04ddf93b0f21c394c24217f9692. Its production file is identical in v0.86.0 and latest stable v0.88.1. The source-built latest-stable observations and remaining runtime gaps are reported below. Bounded public issue/PR searches found no matching report; that is not an exhaustive duplicate check.

Proposed fix against main

The helper reads typed pointer identity directly and dynamic topPointerMove identity through the existing numeric extractor. Optional double values avoid narrowing Android's numeric IDs. Other dynamic event types retain the existing behavior even if they carry a property named pointerId. The existing same-target scan and its break stay intact. No public signature, discriminator, or object layout is changed. The core-to-view header dependency and extraction cost still need upstream review.

Patch base: RN main commit 38f6e192c8b0a04ddf93b0f21c394c24217f9692. The production change and complete test proposal are shown below; the patch contains both.

diff --git a/packages/react-native/ReactCommon/react/renderer/core/EventQueue.cpp b/packages/react-native/ReactCommon/react/renderer/core/EventQueue.cpp
index 27b4ee16df..d091064d8e 100644
--- a/packages/react-native/ReactCommon/react/renderer/core/EventQueue.cpp
+++ b/packages/react-native/ReactCommon/react/renderer/core/EventQueue.cpp
@@ -8,11 +8,29 @@
 #include "EventQueue.h"
 
 #include <react/featureflags/ReactNativeFeatureFlags.h>
+#include <react/renderer/components/view/PointerEvent.h>
+#include <optional>
 #include "EventEmitter.h"
 #include "ShadowNodeFamily.h"
 
 namespace facebook::react {
 
+namespace {
+
+std::optional<double> pointerIdForCoalescing(const RawEvent& event) {
+  const auto& payload = event.eventPayload;
+  if (payload && payload->getType() == EventPayloadType::PointerEvent) {
+    return static_cast<const PointerEvent&>(*payload).pointerId;
+  }
+  // Android pointer moves share ValueFactory's type with nonpointer events.
+  if (payload && event.type == "topPointerMove") {
+    return payload->extractValue({"pointerId"});
+  }
+  return std::nullopt;
+}
+
+} // namespace
+
 EventQueue::EventQueue(
     EventQueueProcessor eventProcessor,
     std::unique_ptr<EventBeat> eventBeat)
@@ -35,7 +53,8 @@ void EventQueue::enqueueEvent(RawEvent&& rawEvent) const {
           // for the same target. If the same target has event types A1, B1
           // in the event queue and event A2 occurs. A1 has to stay in the
           // queue.
-          if (it->isUnique && it->type == rawEvent.type) {
+          if (it->isUnique && it->type == rawEvent.type &&
+              pointerIdForCoalescing(*it) == pointerIdForCoalescing(rawEvent)) {
             repeatedEvent = it;
           }

Regression test proposal

The complete proposed GoogleTest fixture follows the adjacent EventQueueProcessorTest.cpp. It enqueues real RawEvents and drains RN EventQueue through EventQueueProcessor and Hermes. The dynamic arm uses the production DynamicEventPayload factory. The 12 parameterized cases cover distinct pointer identity in both orders, same-pointer replacement, pointer/type/phase/nonunique barriers, cross-target behavior, and nonpointer replacement. The last two nonpointer cases are intentionally run under both fixture parameters. Test destination: packages/react-native/ReactCommon/react/renderer/core/tests/EventQueueTest.cpp.

EventQueueTest.cpp
/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

#include <gtest/gtest.h>
#include <hermes/hermes.h>
#include <react/renderer/components/view/PointerEvent.h>
#include <react/renderer/core/DynamicEventPayload.h>
#include <react/renderer/core/EventQueue.h>
#include <react/renderer/core/InstanceHandle.h>
#include <react/renderer/core/ValueFactoryEventPayload.h>
#include <react/renderer/runtimescheduler/RuntimeScheduler.h>

namespace facebook::react {

namespace {

class DrainableEventQueue : public EventQueue {
 public:
  using EventQueue::EventQueue;
  using EventQueue::flushEvents;
};

struct DeliveredEvent {
  std::string type;
  const EventTarget* target;
  double value;
  int pointerId;
  bool isPrimary;
};

enum class PointerPayloadKind { Typed, Dynamic };

} // namespace

class EventQueueTest : public testing::TestWithParam<PointerPayloadKind> {
 protected:
  void SetUp() override {
    runtime_ = facebook::hermes::makeHermesRuntime();
    scheduler_ = std::make_unique<RuntimeScheduler>(
        [this](auto&& callback) { callback(*runtime_); });
    auto processor = EventQueueProcessor(
        [this](
            jsi::Runtime& runtime,
            const EventTarget* target,
            const std::string& type,
            ReactEventPriority,
            const EventPayload& payload,
            HighResTimeStamp) {
          auto value = payload.asJSIValue(runtime);
          if (value.isObject() &&
              value.asObject(runtime).hasProperty(runtime, "pointerId")) {
            auto object = value.asObject(runtime);
            delivered_.push_back(
                {type,
                 target,
                 object.getProperty(runtime, "clientX").asNumber(),
                 static_cast<int>(
                     object.getProperty(runtime, "pointerId").asNumber()),
                 object.getProperty(runtime, "isPrimary").getBool()});
          } else {
            delivered_.push_back(
                {type,
                 target,
                 value.isObject() ? value.asObject(runtime)
                                        .getProperty(runtime, "value")
                                        .asNumber()
                                  : value.asNumber(),
                 -1,
                 false});
          }
        },
        [](jsi::Runtime&) {},
        [](const StateUpdate&) {},
        {});
    queue_ = std::make_unique<DrainableEventQueue>(
        std::move(processor),
        std::make_unique<EventBeat>(
            std::make_shared<EventBeat::OwnerBox>(), *scheduler_));
  }

  SharedEventPayload makePointer(int pointerId, Float x, bool isPrimary) {
    if (GetParam() == PointerPayloadKind::Dynamic) {
      return DynamicEventPayload::create(
          folly::dynamic::object("pointerId", static_cast<double>(pointerId))(
              "clientX", x)("isPrimary", isPrimary)(
              "hitPathForEventListener", folly::dynamic::array()));
    }
    auto pointer = std::make_shared<PointerEvent>();
    pointer->pointerId = pointerId;
    pointer->clientPoint = {x, 0};
    pointer->isPrimary = isPrimary;
    return pointer;
  }

  void enqueueMove(
      int pointerId,
      Float x,
      bool isPrimary,
      SharedEventTarget target) {
    queue_->enqueueUniqueEvent(RawEvent(
        "topPointerMove",
        makePointer(pointerId, x, isPrimary),
        std::move(target),
        {}));
  }

  SharedEventTarget makeTarget(Tag tag) {
    return std::make_shared<EventTarget>(
        std::make_shared<InstanceHandle>(
            *runtime_, jsi::Object(*runtime_), tag),
        41);
  }

  std::unique_ptr<facebook::hermes::HermesRuntime> runtime_;
  std::unique_ptr<RuntimeScheduler> scheduler_;
  std::unique_ptr<DrainableEventQueue> queue_;
  std::vector<DeliveredEvent> delivered_;
};

TEST_P(EventQueueTest, keepsMovesFromDifferentPointersOnOneTarget) {
  auto target = makeTarget(101);
  enqueueMove(2, 120, true, target);
  enqueueMove(3, 210, false, target);
  queue_->flushEvents(*runtime_);

  ASSERT_EQ(delivered_.size(), 2);
  EXPECT_EQ(delivered_[0].pointerId, 2);
  EXPECT_TRUE(delivered_[0].isPrimary);
  EXPECT_EQ(delivered_[0].value, 120);
  EXPECT_EQ(delivered_[1].pointerId, 3);
  EXPECT_FALSE(delivered_[1].isPrimary);
  EXPECT_EQ(delivered_[1].value, 210);
}

TEST_P(EventQueueTest, replacesConsecutiveMovesFromTheSamePointer) {
  auto target = makeTarget(101);
  enqueueMove(2, 120, true, target);
  enqueueMove(2, 210, true, target);
  queue_->flushEvents(*runtime_);

  ASSERT_EQ(delivered_.size(), 1);
  EXPECT_EQ(delivered_[0].pointerId, 2);
  EXPECT_TRUE(delivered_[0].isPrimary);
  EXPECT_EQ(delivered_[0].value, 210);
}

TEST_P(EventQueueTest, anotherPointerOnTheSameTargetIsAnOrderingBarrier) {
  auto target = makeTarget(101);
  enqueueMove(2, 120, true, target);
  enqueueMove(3, 150, false, target);
  enqueueMove(2, 210, true, target);
  queue_->flushEvents(*runtime_);

  ASSERT_EQ(delivered_.size(), 3);
  EXPECT_EQ(delivered_[0].pointerId, 2);
  EXPECT_EQ(delivered_[0].value, 120);
  EXPECT_EQ(delivered_[1].pointerId, 3);
  EXPECT_EQ(delivered_[1].value, 150);
  EXPECT_EQ(delivered_[2].pointerId, 2);
  EXPECT_EQ(delivered_[2].value, 210);
}

TEST_P(EventQueueTest, anotherEventTypeOnTheSameTargetIsAnOrderingBarrier) {
  auto target = makeTarget(101);
  enqueueMove(2, 120, true, target);
  auto terminal = makePointer(2, 150, true);
  queue_->enqueueEvent(RawEvent("topPointerUp", terminal, target, {}));
  enqueueMove(2, 210, true, target);
  queue_->flushEvents(*runtime_);

  ASSERT_EQ(delivered_.size(), 3);
  EXPECT_EQ(delivered_[0].type, "topPointerMove");
  EXPECT_EQ(delivered_[0].value, 120);
  EXPECT_EQ(delivered_[1].type, "topPointerUp");
  EXPECT_EQ(delivered_[1].value, 150);
  EXPECT_EQ(delivered_[2].type, "topPointerMove");
  EXPECT_EQ(delivered_[2].value, 210);
}

TEST_P(EventQueueTest, anotherTargetDoesNotPreventEligibleReplacement) {
  auto first = makeTarget(101);
  auto second = makeTarget(102);
  enqueueMove(2, 120, true, first);
  enqueueMove(3, 150, false, second);
  enqueueMove(2, 210, true, first);
  queue_->flushEvents(*runtime_);

  ASSERT_EQ(delivered_.size(), 2);
  EXPECT_EQ(delivered_[0].target, first.get());
  EXPECT_EQ(delivered_[0].pointerId, 2);
  EXPECT_EQ(delivered_[0].value, 210);
  EXPECT_EQ(delivered_[1].target, second.get());
  EXPECT_EQ(delivered_[1].pointerId, 3);
  EXPECT_EQ(delivered_[1].value, 150);
}

TEST_P(EventQueueTest, retainsNonPointerUniqueEventReplacement) {
  queue_->enqueueUniqueEvent(RawEvent(
      "topScroll",
      std::make_shared<ValueFactoryEventPayload>(
          [](jsi::Runtime&) { return jsi::Value(120); }),
      nullptr,
      {}));
  queue_->enqueueUniqueEvent(RawEvent(
      "topScroll",
      std::make_shared<ValueFactoryEventPayload>(
          [](jsi::Runtime&) { return jsi::Value(210); }),
      nullptr,
      {}));
  queue_->flushEvents(*runtime_);

  ASSERT_EQ(delivered_.size(), 1);
  EXPECT_EQ(delivered_[0].type, "topScroll");
  EXPECT_EQ(delivered_[0].value, 210);
}

TEST_P(EventQueueTest, keepsMovesWhenSecondaryPointerIsQueuedFirst) {
  auto target = makeTarget(101);
  enqueueMove(1, 210, false, target);
  enqueueMove(0, 120, true, target);
  queue_->flushEvents(*runtime_);

  ASSERT_EQ(delivered_.size(), 2);
  EXPECT_EQ(delivered_[0].pointerId, 1);
  EXPECT_FALSE(delivered_[0].isPrimary);
  EXPECT_EQ(delivered_[0].value, 210);
  EXPECT_EQ(delivered_[1].pointerId, 0);
  EXPECT_TRUE(delivered_[1].isPrimary);
  EXPECT_EQ(delivered_[1].value, 120);
}

TEST_P(EventQueueTest, nonUniqueMoveOnTheSameTargetIsAnOrderingBarrier) {
  auto target = makeTarget(101);
  enqueueMove(2, 120, true, target);
  queue_->enqueueEvent(
      RawEvent("topPointerMove", makePointer(2, 150, true), target, {}));
  enqueueMove(2, 210, true, target);
  queue_->flushEvents(*runtime_);

  ASSERT_EQ(delivered_.size(), 3);
  EXPECT_EQ(delivered_[0].value, 120);
  EXPECT_EQ(delivered_[1].value, 150);
  EXPECT_EQ(delivered_[2].value, 210);
}

TEST_P(EventQueueTest, anotherUniqueTypeOnTheSameTargetIsAnOrderingBarrier) {
  auto target = makeTarget(101);
  enqueueMove(2, 120, true, target);
  queue_->enqueueUniqueEvent(
      RawEvent("topPointerOver", makePointer(2, 150, true), target, {}));
  enqueueMove(2, 210, true, target);
  queue_->flushEvents(*runtime_);

  ASSERT_EQ(delivered_.size(), 3);
  EXPECT_EQ(delivered_[0].type, "topPointerMove");
  EXPECT_EQ(delivered_[0].value, 120);
  EXPECT_EQ(delivered_[1].type, "topPointerOver");
  EXPECT_EQ(delivered_[1].value, 150);
  EXPECT_EQ(delivered_[2].type, "topPointerMove");
  EXPECT_EQ(delivered_[2].value, 210);
}

TEST_P(EventQueueTest, pointerPhasesOnTheSameTargetAreOrderingBarriers) {
  auto target = makeTarget(101);
  for (const auto* phase : {"topPointerDown", "topPointerCancel"}) {
    delivered_.clear();
    enqueueMove(2, 120, true, target);
    queue_->enqueueEvent(
        RawEvent(phase, makePointer(2, 150, true), target, {}));
    enqueueMove(2, 210, true, target);
    queue_->flushEvents(*runtime_);

    ASSERT_EQ(delivered_.size(), 3);
    EXPECT_EQ(delivered_[0].type, "topPointerMove");
    EXPECT_EQ(delivered_[0].value, 120);
    EXPECT_EQ(delivered_[1].type, phase);
    EXPECT_EQ(delivered_[1].value, 150);
    EXPECT_EQ(delivered_[2].type, "topPointerMove");
    EXPECT_EQ(delivered_[2].value, 210);
  }
}

TEST_P(EventQueueTest, retainsDynamicNonPointerUniqueEventReplacement) {
  for (auto pointerId : {2, 3}) {
    queue_->enqueueUniqueEvent(RawEvent(
        "topCustom",
        DynamicEventPayload::create(
            folly::dynamic::object("clientX", pointerId * 10)(
                "pointerId", pointerId)("isPrimary", false)(
                "hitPathForEventListener", folly::dynamic::array())),
        nullptr,
        {}));
  }
  queue_->flushEvents(*runtime_);

  ASSERT_EQ(delivered_.size(), 1);
  EXPECT_EQ(delivered_[0].value, 30);
}

TEST_P(EventQueueTest, retainsDynamicPayloadWithoutPointerId) {
  for (auto value : {120, 210}) {
    queue_->enqueueUniqueEvent(RawEvent(
        "topScroll",
        DynamicEventPayload::create(folly::dynamic::object("value", value)),
        nullptr,
        {}));
  }
  queue_->flushEvents(*runtime_);

  ASSERT_EQ(delivered_.size(), 1);
  EXPECT_EQ(delivered_[0].value, 210);
}

INSTANTIATE_TEST_SUITE_P(
    PointerPayloads,
    EventQueueTest,
    testing::Values(PointerPayloadKind::Typed, PointerPayloadKind::Dynamic),
    [](const testing::TestParamInfo<PointerPayloadKind>& info) {
      return info.param == PointerPayloadKind::Typed ? "Typed" : "Dynamic";
    });

} // namespace facebook::react

Standalone reproduction

The failing reproducer is an RNTesterPlayground change with native pointer-dispatch opt-ins, separate from the production correction. The complete patch is below. It has no dependency on a gesture library.

  1. Check out RN main 38f6e192c8b0a04ddf93b0f21c394c24217f9692 and apply only the reproducer patch. Run yarn install and yarn --cwd packages/react-native-codegen build from the repository root. For a latest-stable comparison use v0.88.1; the Android application context differs slightly, so place the same ReactFeatureFlags.dispatchPointerEvents = true line before super.onCreate() there.
  2. Build RNTester from source. On iOS run bundle install, then RCT_USE_PREBUILT_RNCORE=0 bundle exec pod install in packages/rn-tester, and open its generated workspace. On main, a locked Hermes-version mismatch is resolved with the repository CI's bundle exec pod update hermes-engine --no-repo-update. On Android build :packages:rn-tester:android:app:assembleDebug with the desired architecture. A prebuilt RN core cannot exercise the C++ correction.
  3. Open Playground. First confirm a lone genuine finger produces down, move and up callbacks. Lift it and clear the counters.
  4. Hold two contacts on the same blue view. The second down pauses JS for 1.5 seconds. Move both during that pause, hold still, then lift both. Use genuine multi-touch simulator/emulator input; one mouse pointer does not supply this case.
  5. Press Show counts. Affected builds can erase one contact's queued moves. The fixed queue retains the two identities separately; it may still coalesce intermediate samples for the same pointer. Record actual callback counts and the native input schedule separately. Repeat with the other contact first.
  6. Apply the production correction to the RN source checkout, rebuild native code and repeat the same input. A JS reload alone does not apply this fix.

The recorder retains at most two contacts, records their real native callbacks, and clears move counters when the second down reaches JS. Callback totals depend on the actual native samples and timing. The fresh-run observations below state which trials have completed.

Complete failing RNTesterPlayground patch
diff --git a/packages/rn-tester/RNTester/AppDelegate.mm b/packages/rn-tester/RNTester/AppDelegate.mm
index 8f885bc966..cd7150ecb7 100644
--- a/packages/rn-tester/RNTester/AppDelegate.mm
+++ b/packages/rn-tester/RNTester/AppDelegate.mm
@@ -7,6 +7,8 @@
 
 #import "AppDelegate.h"
 
+#import <React/RCTConstants.h>
+
 #if !TARGET_OS_TV
 #import <React/RCTPushNotificationManager.h>
 #import <UserNotifications/UserNotifications.h>
@@ -21,6 +23,7 @@
 
 - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
 {
+  RCTSetDispatchW3CPointerEvents(YES);
 #if !TARGET_OS_TV
   [[UNUserNotificationCenter currentNotificationCenter] setDelegate:self];
 #endif
diff --git a/packages/rn-tester/android/app/src/main/java/com/facebook/react/uiapp/RNTesterApplication.kt b/packages/rn-tester/android/app/src/main/java/com/facebook/react/uiapp/RNTesterApplication.kt
index 7e8c9f7798..878a328f05 100644
--- a/packages/rn-tester/android/app/src/main/java/com/facebook/react/uiapp/RNTesterApplication.kt
+++ b/packages/rn-tester/android/app/src/main/java/com/facebook/react/uiapp/RNTesterApplication.kt
@@ -123,6 +123,7 @@ internal class RNTesterApplication : Application(), ReactApplication {
   override fun onCreate() {
     ReactFontManager.getInstance().addCustomFont(this, "Rubik", R.font.rubik)
     ReactFontManager.getInstance().addCustomFont(this, "FiraCode", R.font.firacode)
+    com.facebook.react.config.ReactFeatureFlags.dispatchPointerEvents = true
     super.onCreate()
     // Must run before loadReactNative so E2E-provided system properties are readable by the
     // feature flag overrides applied during React Native startup.
diff --git a/packages/rn-tester/js/examples/Playground/RNTesterPlayground.js b/packages/rn-tester/js/examples/Playground/RNTesterPlayground.js
index 8e6b10479d..cb85e0f8f7 100644
--- a/packages/rn-tester/js/examples/Playground/RNTesterPlayground.js
+++ b/packages/rn-tester/js/examples/Playground/RNTesterPlayground.js
@@ -9,17 +9,108 @@
  */
 
 import type {RNTesterModuleExample} from '../../types/RNTesterTypes';
+import type {PointerEvent} from 'react-native';
 
 import RNTesterText from '../../components/RNTesterText';
 import * as React from 'react';
-import {StyleSheet, View} from 'react-native';
+import {Button, StyleSheet, View} from 'react-native';
+
+type Contact = {
+  id: number,
+  primary: boolean,
+  down: number,
+  move: number,
+  up: number,
+  cancel: number,
+  lastX: number,
+};
+
+function Playground(): React.Node {
+  const contacts = React.useRef<Map<number, Contact>>(new Map());
+  const paused = React.useRef(false);
+  const [summary, setSummary] = React.useState('No contacts recorded.');
+
+  function down(event: PointerEvent) {
+    const {pointerId, isPrimary, clientX} = event.nativeEvent;
+    if (contacts.current.size >= 2) {
+      return;
+    }
+    contacts.current.set(pointerId, {
+      id: pointerId,
+      primary: isPrimary,
+      down: 1,
+      move: 0,
+      up: 0,
+      cancel: 0,
+      lastX: clientX,
+    });
+    if (contacts.current.size === 2 && !paused.current) {
+      paused.current = true;
+      for (const contact of contacts.current.values()) {
+        contact.move = 0;
+      }
+      console.log(
+        'Pointer moves: JS pause begins; move both contacts, then hold still.',
+      );
+      const deadline = Date.now() + 1500;
+      while (Date.now() < deadline) {
+        // Native pointer moves queue while JavaScript is busy.
+      }
+      console.log(
+        'Pointer moves: JS pause ends; lift both contacts and show counts.',
+      );
+    }
+  }
+
+  function record(phase: 'move' | 'up' | 'cancel', event: PointerEvent) {
+    const {pointerId, clientX} = event.nativeEvent;
+    const contact = contacts.current.get(pointerId);
+    if (contact != null) {
+      contact[phase] += 1;
+      contact.lastX = clientX;
+    }
+  }
 
-function Playground() {
   return (
     <View style={styles.container}>
       <RNTesterText>
-        Edit "RNTesterPlayground.js" to change this file
+        Hold two fingers on this view. The second down pauses JS for 1.5
+        seconds. Move both fingers during the pause, hold still, then lift both
+        fingers.
       </RNTesterText>
+      <View
+        collapsable={false}
+        testID="pointer-target"
+        onPointerDown={down}
+        onPointerMove={event => record('move', event)}
+        onPointerUp={event => record('up', event)}
+        onPointerCancel={event => record('cancel', event)}
+        style={styles.target}
+      />
+      <Button
+        title="Show counts"
+        onPress={() => {
+          const values = [...contacts.current.values()];
+          console.log(`Pointer moves: ${JSON.stringify(values)}`);
+          setSummary(
+            values
+              .map(
+                contact =>
+                  `id=${contact.id} primary=${contact.primary}\ndown=${contact.down} move=${contact.move} up=${contact.up} cancel=${contact.cancel} x=${contact.lastX.toFixed(1)}`,
+              )
+              .join('\n'),
+          );
+        }}
+      />
+      <Button
+        title="Clear after lifting both fingers"
+        onPress={() => {
+          contacts.current.clear();
+          paused.current = false;
+          setSummary('No contacts recorded.');
+        }}
+      />
+      <RNTesterText testID="pointer-results">{summary}</RNTesterText>
     </View>
   );
 }
@@ -28,11 +119,17 @@ const styles = StyleSheet.create({
   container: {
     padding: 10,
   },
+  target: {
+    height: 300,
+    marginVertical: 16,
+    backgroundColor: 'skyblue',
+  },
 });
 
 export default {
   title: 'Playground',
   name: 'playground',
-  description: 'Test out new features and ideas.',
+  description:
+    'Pointer moves from two contacts on the same view under JS load.',
   render: (): React.Node => <Playground />,
 } as RNTesterModuleExample;

Changelog:

[GENERAL] [FIXED] - Preserve moves from different pointers on the same Fabric event target on iOS and Android.

Test Plan:

Fresh evidence on 2026-11-04: source-built RNTester v0.88.1 (c30b7f86850d47422637a64c70508e7411dbd8ea), iOS 27.2 arm64 simulator and Android API 35 arm64 emulator. iOS uses RCT_USE_PREBUILT_RNCORE=0; Android builds ReactAndroid from source. Each native arm waits for at least three free cores by the host's admission guard. This is functional evidence, not a performance measurement.

Platform / native source build Observed recorder fields
iOS unpatched lone genuine AXe Indigo drag primary=true; down=1, move=40, up=1, cancel=0
Android unpatched lone adb swipe primary=true; down=1, move=12, up=1, cancel=0
iOS unpatched two genuine AXe Indigo contacts primary: down=1, move=0, up=1, cancel=0; secondary: down=1, move=40, up=1, cancel=0
Android unpatched two kernel-input contacts via private adb server primary: down=1, move=0, up=1, cancel=0; secondary: down=1, move=20, up=1, cancel=0
iOS fixed lone control primary=true; down=1, move=40, up=1, cancel=0
iOS fixed paired input primary: down=1, move=40, up=1, cancel=0; secondary: down=1, move=40, up=1, cancel=0
Android fixed lone control primary=true; down=1, move=22, up=1, cancel=0
Android fixed paired input primary: down=1, move=8, up=1, cancel=0; secondary: down=1, move=8, up=1, cancel=0

The recorder initializes down=1 when first seeing an identity; that field does not independently exclude duplicate down callbacks. Move, up and cancel fields count the callbacks received. The iOS paired driver requests 40 movement steps per contact; Android writes 20 paired movement frames and records the kernel event stream. Those schedules are distinct from JS callback counts. The Android lone swipe API does not establish native ingress counts. Both unpatched paired trials reproduce the loss of one contact's moves while both complete down/up lifecycles. Corrected trials used the same paired input schedules and retained movement for both identities. Same-pointer coalescing is still permitted, so callback totals may differ between runs.

Native GoogleTest target investigation on pinned main (2026-11-05 UTC): no exported OSS runner for the ReactCommon core GoogleTests was found at 38f6e192c8b0a04ddf93b0f21c394c24217f9692. The complete tracked file namespace contains no Buck/TARGETS manifests. Searches of every tracked CMake entry, Podspec, Xcode project, and exported GitHub workflow/action found no core GoogleTest registration. The adjacent known-present EventQueueProcessorTest.cpp was inspected, so the absent registration is not inferred from a failed filename lookup.

OSS CI builds RNTester and Fantom, and runs Fantom’s Jest integration suite. The exported iOS test script uses RNTester/XCTest; the shared test-ios-rntester action defaults run-unit-tests to false. Test All builds Fantom using yarn workspace @react-native/fantom build and runs it with yarn fantom; this does not run these GoogleTests. The core CMake target globs production *.cpp, not tests/*.cpp. Fantom's executable globs only its tester source directories and has no GoogleTest linkage. Swift Package Manager explicitly excludes core/tests.

The discovery commands, run before dependency installation, included:

git ls-files
rg -n -i 'gtest|googletest|EventQueueProcessorTest|enable_testing|add_test\(' \
  --glob 'CMakeLists.txt' --glob '*.cmake' --glob '*.podspec' \
  --glob '*.pbxproj' --glob '*.bzl' --glob 'BUCK*' --glob 'TARGETS*' .
rg -n -i 'gtest|googletest|EventQueueProcessorTest|ctest|cxx.*test|cpp.*test|buck2?\b' .github

The manifest-registration search returned no matches. The CI search found only unrelated strict-C++-API configuration and an XCTest comment; the real build actions and CMake entries were then read directly.

No runner was invented and no standalone substitute harness was used. Maintainer test step: in the internal ReactCommon test environment, register the supplied EventQueueTest.cpp alongside EventQueueProcessorTest.cpp, then run the internal target with --gtest_filter=PointerPayloads/EventQueueTest.*, first without the queue correction and then with it. The internal target name and invocation are not available in the exported tree and are deliberately unspecified. Fixture compilation, discovery, and red/green assertions remain unverified; this draft does not claim those results.

Not checked: GoogleTest discovery/compilation/execution, more than two contacts, concurrent producers, extraction performance, or a fixed release. This remains an unfinished draft.

A separate Android guest run passed entry, lone input, clearing, paired input, exchanged contact positions, and return navigation. Paired move counts were 19/19, 20/20 with positions exchanged, and 12/12 after return; all contacts had down=1/up=1/cancel=0. Screenshot review approved the supplied light-mode portrait states. Other themes, larger text and other widths were not reviewed.

Reproduction environment

Captured by running npx @react-native-community/cli info from the v0.88.1 RNTester checkout. Tool paths are redacted; all version/configuration fields are preserved. The CLI reports several source-workspace packages as Not Found and architecture booleans as false. Source identity and the actual build are recorded separately; these scanner fields are not runtime evidence. The global CocoaPods binary reported by the CLI differs from the repository-bundled CocoaPods 1.14.2 used for the source build.

System:
  OS: macOS 27.2
  CPU: (16) arm64 Apple M3 Max
  Memory: 27.85 GB / 128.00 GB
  Shell:
    version: 5.9.2
    path: [redacted]
Binaries:
  Node:
    version: 26.12.1
    path: [redacted]
  Yarn:
    version: 1.22.22
    path: [redacted]
  npm:
    version: 11.20.0
    path: [redacted]
  Watchman:
    version: 2026.07.27.00
    path: [redacted]
Managers:
  CocoaPods:
    version: 1.17.0
    path: [redacted]
SDKs:
  iOS SDK:
    Platforms:
      - DriverKit 27.2
      - iOS 27.2
      - macOS 27.2
      - tvOS 27.2
      - visionOS 27.2
      - watchOS 27.2
  Android SDK:
    Android NDK: 27.1.12297006
IDEs:
  Android Studio: 2025.3 AI-253.29346.138.2531.14876573
  Xcode:
    version: 27.2/27B52
    path: [redacted]
Languages:
  Java:
    version: 17.0.20
    path: [redacted]
  Ruby:
    version: 3.2.2
    path: [redacted]
npmPackages:
  "@react-native-community/cli": Not Found
  react: Not Found
  react-native: Not Found
  react-native-macos: Not Found
npmGlobalPackages:
  "*react-native*": Not Found
Android:
  hermesEnabled: false
  newArchEnabled: false
iOS:
  hermesEnabled: true
  newArchEnabled: true

@meta-cla

meta-cla Bot commented Nov 10, 2026

Copy link
Copy Markdown

Hi @alleneubank!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 10, 2026
@meta-cla

meta-cla Bot commented Nov 10, 2026

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fabric pointermove coalescing drops a different pointer on the same target

1 participant