From 0418c8a8b61a2328fd8e9105a6f49093ee91b436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 14 Apr 2026 18:10:33 +0100 Subject: [PATCH] [RN] Move new event dispatching pipeline to RN (#36266) ## Summary We found a bug in the logic in https://github.com/facebook/react/pull/36253 and we realized it's very inconvenient to iterate on the implementation when it's in this repository, as we're forced to then synchronize it to RN to test changes. This moves the entire implementation to RN for simplicity and also to simplify some clean ups in the future (like removing `top` prefixes from native event types). ## How did you test this change? The changes are gated. Will test e2e in RN. --- .../src/ReactFabricEventEmitter.js | 42 +- .../src/ReactNativeEventTypeMapping.js | 24 - .../src/ReactNativeResponder.js | 572 ------------------ .../ReactNativePrivateInterface.js | 1 + scripts/flow/react-native-host-hooks.js | 9 +- 5 files changed, 6 insertions(+), 642 deletions(-) delete mode 100644 packages/react-native-renderer/src/ReactNativeEventTypeMapping.js delete mode 100644 packages/react-native-renderer/src/ReactNativeResponder.js diff --git a/packages/react-native-renderer/src/ReactFabricEventEmitter.js b/packages/react-native-renderer/src/ReactFabricEventEmitter.js index 5496157e6893..c7f6aebd9028 100644 --- a/packages/react-native-renderer/src/ReactFabricEventEmitter.js +++ b/packages/react-native-renderer/src/ReactFabricEventEmitter.js @@ -30,21 +30,13 @@ import {runEventsInBatch} from './legacy-events/EventBatching'; import { RawEventEmitter, - ReactNativeViewConfigRegistry, - dispatchTrustedEvent, - setEventInitTimeStamp, + dispatchNativeEvent, } from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface'; import {getPublicInstance} from './ReactFiberConfigFabric'; -import LegacySyntheticEvent from './LegacySyntheticEvent'; -import {topLevelTypeToEventName} from './ReactNativeEventTypeMapping'; -import {processResponderEvent} from './ReactNativeResponder'; import {enableNativeEventTargetEventDispatching} from './ReactNativeFeatureFlags'; export {getListener, registrationNameModules as registrationNames}; -const {customBubblingEventTypes, customDirectEventTypes} = - ReactNativeViewConfigRegistry; - /** * Allows registered plugins an opportunity to extract events from top-level * native browser events. @@ -143,38 +135,8 @@ export function dispatchEvent( RawEventEmitter.emit('*', event); if (enableNativeEventTargetEventDispatching()) { - // Process responder events before normal event dispatch. - // This handles touch negotiation (onStartShouldSetResponder, etc.) - processResponderEvent(topLevelType, targetFiber, nativeEvent); - - // New EventTarget-based dispatch path if (eventTarget != null) { - const bubbleDispatchConfig = customBubblingEventTypes[topLevelType]; - const directDispatchConfig = customDirectEventTypes[topLevelType]; - const bubbles = bubbleDispatchConfig != null; - - // Skip events that are not registered in the view config - if (bubbles || directDispatchConfig != null) { - const eventName = topLevelTypeToEventName(topLevelType); - const options = { - bubbles, - cancelable: true, - }; - // Preserve the native event timestamp for backwards compatibility. - // The legacy SyntheticEvent system used nativeEvent.timeStamp || nativeEvent.timestamp. - const nativeTimestamp = - nativeEvent.timeStamp ?? nativeEvent.timestamp; - if (typeof nativeTimestamp === 'number') { - setEventInitTimeStamp(options, nativeTimestamp); - } - const syntheticEvent = new LegacySyntheticEvent( - eventName, - options, - nativeEvent, - ); - // $FlowFixMe[incompatible-call] - dispatchTrustedEvent(eventTarget, syntheticEvent); - } + dispatchNativeEvent(eventTarget, topLevelType, nativeEvent); } } else { // Heritage plugin event system diff --git a/packages/react-native-renderer/src/ReactNativeEventTypeMapping.js b/packages/react-native-renderer/src/ReactNativeEventTypeMapping.js deleted file mode 100644 index 4bb5dbab241f..000000000000 --- a/packages/react-native-renderer/src/ReactNativeEventTypeMapping.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * 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. - * - * @flow - */ - -/** - * Converts a topLevelType (e.g., "topPress") to a DOM event name (e.g., "press"). - * Strips the "top" prefix and lowercases the result. - */ -export function topLevelTypeToEventName(topLevelType: string): string { - const fourthChar = topLevelType.charCodeAt(3); - if ( - topLevelType.startsWith('top') && - fourthChar >= 65 /* A */ && - fourthChar <= 90 /* Z */ - ) { - return topLevelType.slice(3).toLowerCase(); - } - return topLevelType; -} diff --git a/packages/react-native-renderer/src/ReactNativeResponder.js b/packages/react-native-renderer/src/ReactNativeResponder.js deleted file mode 100644 index 78816e49121b..000000000000 --- a/packages/react-native-renderer/src/ReactNativeResponder.js +++ /dev/null @@ -1,572 +0,0 @@ -/** - * 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. - * - * @flow - */ - -/** - * Responder System: - * ----------------- - * - * - A global, solitary "interaction lock" on a view. - * - If a node becomes the responder, it should convey visual feedback - * immediately to indicate so, either by highlighting or moving accordingly. - * - To be the responder means that touches are exclusively important to that - * responder view, and no other view. - * - While touches are still occurring, the responder lock can be transferred to - * a new view, but only to increasingly "higher" views (meaning ancestors of - * the current responder). - * - * Responder being granted: - * ------------------------ - * - * - Touch starts, moves, and scrolls can cause a view to become the responder. - * - We dispatch `startShouldSetResponder`/`moveShouldSetResponder` as bubbling - * EventTarget events to the "appropriate place". - * - If nothing is currently the responder, the "appropriate place" is the - * initiating event's target. - * - If something *is* already the responder, the "appropriate place" is the - * first common ancestor of the event target and the current responder. - * - Some negotiation happens: See the timing diagram below. - * - Scrolled views automatically become responder. The reasoning is that a - * platform scroll view that isn't built on top of the responder system has - * begun scrolling, and the active responder must now be notified that the - * interaction is no longer locked to it — the system has taken over. - * - * Responder being released: - * ------------------------- - * - * As soon as no more touches that *started* inside of descendants of the - * *current* responder remain active, an `onResponderRelease` event is - * dispatched to the current responder, and the responder lock is released. - * - * Direct dispatch (no EventTarget): - * ---------------------------------- - * - * Responder events bypass EventTarget entirely. Handlers are read directly - * from `canonical.currentProps` at dispatch time — no commit-time registration, - * no wrappers, no addEventListener. - * - * Negotiation walks the fiber tree manually (capture then bubble phase) using - * `getParent()`. The first handler returning `true` wins. - * - * Lifecycle events call the handler directly and inspect return values: - * - `onResponderGrant` returning `true` → block native responder - * - `onResponderTerminationRequest` returning `false` → refuse termination - * - * - * Negotiation Performed - * +-----------------------+ - * / \ - * Process low level events to + Current Responder + wantsResponder - * determine who to perform negot-| (if any exists at all) | - * iation/transition | Otherwise just pass through| - * -------------------------------+----------------------------+------------------+ - * Bubble to find first ID | | - * to return true:wantsResponder | | - * | | - * +-------------+ | | - * | onTouchStart| | | - * +------+------+ none | | - * | return| | - * +-----------v-------------+true| +------------------------+ | - * |onStartShouldSetResponder|----->|onResponderStart (cur) |<-----------+ - * +-----------+-------------+ | +------------------------+ | | - * | | | +--------+-------+ - * | returned true for| false:REJECT +-------->|onResponderReject - * | wantsResponder | | | +----------------+ - * | (now attempt | +------------------+-----+ | - * | handoff) | | onResponder | | - * +------------------->| TerminationRequest| | - * | +------------------+-----+ | - * | | | +----------------+ - * | true:GRANT +-------->|onResponderGrant| - * | | +--------+-------+ - * | +------------------------+ | | - * | | onResponderTerminate |<-----------+ - * | +------------------+-----+ | - * | | | +----------------+ - * | +-------->|onResponderStart| - * | | +----------------+ - * Bubble to find first ID | | - * to return true:wantsResponder | | - * | | - * +-------------+ | | - * | onTouchMove | | | - * +------+------+ none | | - * | return| | - * +-----------v-------------+true| +------------------------+ | - * |onMoveShouldSetResponder |----->|onResponderMove (cur) |<-----------+ - * +-----------+-------------+ | +------------------------+ | | - * | | | +--------+-------+ - * | returned true for| false:REJECT +-------->|onResponderReject - * | wantsResponder | | | +----------------+ - * | (now attempt | +------------------+-----+ | - * | handoff) | | onResponder | | - * +------------------->| TerminationRequest| | - * | +------------------+-----+ | - * | | | +----------------+ - * | true:GRANT +-------->|onResponderGrant| - * | | +--------+-------+ - * | +------------------------+ | | - * | | onResponderTerminate |<-----------+ - * | +------------------+-----+ | - * | | | +----------------+ - * | +-------->|onResponderMove | - * | | +----------------+ - * | | - * | | - * Some active touch started| | - * inside current responder | +------------------------+ | - * +------------------------->| onResponderEnd | | - * | | +------------------------+ | - * +---+---------+ | | - * | onTouchEnd | | | - * +---+---------+ | | - * | | +------------------------+ | - * +------------------------->| onResponderEnd | | - * No active touches started| +-----------+------------+ | - * inside current responder | | | - * | v | - * | +------------------------+ | - * | | onResponderRelease | | - * | +------------------------+ | - * | | - * + + - */ - -import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; - -import LegacySyntheticEvent from './LegacySyntheticEvent'; -import ResponderTouchHistoryStore from './legacy-events/ResponderTouchHistoryStore'; -import {HostComponent} from 'react-reconciler/src/ReactWorkTags'; -import {getInstanceFromNode} from './ReactFabricComponentTree'; - -// The currently active responder (tracked as a fiber) -let responderFiber: Fiber | null = null; - -/** - * Count of current touches. A textInput should become responder iff the - * selection changes while there is a touch on the screen. - */ -let trackedTouchCount = 0; - -function isStartish(topLevelType: string): boolean { - return topLevelType === 'topTouchStart'; -} - -function isMoveish(topLevelType: string): boolean { - return topLevelType === 'topTouchMove'; -} - -function isEndish(topLevelType: string): boolean { - return topLevelType === 'topTouchEnd' || topLevelType === 'topTouchCancel'; -} - -/** - * Walk up the fiber tree, skipping non-HostComponent fibers. - */ -function getParent(inst: Fiber): Fiber | null { - let fiber = inst.return; - while (fiber != null) { - if (fiber.tag === HostComponent) { - return fiber; - } - fiber = fiber.return; - } - return null; -} - -/** - * Return the lowest common ancestor of A and B, or null if they are in - * different trees. - */ -function getLowestCommonAncestor(instA: Fiber, instB: Fiber): Fiber | null { - let depthA = 0; - for (let tempA: Fiber | null = instA; tempA; tempA = getParent(tempA)) { - depthA++; - } - let depthB = 0; - for (let tempB: Fiber | null = instB; tempB; tempB = getParent(tempB)) { - depthB++; - } - - let a = instA; - let b = instB; - - // If A is deeper, crawl up. - while (depthA - depthB > 0) { - a = (getParent(a): any); - depthA--; - } - - // If B is deeper, crawl up. - while (depthB - depthA > 0) { - b = (getParent(b): any); - depthB--; - } - - // Walk in lockstep until we find a match. - let depth = depthA; - while (depth--) { - if (a === b || a === b.alternate) { - return a; - } - a = (getParent(a): any); - b = (getParent(b): any); - } - return null; -} - -/** - * Return true if A is an ancestor of B. - */ -function isAncestor(instA: Fiber, instB: Fiber | null): boolean { - let current = instB; - while (current != null) { - if (instA === current || instA === current.alternate) { - return true; - } - current = getParent(current); - } - return false; -} - -function changeResponder( - nextResponderFiber: Fiber | null, - blockNativeResponder: boolean, -): void { - const oldResponderFiber = responderFiber; - responderFiber = nextResponderFiber; - - // Notify the native side about responder changes so native gestures - // (e.g. ScrollView scroll) can defer to JS. - if (oldResponderFiber != null && oldResponderFiber.stateNode != null) { - nativeFabricUIManager.setIsJSResponder( - oldResponderFiber.stateNode.node, - false, - blockNativeResponder, - ); - } - if (nextResponderFiber != null && nextResponderFiber.stateNode != null) { - nativeFabricUIManager.setIsJSResponder( - nextResponderFiber.stateNode.node, - true, - blockNativeResponder, - ); - } -} - -/** - * Determine the negotiation event name for a given topLevelType. - */ -function getShouldSetEventName(topLevelType: string): string { - if (isStartish(topLevelType)) { - return 'startShouldSetResponder'; - } else if (isMoveish(topLevelType)) { - return 'moveShouldSetResponder'; - } else if (topLevelType === 'topSelectionChange') { - return 'selectionChangeShouldSetResponder'; - } else { - return 'scrollShouldSetResponder'; - } -} - -/** - * Run negotiation by walking the fiber tree directly. Performs capture phase - * (root→target) then bubble phase (target→root), calling handlers from - * `canonical.currentProps`. The first handler that returns `true` wins. - * - * The dispatch target is determined as follows: - * - If no responder exists, dispatch from the event target (full tree). - * - If a responder exists, dispatch from the lowest common ancestor (LCA) - * of the responder and the target — only ancestors can claim. - * - If the LCA is the current responder itself, skip it (don't re-negotiate - * with yourself) and dispatch from the parent. - * - * @return {Fiber | null} The fiber that claimed the responder, or null. - */ -function negotiateResponder( - targetFiber: Fiber, - topLevelType: string, - nativeEvent: {[string]: mixed}, -): Fiber | null { - const shouldSetEventName = getShouldSetEventName(topLevelType); - - // Determine the negotiation dispatch target - let negotiationFiber; - let skipSelf = false; - if (responderFiber == null) { - negotiationFiber = targetFiber; - } else { - negotiationFiber = getLowestCommonAncestor(responderFiber, targetFiber); - if (negotiationFiber == null) { - return null; - } - if (negotiationFiber === responderFiber) { - skipSelf = true; - } - } - - const dispatchFiber = skipSelf - ? getParent(negotiationFiber) - : negotiationFiber; - if (dispatchFiber == null) { - return null; - } - - // Build ancestor path (root to dispatch fiber) - const path: Array = []; - let fiber: Fiber | null = dispatchFiber; - while (fiber != null) { - path.unshift(fiber); - fiber = getParent(fiber); - } - - const event = new LegacySyntheticEvent( - shouldSetEventName, - {bubbles: true, cancelable: true}, - nativeEvent, - ); - // $FlowFixMe[prop-missing] touchHistory is a responder-specific extension not in the Event type - event.touchHistory = ResponderTouchHistoryStore.touchHistory; - - // Derive prop names from event name - const bubblePropName = - 'on' + - shouldSetEventName.charAt(0).toUpperCase() + - shouldSetEventName.slice(1); - const capturePropName = bubblePropName + 'Capture'; - - // Capture phase: root → target - for (let i = 0; i < path.length; i++) { - const stateNode = path[i].stateNode; - if (stateNode == null) { - continue; - } - const handler = stateNode.canonical.currentProps[capturePropName]; - if (typeof handler === 'function' && handler(event) === true) { - return path[i]; - } - } - - // Bubble phase: target → root - for (let i = path.length - 1; i >= 0; i--) { - const stateNode = path[i].stateNode; - if (stateNode == null) { - continue; - } - const handler = stateNode.canonical.currentProps[bubblePropName]; - if (typeof handler === 'function' && handler(event) === true) { - return path[i]; - } - } - - return null; -} - -/** - * Dispatch a lifecycle responder event by calling the handler directly from - * `canonical.currentProps`. Returns the handler's return value so callers can - * inspect it (e.g. `onResponderGrant` returning `true` to block native). - */ -function dispatchResponderEvent( - fiber: Fiber, - eventName: string, - nativeEvent: {[string]: mixed}, -): mixed { - const stateNode = fiber.stateNode; - if (stateNode == null) { - return undefined; - } - - const propName = - 'on' + eventName.charAt(0).toUpperCase() + eventName.slice(1); - const handler = stateNode.canonical.currentProps[propName]; - if (typeof handler !== 'function') { - return undefined; - } - - const event = new LegacySyntheticEvent( - eventName, - {bubbles: false, cancelable: true}, - nativeEvent, - ); - // $FlowFixMe[prop-missing] touchHistory is a responder-specific extension not in the Event type - event.touchHistory = ResponderTouchHistoryStore.touchHistory; - - return handler(event); -} - -/** - * A transfer is a negotiation between a currently set responder and the next - * element to claim responder status. Any start event could trigger a transfer - * of responderFiber. Any move event could trigger a transfer. - * - * @return {boolean} True if a transfer of responder could possibly occur. - */ -function canTriggerTransfer( - topLevelType: string, - targetFiber: Fiber | null, - nativeEvent: {[string]: mixed}, -): boolean { - return ( - targetFiber != null && - ((topLevelType === 'topScroll' && !nativeEvent.responderIgnoreScroll) || - (trackedTouchCount > 0 && topLevelType === 'topSelectionChange') || - isStartish(topLevelType) || - isMoveish(topLevelType)) - ); -} - -/** - * Returns whether or not this touch end event makes it such that there are no - * longer any touches that started inside of the current `responderFiber`. - * - * @param {NativeEvent} nativeEvent Native touch end event. - * @return {boolean} Whether or not this touch end event ends the responder. - */ -function noResponderTouches(nativeEvent: {[string]: mixed}): boolean { - const touches = (nativeEvent.touches: any); - if (!touches || touches.length === 0) { - return true; - } - for (let i = 0; i < touches.length; i++) { - const activeTouch = touches[i]; - const target = activeTouch.target; - if (target !== null && target !== undefined && target !== 0) { - // Is the original touch location inside of the current responder? - const targetInst = getInstanceFromNode(target); - if ( - responderFiber != null && - targetInst != null && - isAncestor(responderFiber, targetInst) - ) { - return false; - } - } - } - return true; -} - -/** - * Process a native event through the responder system. - * Called from ReactFabricEventEmitter when the flag is enabled. - */ -export function processResponderEvent( - topLevelType: string, - targetFiber: Fiber | null, - nativeEvent: {[string]: mixed}, -): void { - // Track touch count - if (isStartish(topLevelType)) { - trackedTouchCount += 1; - } else if (isEndish(topLevelType)) { - if (trackedTouchCount >= 0) { - trackedTouchCount -= 1; - } else { - if (__DEV__) { - console.warn( - 'Ended a touch event which was not counted in `trackedTouchCount`.', - ); - } - return; - } - } - - ResponderTouchHistoryStore.recordTouchTrack(topLevelType, (nativeEvent: any)); - - // Negotiation: determine if a new responder should be set - if ( - canTriggerTransfer(topLevelType, targetFiber, nativeEvent) && - targetFiber != null - ) { - const wantsResponderFiber = negotiateResponder( - targetFiber, - topLevelType, - nativeEvent, - ); - - if (wantsResponderFiber != null && wantsResponderFiber !== responderFiber) { - // A new view wants to become responder. - // onResponderGrant returning true means block native responder. - const grantResult = dispatchResponderEvent( - wantsResponderFiber, - 'responderGrant', - nativeEvent, - ); - const blockNativeResponder = grantResult === true; - - if (responderFiber != null) { - // Capture in a local to preserve Flow narrowing across function calls. - const currentResponder = responderFiber; - // Ask current responder if it will terminate. - // onResponderTerminationRequest returning false means refuse. - const terminationResult = dispatchResponderEvent( - currentResponder, - 'responderTerminationRequest', - nativeEvent, - ); - const shouldSwitch = terminationResult !== false; - - if (shouldSwitch) { - dispatchResponderEvent( - currentResponder, - 'responderTerminate', - nativeEvent, - ); - changeResponder(wantsResponderFiber, blockNativeResponder); - } else { - dispatchResponderEvent( - wantsResponderFiber, - 'responderReject', - nativeEvent, - ); - } - } else { - changeResponder(wantsResponderFiber, blockNativeResponder); - } - } - } - - // Responder may or may not have transferred on a new touch start/move. - // Regardless, whoever is the responder after any potential transfer, we - // direct all touch start/move/ends to them in the form of - // `onResponderMove/Start/End`. These will be called for *every* additional - // finger that move/start/end, dispatched directly to whoever is the - // current responder at that moment, until the responder is "released". - // - // These multiple individual change touch events are always bookended - // by `onResponderGrant`, and one of - // (`onResponderRelease/onResponderTerminate`). - if (responderFiber != null) { - // Capture in a local to preserve Flow narrowing across function calls. - const activeResponder = responderFiber; - if (isStartish(topLevelType)) { - dispatchResponderEvent(activeResponder, 'responderStart', nativeEvent); - } else if (isMoveish(topLevelType)) { - dispatchResponderEvent(activeResponder, 'responderMove', nativeEvent); - } else if (isEndish(topLevelType)) { - dispatchResponderEvent(activeResponder, 'responderEnd', nativeEvent); - - if (topLevelType === 'topTouchCancel') { - dispatchResponderEvent( - activeResponder, - 'responderTerminate', - nativeEvent, - ); - changeResponder(null, false); - } else if (noResponderTouches(nativeEvent)) { - dispatchResponderEvent( - activeResponder, - 'responderRelease', - nativeEvent, - ); - changeResponder(null, false); - } - } - } -} diff --git a/packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js b/packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js index 0d76206124ee..d8d1c240951e 100644 --- a/packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js +++ b/packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js @@ -65,4 +65,5 @@ module.exports = { get diffAttributePayloads() { return require('./diffAttributePayloads').default; }, + dispatchNativeEvent() {}, }; diff --git a/scripts/flow/react-native-host-hooks.js b/scripts/flow/react-native-host-hooks.js index d38b93cb8fa2..b09d3d42531e 100644 --- a/scripts/flow/react-native-host-hooks.js +++ b/scripts/flow/react-native-host-hooks.js @@ -204,13 +204,10 @@ declare module 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface' declare export function getInternalInstanceHandleFromPublicInstance( publicInstance: PublicInstance, ): ?Object; - declare export function dispatchTrustedEvent( + declare export function dispatchNativeEvent( target: EventTarget, - event: Event, - ): void; - declare export function setEventInitTimeStamp( - eventInit: {[string]: mixed}, - timeStamp: number, + type: string, + eventData: {[string]: mixed}, ): void; declare export function createAttributePayload( props: Object,