Repository navigation
Clear primary-touch ownership after resetting the iOS pointer handler - #59920
alleneubank wants to merge 1 commit into
Conversation
|
Hi @alleneubank! Thank you for your pull request and welcome to our community. Action RequiredIn 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. ProcessIn 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 If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks! |
|
Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks! |
Summary:
Fixes #59919
Status: the proposed RNTester XCTest was observed failing on the original handler and passing with the fix (iOS simulator, source-built RNTester at main 38f6e19); what was not checked is listed below.
On iOS Fabric, resetting an active
RCTSurfacePointerHandlercancels its current contacts and clears their registry, but retains_primaryTouchPointerId. A subsequent lone touch can therefore be emitted withisPrimary=falseeven though no other touch remains.Expected: cancellation preserves the old contact's identity and primary bit, then retires primary ownership. A later lone touch starts with
isPrimary=true.Actual: the old down/cancel are primary, but the next lone down, moves, and up can all be non-primary. This report concerns a deliberately induced active reset; it does not establish how often an unmodified app naturally reaches that state.
Source and current status
All root-cause links below are pinned to React Native v0.86.0, commit
a632f9efe24bac8b3a113c78469948f55bde0f5d:_primaryTouchPointerId == -1.This handler is unchanged in main at 38f6e192c8b0a04ddf93b0f21c394c24217f9692 and latest stable v0.88.1. The fresh source-build and runtime observations are reported below. Bounded public issue/PR searches on 2026-11-04 found no matching report; that is not an exhaustive duplicate check.
The correction is iOS-only. Android's JSPointerDispatcher establishes primary ownership on each ordinary ACTION_DOWN before constructing event state. That source difference does not qualify Android cancellation or recognizer takeover.
Proposed fix against main
Set the existing
-1sentinel after cancellation and registry retirement, outside the nonempty-registry branch. The old cancellation payloads retain their primary bit. Empty or repeated reset also clears a stale owner. No public declaration or mouse/pen routing changes.Patch base: RN main commit
38f6e192c8b0a04ddf93b0f21c394c24217f9692. The production change and complete test proposal are shown below; the patch contains both.Regression test proposal
The complete proposed XCTest uses the real RCTSurfacePointerHandler, TouchEventEmitter, and EventDispatcher. Its assertion observes the emitted down/cancel/down identity and primary bits. The fake UITouch and dispatch listener limit it to handler behavior before queueing. It follows the XCTest/Objective-C++ conventions of RCTViewComponentViewTests.mm. Test destination:
packages/react-native/React/Tests/TouchHandling/RCTSurfacePointerHandlerTests.mm. The hosted-target integration through a focused plan in RNTester’s existing scheme below is part of the proposal; target discovery and execution are reported separately.RCTSurfacePointerHandlerTests.mm
Dedicated app-hosted RNTester test-target integration
Standalone reproduction
The failing reproducer is an iOS RNTesterPlayground change plus one RNTester-native reset button rendered inside Playground. The complete patch is below. JavaScript alone has no API for resetting this native handler.
38f6e192c8b0a04ddf93b0f21c394c24217f9692and apply only this reproducer patch. Its AppDelegate enables pointer dispatch before React starts. Use v0.88.1 for the latest-stable comparison.yarn installandyarn --cwd packages/react-native-codegen build. Install the repository's bundled CocoaPods dependencies. Inpackages/rn-tester, useRCT_USE_PREBUILT_RNCORE=0 bundle exec pod installand build RNTester from the generated workspace. Run yarn start to serve RNTester over Metro. On main, resolve the checked-in Hermes-version mismatch withbundle exec pod update hermes-engine --no-repo-update, as RN's CI does.The reset is deliberate fault injection in the test app. The button uses RNTester's native view-manager mechanism and is mounted only as part of Playground; it is absent from neighboring screens. The experiment does not establish natural production reset frequency. The monitor retains at most 16 phase groups, folding consecutive moves with the same identity and primary bit into a count. The fresh-run observations below state whether this exact input sequence has been executed.
Complete failing RNTesterPlayground and native reset-button patch
Changelog:
[IOS] [FIXED] - Clear primary-touch ownership after resetting the Fabric pointer handler so a subsequent lone touch is primary.
Test Plan:
Fresh runtime evidence on 2026-11-05 UTC: source-built RNTester v0.88.1 (
c30b7f86850d47422637a64c70508e7411dbd8ea), iOS 27.2 arm64 simulator, withRCT_USE_PREBUILT_RNCORE=0. The failing Playground/native-view patch is present in both variants; only the production handler correction differs. Genuine AXe Indigo input holds the blue contact, presses the native reset button with a second finger, releases both, then starts a new lone drag. Each native arm waits for the unchanged three-free-core admission guard. This is functional evidence, not a performance measurement.The native control reports Reset performed. The old monitored down/cancel and complete new lifecycle qualify the trial; button status alone does not establish that the blue contact was active. There is no JS-synthesized pointer event. This deliberate native fault injection does not establish natural production reset frequency. The reset control is part of Playground's native view layout and is absent on Components; the author drove leaving and returning. Both native variants build; the current JavaScript passes RN ESLint with zero warnings, Prettier, and a real stable iOS Metro bundle. Native source passes the pinned clang-format check.
Native XCTest on pinned main (2026-11-05 UTC): the supplied
RCTSurfacePointerHandlerTests/testLoneTouchAfterActiveResetIsPrimarynow executes through Xcode's standardRNTesterscheme, using a focusedRNTesterPointerTeststest plan and the realRNTester.apphost. The focused plan preserves the existing default test plan and itsRNTesterUnitTeststarget. Source base:38f6e192c8b0a04ddf93b0f21c394c24217f9692; destination: a newly created, Xcode-visible iPhone 17 Pro / iOS 27.2 arm64 simulator in the default device set. React Native was built from source withRCT_USE_PREBUILT_RNCORE=0. The plan retains Address Sanitizer and Undefined Behavior Sanitizer.build-for-testingtest-without-buildingXCTAssertTrue(receipts[2].isPrimary)(line 125); no other assertion failedThe fixture's SHA-256 is identical in both arms. Only
_primaryTouchPointerId = -1at the end ofresetdiffers in production code. There is no repetition configured in the focused plan, and each result bundle reports exactly one test. Each native arm passed the three-free-core admission guard; builds used at most three jobs, with one native build at a time.Commands below were run from the source checkout root.
SIMULATOR_UDIDnames the owned default-set simulator;DERIVED_DATAandRESULT_BUNDLEstand for its lane-owned output paths (a distinct result bundle for each arm). A lane-owned Metro server served this checkout on the initially free port 8081, started frompackages/rn-testerwithnode ../react-native/cli.js start --port 8081 --no-interactive --max-workers 1.The first attempt added the regression to the default test plan, which compiled and linked the supplied fixture but failed while compiling unchanged
RNTesterUnitTests/RCTImageLoaderTests.mat undefinedRCTDefineImageURLLoader/RCTDefineImageDecoderinvocations. Explicit test skipping still left that target in Xcode's build graph. The focused plan selects this regression without modifying that source or any existing assertion. A clean-base whole-suite verdict is not claimed. Xcode also rejected an initial-test-iterations 1command before executing tests; the actual commands above omit that option. The earlier private-device-set/vendor-launcher block is resolved by this lane's standard Xcode execution on a default-set device.The XCTest uses a fake UITouch and observes real handler dispatch before queueing; the separate Playground/HID experiment above supplies genuine input and JS delivery evidence.
Not checked: the full RNTester unit suite, empty reset or consecutive resets without a new active blue contact, ordinary external cancellation, more than two contacts, mouse/pen, physical devices, other widths/larger text/dark mode, natural production reset frequency, performance, or a fixed release. Android is outside this iOS-only reset correction. This is a local draft pending maintainer review and publication.
A separate iOS guest run passed ordinary input, clearing, three active-reset/recovery trials and leaving/returning. Each reset showed old primary down=1/cancel=1 and a new primary down=1/move=40/up=1. The reset control was absent from Components and reappeared in Playground. Fresh screenshot review approved the current light-mode portrait layout; other themes, larger text and other widths remain unreviewed.
Reproduction environment
Captured by running
npx @react-native-community/cli infofrom 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.