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
+}