diff --git a/.eslintrc.js b/.eslintrc.js index 813c99dbece6..128d872d4647 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -463,6 +463,7 @@ module.exports = { globals: { nativeFabricUIManager: 'readonly', RN$enableMicrotasksInReact: 'readonly', + RN$isNativeEventTargetEventDispatchingEnabled: 'readonly', }, }, { diff --git a/packages/react-native-renderer/src/LegacySyntheticEvent.js b/packages/react-native-renderer/src/LegacySyntheticEvent.js new file mode 100644 index 000000000000..3ddfe0d98c5d --- /dev/null +++ b/packages/react-native-renderer/src/LegacySyntheticEvent.js @@ -0,0 +1,69 @@ +/** + * 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 + */ + +/* globals Event$Init */ + +/** + * A bridge event class that extends the W3C Event interface and carries + * the native event payload. This is used as a compatibility layer during + * the migration from the legacy SyntheticEvent system to EventTarget-based + * dispatching. + */ +export default class LegacySyntheticEvent extends Event { + _nativeEvent: {[string]: mixed}; + _propagationStopped: boolean; + + constructor( + type: string, + options: Event$Init, + nativeEvent: {[string]: mixed}, + ) { + super(type, options); + this._nativeEvent = nativeEvent; + this._propagationStopped = false; + } + + get nativeEvent(): {[string]: mixed} { + return this._nativeEvent; + } + + stopPropagation(): void { + super.stopPropagation(); + this._propagationStopped = true; + } + + stopImmediatePropagation(): void { + super.stopImmediatePropagation(); + this._propagationStopped = true; + } + + /** + * No-op for backward compatibility. The legacy SyntheticEvent system + * used pooling which required calling persist() to keep the event. + * With EventTarget-based dispatching, events are never pooled. + */ + persist(): void { + // No-op + } + + /** + * Backward-compatible wrapper for `defaultPrevented`. + */ + isDefaultPrevented(): boolean { + return this.defaultPrevented; + } + + /** + * Backward-compatible wrapper. Returns true if stopPropagation() + * has been called. + */ + isPropagationStopped(): boolean { + return this._propagationStopped; + } +} diff --git a/packages/react-native-renderer/src/ReactFabricEventEmitter.js b/packages/react-native-renderer/src/ReactFabricEventEmitter.js index a0b8eb98b486..5496157e6893 100644 --- a/packages/react-native-renderer/src/ReactFabricEventEmitter.js +++ b/packages/react-native-renderer/src/ReactFabricEventEmitter.js @@ -28,11 +28,23 @@ import accumulateInto from './legacy-events/accumulateInto'; import getListener from './ReactNativeGetListener'; import {runEventsInBatch} from './legacy-events/EventBatching'; -import {RawEventEmitter} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface'; +import { + RawEventEmitter, + ReactNativeViewConfigRegistry, + dispatchTrustedEvent, + setEventInitTimeStamp, +} 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. @@ -47,10 +59,12 @@ function extractPluginEvents( nativeEventTarget: null | EventTarget, ): Array | ReactSyntheticEvent | null { let events: Array | ReactSyntheticEvent | null = null; - const legacyPlugins = ((plugins: any): Array>); + const legacyPlugins = ((plugins: any): Array< + LegacyPluginModule, + >); for (let i = 0; i < legacyPlugins.length; i++) { // Not every plugin in the ordering may be loaded at runtime. - const possiblePlugin: LegacyPluginModule = legacyPlugins[i]; + const possiblePlugin = legacyPlugins[i]; if (possiblePlugin) { const extractedEvents = possiblePlugin.extractEvents( topLevelType, @@ -84,8 +98,12 @@ function runExtractedPluginEventsInBatch( export function dispatchEvent( target: null | Object, topLevelType: RNTopLevelEventType, - nativeEvent: AnyNativeEvent, + nativeEventParam: mixed, ) { + const nativeEvent: AnyNativeEvent = + nativeEventParam != null && typeof nativeEventParam === 'object' + ? (nativeEventParam: any) + : {}; const targetFiber = (target: null | Fiber); let eventTarget = null; @@ -121,18 +139,52 @@ export function dispatchEvent( // Note that extracted events are *not* emitted, // only events that have a 1:1 mapping with a native event, at least for now. const event = {eventName: topLevelType, nativeEvent}; - // $FlowFixMe[class-object-subtyping] found when upgrading Flow RawEventEmitter.emit(topLevelType, event); - // $FlowFixMe[class-object-subtyping] found when upgrading Flow RawEventEmitter.emit('*', event); - // Heritage plugin event system - runExtractedPluginEventsInBatch( - topLevelType, - targetFiber, - nativeEvent, - eventTarget, - ); + 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); + } + } + } else { + // Heritage plugin event system + runExtractedPluginEventsInBatch( + topLevelType, + targetFiber, + nativeEvent, + eventTarget, + ); + } }); // React Native doesn't use ReactControlledComponent but if it did, here's // where it would do it. diff --git a/packages/react-native-renderer/src/ReactNativeEventEmitter.js b/packages/react-native-renderer/src/ReactNativeEventEmitter.js index 270708036748..ebac054de938 100644 --- a/packages/react-native-renderer/src/ReactNativeEventEmitter.js +++ b/packages/react-native-renderer/src/ReactNativeEventEmitter.js @@ -131,10 +131,12 @@ function extractPluginEvents( nativeEventTarget: null | EventTarget, ): Array | ReactSyntheticEvent | null { let events: Array | ReactSyntheticEvent | null = null; - const legacyPlugins = ((plugins: any): Array>); + const legacyPlugins = ((plugins: any): Array< + LegacyPluginModule, + >); for (let i = 0; i < legacyPlugins.length; i++) { // Not every plugin in the ordering may be loaded at runtime. - const possiblePlugin: LegacyPluginModule = legacyPlugins[i]; + const possiblePlugin = legacyPlugins[i]; if (possiblePlugin) { const extractedEvents = possiblePlugin.extractEvents( topLevelType, diff --git a/packages/react-native-renderer/src/ReactNativeEventTypeMapping.js b/packages/react-native-renderer/src/ReactNativeEventTypeMapping.js new file mode 100644 index 000000000000..4bb5dbab241f --- /dev/null +++ b/packages/react-native-renderer/src/ReactNativeEventTypeMapping.js @@ -0,0 +1,24 @@ +/** + * 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/ReactNativeFeatureFlags.js b/packages/react-native-renderer/src/ReactNativeFeatureFlags.js new file mode 100644 index 000000000000..a9aa9cb6fec7 --- /dev/null +++ b/packages/react-native-renderer/src/ReactNativeFeatureFlags.js @@ -0,0 +1,23 @@ +/** + * 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 + */ + +// These globals are set by React Native (e.g. in setUpDOM.js, setUpTimers.js) +// and provide access to RN's feature flags. We use global functions because we +// don't have another mechanism to pass feature flags from RN to React in OSS. +// Values are lazily evaluated and cached on first access. + +let _enableNativeEventTargetEventDispatching: boolean | null = null; +export function enableNativeEventTargetEventDispatching(): boolean { + if (_enableNativeEventTargetEventDispatching == null) { + _enableNativeEventTargetEventDispatching = + typeof RN$isNativeEventTargetEventDispatchingEnabled === 'function' && + RN$isNativeEventTargetEventDispatchingEnabled(); + } + return _enableNativeEventTargetEventDispatching; +} diff --git a/packages/react-native-renderer/src/ReactNativeResponder.js b/packages/react-native-renderer/src/ReactNativeResponder.js new file mode 100644 index 000000000000..78816e49121b --- /dev/null +++ b/packages/react-native-renderer/src/ReactNativeResponder.js @@ -0,0 +1,572 @@ +/** + * 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/legacy-events/PluginModuleType.js b/packages/react-native-renderer/src/legacy-events/PluginModuleType.js index e46fe52f6723..bafa0c997c9c 100644 --- a/packages/react-native-renderer/src/legacy-events/PluginModuleType.js +++ b/packages/react-native-renderer/src/legacy-events/PluginModuleType.js @@ -16,7 +16,9 @@ import type {TopLevelType} from './TopLevelEventTypes'; export type EventTypes = {[key: string]: DispatchConfig}; -export type AnyNativeEvent = Event | KeyboardEvent | MouseEvent | TouchEvent; +// Native events from C++ are plain objects with arbitrary properties, +// not DOM Event class instances. +export type AnyNativeEvent = {[string]: mixed}; export type PluginName = string; diff --git a/scripts/flow/react-native-host-hooks.js b/scripts/flow/react-native-host-hooks.js index 3e924f1c7f1b..d38b93cb8fa2 100644 --- a/scripts/flow/react-native-host-hooks.js +++ b/scripts/flow/react-native-host-hooks.js @@ -204,6 +204,14 @@ declare module 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface' declare export function getInternalInstanceHandleFromPublicInstance( publicInstance: PublicInstance, ): ?Object; + declare export function dispatchTrustedEvent( + target: EventTarget, + event: Event, + ): void; + declare export function setEventInitTimeStamp( + eventInit: {[string]: mixed}, + timeStamp: number, + ): void; declare export function createAttributePayload( props: Object, validAttributes: __AttributeConfiguration, @@ -228,6 +236,11 @@ declare module 'react-native' { // eslint-disable-next-line no-unused-vars declare const RN$enableMicrotasksInReact: boolean; +// eslint-disable-next-line no-unused-vars +declare const RN$isNativeEventTargetEventDispatchingEnabled: + | (() => boolean) + | void; + // This is needed for a short term solution. // See https://github.com/facebook/react/pull/15490 for more info // eslint-disable-next-line no-unused-vars diff --git a/scripts/rollup/validate/eslintrc.rn.js b/scripts/rollup/validate/eslintrc.rn.js index 2ee825dd7741..3cd93518db90 100644 --- a/scripts/rollup/validate/eslintrc.rn.js +++ b/scripts/rollup/validate/eslintrc.rn.js @@ -48,6 +48,7 @@ module.exports = { nativeFabricUIManager: 'readonly', // RN flag to enable microtasks RN$enableMicrotasksInReact: 'readonly', + RN$isNativeEventTargetEventDispatchingEnabled: 'readonly', // Trusted Types trustedTypes: 'readonly', // RN supports this