@samitouri / QOS-React / commits / 568244232e

[react-native-renderer] EventTarget-based event dispatching (#36253)

## Summary Set up the experiment to migrate event dispatching in the React Native renderer to be based on the native EventTarget API. Behind the `enableNativeEventTargetEventDispatching` flag, events are dispatched through `dispatchTrustedEvent` instead of the legacy plugin system. Regular event handler props are NOT registered via addEventListener at commit time. Instead, a hook on EventTarget (`EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY`) extracts handlers from `canonical.currentProps` at dispatch time, shifting cost from every render to only when events fire. The hook is overridden in ReactNativeElement to look up the prop name via a reverse mapping from event names (built lazily from the view config registry). Responder events bypass EventTarget entirely. `negotiateResponder` walks the fiber tree directly (capture then bubble phase), calling handlers from `canonical.currentProps` and checking return values inline. Lifecycle events (`responderGrant`, `responderMove`, etc.) call handlers directly from props and inspect return values — `onResponderGrant` returning `true` blocks native responder, `onResponderTerminationRequest` returning `false` refuses termination. This eliminates all commit-time cost for responder events (no wrappers, no addEventListener, no `responderWrappers` on canonical). ## How did you test this change? Flow Tested e2e in RN using Fantom tests (that will land after this).

Rubén Norte committed Apr 14, 2026 at 12:43 UTC 568244232e29a0d4524544344aa280917580e8f7
10 files changed +775 -16
.eslintrc.js
+1
@@ -463,6 +463,7 @@ module.exports = {
463 globals: {
464 nativeFabricUIManager: 'readonly',
465 RN$enableMicrotasksInReact: 'readonly',
466 + RN$isNativeEventTargetEventDispatchingEnabled: 'readonly',
467 },
468 },
469 {
packages/react-native-renderer/src/LegacySyntheticEvent.js new
+69
@@ -0,0 +1,69 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +/* globals Event$Init */
11 +
12 +/**
13 + * A bridge event class that extends the W3C Event interface and carries
14 + * the native event payload. This is used as a compatibility layer during
15 + * the migration from the legacy SyntheticEvent system to EventTarget-based
16 + * dispatching.
17 + */
18 +export default class LegacySyntheticEvent extends Event {
19 + _nativeEvent: {[string]: mixed};
20 + _propagationStopped: boolean;
21 +
22 + constructor(
23 + type: string,
24 + options: Event$Init,
25 + nativeEvent: {[string]: mixed},
26 + ) {
27 + super(type, options);
28 + this._nativeEvent = nativeEvent;
29 + this._propagationStopped = false;
30 + }
31 +
32 + get nativeEvent(): {[string]: mixed} {
33 + return this._nativeEvent;
34 + }
35 +
36 + stopPropagation(): void {
37 + super.stopPropagation();
38 + this._propagationStopped = true;
39 + }
40 +
41 + stopImmediatePropagation(): void {
42 + super.stopImmediatePropagation();
43 + this._propagationStopped = true;
44 + }
45 +
46 + /**
47 + * No-op for backward compatibility. The legacy SyntheticEvent system
48 + * used pooling which required calling persist() to keep the event.
49 + * With EventTarget-based dispatching, events are never pooled.
50 + */
51 + persist(): void {
52 + // No-op
53 + }
54 +
55 + /**
56 + * Backward-compatible wrapper for `defaultPrevented`.
57 + */
58 + isDefaultPrevented(): boolean {
59 + return this.defaultPrevented;
60 + }
61 +
62 + /**
63 + * Backward-compatible wrapper. Returns true if stopPropagation()
64 + * has been called.
65 + */
66 + isPropagationStopped(): boolean {
67 + return this._propagationStopped;
68 + }
69 +}
packages/react-native-renderer/src/ReactFabricEventEmitter.js
+65 -13
@@ -28,11 +28,23 @@ import accumulateInto from './legacy-events/accumulateInto';
28 import getListener from './ReactNativeGetListener';
29 import {runEventsInBatch} from './legacy-events/EventBatching';
30
31 -import {RawEventEmitter} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface';
31 +import {
32 + RawEventEmitter,
33 + ReactNativeViewConfigRegistry,
34 + dispatchTrustedEvent,
35 + setEventInitTimeStamp,
36 +} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface';
37 import {getPublicInstance} from './ReactFiberConfigFabric';
38 +import LegacySyntheticEvent from './LegacySyntheticEvent';
39 +import {topLevelTypeToEventName} from './ReactNativeEventTypeMapping';
40 +import {processResponderEvent} from './ReactNativeResponder';
41 +import {enableNativeEventTargetEventDispatching} from './ReactNativeFeatureFlags';
42
43 export {getListener, registrationNameModules as registrationNames};
44
45 +const {customBubblingEventTypes, customDirectEventTypes} =
46 + ReactNativeViewConfigRegistry;
47 +
48 /**
49 * Allows registered plugins an opportunity to extract events from top-level
50 * native browser events.
@@ -47,10 +59,12 @@ function extractPluginEvents(
59 nativeEventTarget: null | EventTarget,
60 ): Array<ReactSyntheticEvent> | ReactSyntheticEvent | null {
61 let events: Array<ReactSyntheticEvent> | ReactSyntheticEvent | null = null;
50 - const legacyPlugins = ((plugins: any): Array<LegacyPluginModule<Event>>);
62 + const legacyPlugins = ((plugins: any): Array<
63 + LegacyPluginModule<AnyNativeEvent>,
64 + >);
65 for (let i = 0; i < legacyPlugins.length; i++) {
66 // Not every plugin in the ordering may be loaded at runtime.
53 - const possiblePlugin: LegacyPluginModule<AnyNativeEvent> = legacyPlugins[i];
67 + const possiblePlugin = legacyPlugins[i];
68 if (possiblePlugin) {
69 const extractedEvents = possiblePlugin.extractEvents(
70 topLevelType,
@@ -84,8 +98,12 @@ function runExtractedPluginEventsInBatch(
98 export function dispatchEvent(
99 target: null | Object,
100 topLevelType: RNTopLevelEventType,
87 - nativeEvent: AnyNativeEvent,
101 + nativeEventParam: mixed,
102 ) {
103 + const nativeEvent: AnyNativeEvent =
104 + nativeEventParam != null && typeof nativeEventParam === 'object'
105 + ? (nativeEventParam: any)
106 + : {};
107 const targetFiber = (target: null | Fiber);
108
109 let eventTarget = null;
@@ -121,18 +139,52 @@ export function dispatchEvent(
139 // Note that extracted events are *not* emitted,
140 // only events that have a 1:1 mapping with a native event, at least for now.
141 const event = {eventName: topLevelType, nativeEvent};
124 - // $FlowFixMe[class-object-subtyping] found when upgrading Flow
142 RawEventEmitter.emit(topLevelType, event);
126 - // $FlowFixMe[class-object-subtyping] found when upgrading Flow
143 RawEventEmitter.emit('*', event);
144
129 - // Heritage plugin event system
130 - runExtractedPluginEventsInBatch(
131 - topLevelType,
132 - targetFiber,
133 - nativeEvent,
134 - eventTarget,
135 - );
145 + if (enableNativeEventTargetEventDispatching()) {
146 + // Process responder events before normal event dispatch.
147 + // This handles touch negotiation (onStartShouldSetResponder, etc.)
148 + processResponderEvent(topLevelType, targetFiber, nativeEvent);
149 +
150 + // New EventTarget-based dispatch path
151 + if (eventTarget != null) {
152 + const bubbleDispatchConfig = customBubblingEventTypes[topLevelType];
153 + const directDispatchConfig = customDirectEventTypes[topLevelType];
154 + const bubbles = bubbleDispatchConfig != null;
155 +
156 + // Skip events that are not registered in the view config
157 + if (bubbles || directDispatchConfig != null) {
158 + const eventName = topLevelTypeToEventName(topLevelType);
159 + const options = {
160 + bubbles,
161 + cancelable: true,
162 + };
163 + // Preserve the native event timestamp for backwards compatibility.
164 + // The legacy SyntheticEvent system used nativeEvent.timeStamp || nativeEvent.timestamp.
165 + const nativeTimestamp =
166 + nativeEvent.timeStamp ?? nativeEvent.timestamp;
167 + if (typeof nativeTimestamp === 'number') {
168 + setEventInitTimeStamp(options, nativeTimestamp);
169 + }
170 + const syntheticEvent = new LegacySyntheticEvent(
171 + eventName,
172 + options,
173 + nativeEvent,
174 + );
175 + // $FlowFixMe[incompatible-call]
176 + dispatchTrustedEvent(eventTarget, syntheticEvent);
177 + }
178 + }
179 + } else {
180 + // Heritage plugin event system
181 + runExtractedPluginEventsInBatch(
182 + topLevelType,
183 + targetFiber,
184 + nativeEvent,
185 + eventTarget,
186 + );
187 + }
188 });
189 // React Native doesn't use ReactControlledComponent but if it did, here's
190 // where it would do it.
packages/react-native-renderer/src/ReactNativeEventEmitter.js
+4 -2
@@ -131,10 +131,12 @@ function extractPluginEvents(
131 nativeEventTarget: null | EventTarget,
132 ): Array<ReactSyntheticEvent> | ReactSyntheticEvent | null {
133 let events: Array<ReactSyntheticEvent> | ReactSyntheticEvent | null = null;
134 - const legacyPlugins = ((plugins: any): Array<LegacyPluginModule<Event>>);
134 + const legacyPlugins = ((plugins: any): Array<
135 + LegacyPluginModule<AnyNativeEvent>,
136 + >);
137 for (let i = 0; i < legacyPlugins.length; i++) {
138 // Not every plugin in the ordering may be loaded at runtime.
137 - const possiblePlugin: LegacyPluginModule<AnyNativeEvent> = legacyPlugins[i];
139 + const possiblePlugin = legacyPlugins[i];
140 if (possiblePlugin) {
141 const extractedEvents = possiblePlugin.extractEvents(
142 topLevelType,
packages/react-native-renderer/src/ReactNativeEventTypeMapping.js new
+24
@@ -0,0 +1,24 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +/**
11 + * Converts a topLevelType (e.g., "topPress") to a DOM event name (e.g., "press").
12 + * Strips the "top" prefix and lowercases the result.
13 + */
14 +export function topLevelTypeToEventName(topLevelType: string): string {
15 + const fourthChar = topLevelType.charCodeAt(3);
16 + if (
17 + topLevelType.startsWith('top') &&
18 + fourthChar >= 65 /* A */ &&
19 + fourthChar <= 90 /* Z */
20 + ) {
21 + return topLevelType.slice(3).toLowerCase();
22 + }
23 + return topLevelType;
24 +}
packages/react-native-renderer/src/ReactNativeFeatureFlags.js new
+23
@@ -0,0 +1,23 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +// These globals are set by React Native (e.g. in setUpDOM.js, setUpTimers.js)
11 +// and provide access to RN's feature flags. We use global functions because we
12 +// don't have another mechanism to pass feature flags from RN to React in OSS.
13 +// Values are lazily evaluated and cached on first access.
14 +
15 +let _enableNativeEventTargetEventDispatching: boolean | null = null;
16 +export function enableNativeEventTargetEventDispatching(): boolean {
17 + if (_enableNativeEventTargetEventDispatching == null) {
18 + _enableNativeEventTargetEventDispatching =
19 + typeof RN$isNativeEventTargetEventDispatchingEnabled === 'function' &&
20 + RN$isNativeEventTargetEventDispatchingEnabled();
21 + }
22 + return _enableNativeEventTargetEventDispatching;
23 +}
packages/react-native-renderer/src/ReactNativeResponder.js new
+572
@@ -0,0 +1,572 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +/**
11 + * Responder System:
12 + * -----------------
13 + *
14 + * - A global, solitary "interaction lock" on a view.
15 + * - If a node becomes the responder, it should convey visual feedback
16 + * immediately to indicate so, either by highlighting or moving accordingly.
17 + * - To be the responder means that touches are exclusively important to that
18 + * responder view, and no other view.
19 + * - While touches are still occurring, the responder lock can be transferred to
20 + * a new view, but only to increasingly "higher" views (meaning ancestors of
21 + * the current responder).
22 + *
23 + * Responder being granted:
24 + * ------------------------
25 + *
26 + * - Touch starts, moves, and scrolls can cause a view to become the responder.
27 + * - We dispatch `startShouldSetResponder`/`moveShouldSetResponder` as bubbling
28 + * EventTarget events to the "appropriate place".
29 + * - If nothing is currently the responder, the "appropriate place" is the
30 + * initiating event's target.
31 + * - If something *is* already the responder, the "appropriate place" is the
32 + * first common ancestor of the event target and the current responder.
33 + * - Some negotiation happens: See the timing diagram below.
34 + * - Scrolled views automatically become responder. The reasoning is that a
35 + * platform scroll view that isn't built on top of the responder system has
36 + * begun scrolling, and the active responder must now be notified that the
37 + * interaction is no longer locked to it — the system has taken over.
38 + *
39 + * Responder being released:
40 + * -------------------------
41 + *
42 + * As soon as no more touches that *started* inside of descendants of the
43 + * *current* responder remain active, an `onResponderRelease` event is
44 + * dispatched to the current responder, and the responder lock is released.
45 + *
46 + * Direct dispatch (no EventTarget):
47 + * ----------------------------------
48 + *
49 + * Responder events bypass EventTarget entirely. Handlers are read directly
50 + * from `canonical.currentProps` at dispatch time — no commit-time registration,
51 + * no wrappers, no addEventListener.
52 + *
53 + * Negotiation walks the fiber tree manually (capture then bubble phase) using
54 + * `getParent()`. The first handler returning `true` wins.
55 + *
56 + * Lifecycle events call the handler directly and inspect return values:
57 + * - `onResponderGrant` returning `true` → block native responder
58 + * - `onResponderTerminationRequest` returning `false` → refuse termination
59 + *
60 + *
61 + * Negotiation Performed
62 + * +-----------------------+
63 + * / \
64 + * Process low level events to + Current Responder + wantsResponder
65 + * determine who to perform negot-| (if any exists at all) |
66 + * iation/transition | Otherwise just pass through|
67 + * -------------------------------+----------------------------+------------------+
68 + * Bubble to find first ID | |
69 + * to return true:wantsResponder | |
70 + * | |
71 + * +-------------+ | |
72 + * | onTouchStart| | |
73 + * +------+------+ none | |
74 + * | return| |
75 + * +-----------v-------------+true| +------------------------+ |
76 + * |onStartShouldSetResponder|----->|onResponderStart (cur) |<-----------+
77 + * +-----------+-------------+ | +------------------------+ | |
78 + * | | | +--------+-------+
79 + * | returned true for| false:REJECT +-------->|onResponderReject
80 + * | wantsResponder | | | +----------------+
81 + * | (now attempt | +------------------+-----+ |
82 + * | handoff) | | onResponder | |
83 + * +------------------->| TerminationRequest| |
84 + * | +------------------+-----+ |
85 + * | | | +----------------+
86 + * | true:GRANT +-------->|onResponderGrant|
87 + * | | +--------+-------+
88 + * | +------------------------+ | |
89 + * | | onResponderTerminate |<-----------+
90 + * | +------------------+-----+ |
91 + * | | | +----------------+
92 + * | +-------->|onResponderStart|
93 + * | | +----------------+
94 + * Bubble to find first ID | |
95 + * to return true:wantsResponder | |
96 + * | |
97 + * +-------------+ | |
98 + * | onTouchMove | | |
99 + * +------+------+ none | |
100 + * | return| |
101 + * +-----------v-------------+true| +------------------------+ |
102 + * |onMoveShouldSetResponder |----->|onResponderMove (cur) |<-----------+
103 + * +-----------+-------------+ | +------------------------+ | |
104 + * | | | +--------+-------+
105 + * | returned true for| false:REJECT +-------->|onResponderReject
106 + * | wantsResponder | | | +----------------+
107 + * | (now attempt | +------------------+-----+ |
108 + * | handoff) | | onResponder | |
109 + * +------------------->| TerminationRequest| |
110 + * | +------------------+-----+ |
111 + * | | | +----------------+
112 + * | true:GRANT +-------->|onResponderGrant|
113 + * | | +--------+-------+
114 + * | +------------------------+ | |
115 + * | | onResponderTerminate |<-----------+
116 + * | +------------------+-----+ |
117 + * | | | +----------------+
118 + * | +-------->|onResponderMove |
119 + * | | +----------------+
120 + * | |
121 + * | |
122 + * Some active touch started| |
123 + * inside current responder | +------------------------+ |
124 + * +------------------------->| onResponderEnd | |
125 + * | | +------------------------+ |
126 + * +---+---------+ | |
127 + * | onTouchEnd | | |
128 + * +---+---------+ | |
129 + * | | +------------------------+ |
130 + * +------------------------->| onResponderEnd | |
131 + * No active touches started| +-----------+------------+ |
132 + * inside current responder | | |
133 + * | v |
134 + * | +------------------------+ |
135 + * | | onResponderRelease | |
136 + * | +------------------------+ |
137 + * | |
138 + * + +
139 + */
140 +
141 +import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
142 +
143 +import LegacySyntheticEvent from './LegacySyntheticEvent';
144 +import ResponderTouchHistoryStore from './legacy-events/ResponderTouchHistoryStore';
145 +import {HostComponent} from 'react-reconciler/src/ReactWorkTags';
146 +import {getInstanceFromNode} from './ReactFabricComponentTree';
147 +
148 +// The currently active responder (tracked as a fiber)
149 +let responderFiber: Fiber | null = null;
150 +
151 +/**
152 + * Count of current touches. A textInput should become responder iff the
153 + * selection changes while there is a touch on the screen.
154 + */
155 +let trackedTouchCount = 0;
156 +
157 +function isStartish(topLevelType: string): boolean {
158 + return topLevelType === 'topTouchStart';
159 +}
160 +
161 +function isMoveish(topLevelType: string): boolean {
162 + return topLevelType === 'topTouchMove';
163 +}
164 +
165 +function isEndish(topLevelType: string): boolean {
166 + return topLevelType === 'topTouchEnd' || topLevelType === 'topTouchCancel';
167 +}
168 +
169 +/**
170 + * Walk up the fiber tree, skipping non-HostComponent fibers.
171 + */
172 +function getParent(inst: Fiber): Fiber | null {
173 + let fiber = inst.return;
174 + while (fiber != null) {
175 + if (fiber.tag === HostComponent) {
176 + return fiber;
177 + }
178 + fiber = fiber.return;
179 + }
180 + return null;
181 +}
182 +
183 +/**
184 + * Return the lowest common ancestor of A and B, or null if they are in
185 + * different trees.
186 + */
187 +function getLowestCommonAncestor(instA: Fiber, instB: Fiber): Fiber | null {
188 + let depthA = 0;
189 + for (let tempA: Fiber | null = instA; tempA; tempA = getParent(tempA)) {
190 + depthA++;
191 + }
192 + let depthB = 0;
193 + for (let tempB: Fiber | null = instB; tempB; tempB = getParent(tempB)) {
194 + depthB++;
195 + }
196 +
197 + let a = instA;
198 + let b = instB;
199 +
200 + // If A is deeper, crawl up.
201 + while (depthA - depthB > 0) {
202 + a = (getParent(a): any);
203 + depthA--;
204 + }
205 +
206 + // If B is deeper, crawl up.
207 + while (depthB - depthA > 0) {
208 + b = (getParent(b): any);
209 + depthB--;
210 + }
211 +
212 + // Walk in lockstep until we find a match.
213 + let depth = depthA;
214 + while (depth--) {
215 + if (a === b || a === b.alternate) {
216 + return a;
217 + }
218 + a = (getParent(a): any);
219 + b = (getParent(b): any);
220 + }
221 + return null;
222 +}
223 +
224 +/**
225 + * Return true if A is an ancestor of B.
226 + */
227 +function isAncestor(instA: Fiber, instB: Fiber | null): boolean {
228 + let current = instB;
229 + while (current != null) {
230 + if (instA === current || instA === current.alternate) {
231 + return true;
232 + }
233 + current = getParent(current);
234 + }
235 + return false;
236 +}
237 +
238 +function changeResponder(
239 + nextResponderFiber: Fiber | null,
240 + blockNativeResponder: boolean,
241 +): void {
242 + const oldResponderFiber = responderFiber;
243 + responderFiber = nextResponderFiber;
244 +
245 + // Notify the native side about responder changes so native gestures
246 + // (e.g. ScrollView scroll) can defer to JS.
247 + if (oldResponderFiber != null && oldResponderFiber.stateNode != null) {
248 + nativeFabricUIManager.setIsJSResponder(
249 + oldResponderFiber.stateNode.node,
250 + false,
251 + blockNativeResponder,
252 + );
253 + }
254 + if (nextResponderFiber != null && nextResponderFiber.stateNode != null) {
255 + nativeFabricUIManager.setIsJSResponder(
256 + nextResponderFiber.stateNode.node,
257 + true,
258 + blockNativeResponder,
259 + );
260 + }
261 +}
262 +
263 +/**
264 + * Determine the negotiation event name for a given topLevelType.
265 + */
266 +function getShouldSetEventName(topLevelType: string): string {
267 + if (isStartish(topLevelType)) {
268 + return 'startShouldSetResponder';
269 + } else if (isMoveish(topLevelType)) {
270 + return 'moveShouldSetResponder';
271 + } else if (topLevelType === 'topSelectionChange') {
272 + return 'selectionChangeShouldSetResponder';
273 + } else {
274 + return 'scrollShouldSetResponder';
275 + }
276 +}
277 +
278 +/**
279 + * Run negotiation by walking the fiber tree directly. Performs capture phase
280 + * (root→target) then bubble phase (target→root), calling handlers from
281 + * `canonical.currentProps`. The first handler that returns `true` wins.
282 + *
283 + * The dispatch target is determined as follows:
284 + * - If no responder exists, dispatch from the event target (full tree).
285 + * - If a responder exists, dispatch from the lowest common ancestor (LCA)
286 + * of the responder and the target — only ancestors can claim.
287 + * - If the LCA is the current responder itself, skip it (don't re-negotiate
288 + * with yourself) and dispatch from the parent.
289 + *
290 + * @return {Fiber | null} The fiber that claimed the responder, or null.
291 + */
292 +function negotiateResponder(
293 + targetFiber: Fiber,
294 + topLevelType: string,
295 + nativeEvent: {[string]: mixed},
296 +): Fiber | null {
297 + const shouldSetEventName = getShouldSetEventName(topLevelType);
298 +
299 + // Determine the negotiation dispatch target
300 + let negotiationFiber;
301 + let skipSelf = false;
302 + if (responderFiber == null) {
303 + negotiationFiber = targetFiber;
304 + } else {
305 + negotiationFiber = getLowestCommonAncestor(responderFiber, targetFiber);
306 + if (negotiationFiber == null) {
307 + return null;
308 + }
309 + if (negotiationFiber === responderFiber) {
310 + skipSelf = true;
311 + }
312 + }
313 +
314 + const dispatchFiber = skipSelf
315 + ? getParent(negotiationFiber)
316 + : negotiationFiber;
317 + if (dispatchFiber == null) {
318 + return null;
319 + }
320 +
321 + // Build ancestor path (root to dispatch fiber)
322 + const path: Array<Fiber> = [];
323 + let fiber: Fiber | null = dispatchFiber;
324 + while (fiber != null) {
325 + path.unshift(fiber);
326 + fiber = getParent(fiber);
327 + }
328 +
329 + const event = new LegacySyntheticEvent(
330 + shouldSetEventName,
331 + {bubbles: true, cancelable: true},
332 + nativeEvent,
333 + );
334 + // $FlowFixMe[prop-missing] touchHistory is a responder-specific extension not in the Event type
335 + event.touchHistory = ResponderTouchHistoryStore.touchHistory;
336 +
337 + // Derive prop names from event name
338 + const bubblePropName =
339 + 'on' +
340 + shouldSetEventName.charAt(0).toUpperCase() +
341 + shouldSetEventName.slice(1);
342 + const capturePropName = bubblePropName + 'Capture';
343 +
344 + // Capture phase: root → target
345 + for (let i = 0; i < path.length; i++) {
346 + const stateNode = path[i].stateNode;
347 + if (stateNode == null) {
348 + continue;
349 + }
350 + const handler = stateNode.canonical.currentProps[capturePropName];
351 + if (typeof handler === 'function' && handler(event) === true) {
352 + return path[i];
353 + }
354 + }
355 +
356 + // Bubble phase: target → root
357 + for (let i = path.length - 1; i >= 0; i--) {
358 + const stateNode = path[i].stateNode;
359 + if (stateNode == null) {
360 + continue;
361 + }
362 + const handler = stateNode.canonical.currentProps[bubblePropName];
363 + if (typeof handler === 'function' && handler(event) === true) {
364 + return path[i];
365 + }
366 + }
367 +
368 + return null;
369 +}
370 +
371 +/**
372 + * Dispatch a lifecycle responder event by calling the handler directly from
373 + * `canonical.currentProps`. Returns the handler's return value so callers can
374 + * inspect it (e.g. `onResponderGrant` returning `true` to block native).
375 + */
376 +function dispatchResponderEvent(
377 + fiber: Fiber,
378 + eventName: string,
379 + nativeEvent: {[string]: mixed},
380 +): mixed {
381 + const stateNode = fiber.stateNode;
382 + if (stateNode == null) {
383 + return undefined;
384 + }
385 +
386 + const propName =
387 + 'on' + eventName.charAt(0).toUpperCase() + eventName.slice(1);
388 + const handler = stateNode.canonical.currentProps[propName];
389 + if (typeof handler !== 'function') {
390 + return undefined;
391 + }
392 +
393 + const event = new LegacySyntheticEvent(
394 + eventName,
395 + {bubbles: false, cancelable: true},
396 + nativeEvent,
397 + );
398 + // $FlowFixMe[prop-missing] touchHistory is a responder-specific extension not in the Event type
399 + event.touchHistory = ResponderTouchHistoryStore.touchHistory;
400 +
401 + return handler(event);
402 +}
403 +
404 +/**
405 + * A transfer is a negotiation between a currently set responder and the next
406 + * element to claim responder status. Any start event could trigger a transfer
407 + * of responderFiber. Any move event could trigger a transfer.
408 + *
409 + * @return {boolean} True if a transfer of responder could possibly occur.
410 + */
411 +function canTriggerTransfer(
412 + topLevelType: string,
413 + targetFiber: Fiber | null,
414 + nativeEvent: {[string]: mixed},
415 +): boolean {
416 + return (
417 + targetFiber != null &&
418 + ((topLevelType === 'topScroll' && !nativeEvent.responderIgnoreScroll) ||
419 + (trackedTouchCount > 0 && topLevelType === 'topSelectionChange') ||
420 + isStartish(topLevelType) ||
421 + isMoveish(topLevelType))
422 + );
423 +}
424 +
425 +/**
426 + * Returns whether or not this touch end event makes it such that there are no
427 + * longer any touches that started inside of the current `responderFiber`.
428 + *
429 + * @param {NativeEvent} nativeEvent Native touch end event.
430 + * @return {boolean} Whether or not this touch end event ends the responder.
431 + */
432 +function noResponderTouches(nativeEvent: {[string]: mixed}): boolean {
433 + const touches = (nativeEvent.touches: any);
434 + if (!touches || touches.length === 0) {
435 + return true;
436 + }
437 + for (let i = 0; i < touches.length; i++) {
438 + const activeTouch = touches[i];
439 + const target = activeTouch.target;
440 + if (target !== null && target !== undefined && target !== 0) {
441 + // Is the original touch location inside of the current responder?
442 + const targetInst = getInstanceFromNode(target);
443 + if (
444 + responderFiber != null &&
445 + targetInst != null &&
446 + isAncestor(responderFiber, targetInst)
447 + ) {
448 + return false;
449 + }
450 + }
451 + }
452 + return true;
453 +}
454 +
455 +/**
456 + * Process a native event through the responder system.
457 + * Called from ReactFabricEventEmitter when the flag is enabled.
458 + */
459 +export function processResponderEvent(
460 + topLevelType: string,
461 + targetFiber: Fiber | null,
462 + nativeEvent: {[string]: mixed},
463 +): void {
464 + // Track touch count
465 + if (isStartish(topLevelType)) {
466 + trackedTouchCount += 1;
467 + } else if (isEndish(topLevelType)) {
468 + if (trackedTouchCount >= 0) {
469 + trackedTouchCount -= 1;
470 + } else {
471 + if (__DEV__) {
472 + console.warn(
473 + 'Ended a touch event which was not counted in `trackedTouchCount`.',
474 + );
475 + }
476 + return;
477 + }
478 + }
479 +
480 + ResponderTouchHistoryStore.recordTouchTrack(topLevelType, (nativeEvent: any));
481 +
482 + // Negotiation: determine if a new responder should be set
483 + if (
484 + canTriggerTransfer(topLevelType, targetFiber, nativeEvent) &&
485 + targetFiber != null
486 + ) {
487 + const wantsResponderFiber = negotiateResponder(
488 + targetFiber,
489 + topLevelType,
490 + nativeEvent,
491 + );
492 +
493 + if (wantsResponderFiber != null && wantsResponderFiber !== responderFiber) {
494 + // A new view wants to become responder.
495 + // onResponderGrant returning true means block native responder.
496 + const grantResult = dispatchResponderEvent(
497 + wantsResponderFiber,
498 + 'responderGrant',
499 + nativeEvent,
500 + );
501 + const blockNativeResponder = grantResult === true;
502 +
503 + if (responderFiber != null) {
504 + // Capture in a local to preserve Flow narrowing across function calls.
505 + const currentResponder = responderFiber;
506 + // Ask current responder if it will terminate.
507 + // onResponderTerminationRequest returning false means refuse.
508 + const terminationResult = dispatchResponderEvent(
509 + currentResponder,
510 + 'responderTerminationRequest',
511 + nativeEvent,
512 + );
513 + const shouldSwitch = terminationResult !== false;
514 +
515 + if (shouldSwitch) {
516 + dispatchResponderEvent(
517 + currentResponder,
518 + 'responderTerminate',
519 + nativeEvent,
520 + );
521 + changeResponder(wantsResponderFiber, blockNativeResponder);
522 + } else {
523 + dispatchResponderEvent(
524 + wantsResponderFiber,
525 + 'responderReject',
526 + nativeEvent,
527 + );
528 + }
529 + } else {
530 + changeResponder(wantsResponderFiber, blockNativeResponder);
531 + }
532 + }
533 + }
534 +
535 + // Responder may or may not have transferred on a new touch start/move.
536 + // Regardless, whoever is the responder after any potential transfer, we
537 + // direct all touch start/move/ends to them in the form of
538 + // `onResponderMove/Start/End`. These will be called for *every* additional
539 + // finger that move/start/end, dispatched directly to whoever is the
540 + // current responder at that moment, until the responder is "released".
541 + //
542 + // These multiple individual change touch events are always bookended
543 + // by `onResponderGrant`, and one of
544 + // (`onResponderRelease/onResponderTerminate`).
545 + if (responderFiber != null) {
546 + // Capture in a local to preserve Flow narrowing across function calls.
547 + const activeResponder = responderFiber;
548 + if (isStartish(topLevelType)) {
549 + dispatchResponderEvent(activeResponder, 'responderStart', nativeEvent);
550 + } else if (isMoveish(topLevelType)) {
551 + dispatchResponderEvent(activeResponder, 'responderMove', nativeEvent);
552 + } else if (isEndish(topLevelType)) {
553 + dispatchResponderEvent(activeResponder, 'responderEnd', nativeEvent);
554 +
555 + if (topLevelType === 'topTouchCancel') {
556 + dispatchResponderEvent(
557 + activeResponder,
558 + 'responderTerminate',
559 + nativeEvent,
560 + );
561 + changeResponder(null, false);
562 + } else if (noResponderTouches(nativeEvent)) {
563 + dispatchResponderEvent(
564 + activeResponder,
565 + 'responderRelease',
566 + nativeEvent,
567 + );
568 + changeResponder(null, false);
569 + }
570 + }
571 + }
572 +}
packages/react-native-renderer/src/legacy-events/PluginModuleType.js
+3 -1
@@ -16,7 +16,9 @@ import type {TopLevelType} from './TopLevelEventTypes';
16
17 export type EventTypes = {[key: string]: DispatchConfig};
18
19 -export type AnyNativeEvent = Event | KeyboardEvent | MouseEvent | TouchEvent;
19 +// Native events from C++ are plain objects with arbitrary properties,
20 +// not DOM Event class instances.
21 +export type AnyNativeEvent = {[string]: mixed};
22
23 export type PluginName = string;
24
scripts/flow/react-native-host-hooks.js
+13
@@ -204,6 +204,14 @@ declare module 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface'
204 declare export function getInternalInstanceHandleFromPublicInstance(
205 publicInstance: PublicInstance,
206 ): ?Object;
207 + declare export function dispatchTrustedEvent(
208 + target: EventTarget,
209 + event: Event,
210 + ): void;
211 + declare export function setEventInitTimeStamp(
212 + eventInit: {[string]: mixed},
213 + timeStamp: number,
214 + ): void;
215 declare export function createAttributePayload(
216 props: Object,
217 validAttributes: __AttributeConfiguration,
@@ -228,6 +236,11 @@ declare module 'react-native' {
236 // eslint-disable-next-line no-unused-vars
237 declare const RN$enableMicrotasksInReact: boolean;
238
239 +// eslint-disable-next-line no-unused-vars
240 +declare const RN$isNativeEventTargetEventDispatchingEnabled:
241 + | (() => boolean)
242 + | void;
243 +
244 // This is needed for a short term solution.
245 // See https://github.com/facebook/react/pull/15490 for more info
246 // eslint-disable-next-line no-unused-vars
scripts/rollup/validate/eslintrc.rn.js
+1
@@ -48,6 +48,7 @@ module.exports = {
48 nativeFabricUIManager: 'readonly',
49 // RN flag to enable microtasks
50 RN$enableMicrotasksInReact: 'readonly',
51 + RN$isNativeEventTargetEventDispatchingEnabled: 'readonly',
52 // Trusted Types
53 trustedTypes: 'readonly',
54 // RN supports this