main
js 676 lines 22 KB
Raw
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 import type {AnyNativeEvent} from '../events/PluginModuleType';
11 import type {
12 Container,
13 ActivityInstance,
14 SuspenseInstance,
15 } from '../client/ReactFiberConfigDOM';
16 import type {DOMEventName} from '../events/DOMEventNames';
17 import type {EventSystemFlags} from './EventSystemFlags';
18 import type {FiberRoot} from 'react-reconciler/src/ReactInternalTypes';
19 import type {EventPriority} from 'react-reconciler/src/ReactEventPriorities';
20
21 import {
22 unstable_scheduleCallback as scheduleCallback,
23 unstable_NormalPriority as NormalPriority,
24 } from 'scheduler';
25 import {
26 getNearestMountedFiber,
27 getContainerFromFiber,
28 getActivityInstanceFromFiber,
29 getSuspenseInstanceFromFiber,
30 } from 'react-reconciler/src/ReactFiberTreeReflection';
31 import {
32 findInstanceBlockingEvent,
33 findInstanceBlockingTarget,
34 } from './ReactDOMEventListener';
35 import {setReplayingEvent, resetReplayingEvent} from './CurrentReplayingEvent';
36 import {
37 getInstanceFromNode,
38 getClosestInstanceFromNode,
39 getFiberCurrentPropsFromNode,
40 } from '../client/ReactDOMComponentTree';
41 import {
42 HostRoot,
43 ActivityComponent,
44 SuspenseComponent,
45 } from 'react-reconciler/src/ReactWorkTags';
46 import {isHigherEventPriority} from 'react-reconciler/src/ReactEventPriorities';
47 import {isRootDehydrated} from 'react-reconciler/src/ReactFiberShellHydration';
48 import {dispatchReplayedFormAction} from './plugins/FormActionEventPlugin';
49 import {
50 resolveUpdatePriority,
51 runWithPriority as attemptHydrationAtPriority,
52 } from '../client/ReactDOMUpdatePriority';
53
54 import {
55 attemptContinuousHydration,
56 attemptHydrationAtCurrentPriority,
57 } from 'react-reconciler/src/ReactFiberReconciler';
58
59 import {enableHydrationChangeEvent} from 'shared/ReactFeatureFlags';
60
61 // TODO: Upgrade this definition once we're on a newer version of Flow that
62 // has this definition built-in.
63 type PointerEventType = Event & {
64 pointerId: number,
65 relatedTarget: EventTarget | null,
66 ...
67 };
68
69 type QueuedReplayableEvent = {
70 blockedOn: null | Container | ActivityInstance | SuspenseInstance,
71 domEventName: DOMEventName,
72 eventSystemFlags: EventSystemFlags,
73 nativeEvent: AnyNativeEvent,
74 targetContainers: Array<EventTarget>,
75 };
76
77 let hasScheduledReplayAttempt = false;
78
79 // The last of each continuous event type. We only need to replay the last one
80 // if the last target was dehydrated.
81 let queuedFocus: null | QueuedReplayableEvent = null;
82 let queuedDrag: null | QueuedReplayableEvent = null;
83 let queuedMouse: null | QueuedReplayableEvent = null;
84 // For pointer events there can be one latest event per pointerId.
85 const queuedPointers: Map<number, QueuedReplayableEvent> = new Map();
86 const queuedPointerCaptures: Map<number, QueuedReplayableEvent> = new Map();
87 // We could consider replaying selectionchange and touchmoves too.
88
89 const queuedChangeEventTargets: Array<EventTarget> = [];
90
91 type QueuedHydrationTarget = {
92 blockedOn: null | Container | ActivityInstance | SuspenseInstance,
93 target: Node,
94 priority: EventPriority,
95 };
96 const queuedExplicitHydrationTargets: Array<QueuedHydrationTarget> = [];
97
98 const discreteReplayableEvents: Array<DOMEventName> = [
99 'mousedown',
100 'mouseup',
101 'touchcancel',
102 'touchend',
103 'touchstart',
104 'auxclick',
105 'dblclick',
106 'pointercancel',
107 'pointerdown',
108 'pointerup',
109 'dragend',
110 'dragstart',
111 'drop',
112 'compositionend',
113 'compositionstart',
114 'keydown',
115 'keypress',
116 'keyup',
117 'input',
118 'textInput', // Intentionally camelCase
119 'copy',
120 'cut',
121 'paste',
122 'click',
123 'change',
124 'contextmenu',
125 'reset',
126 // 'submit', // stopPropagation blocks the replay mechanism
127 ];
128
129 export function isDiscreteEventThatRequiresHydration(
130 eventType: DOMEventName,
131 ): boolean {
132 return discreteReplayableEvents.indexOf(eventType) > -1;
133 }
134
135 function createQueuedReplayableEvent(
136 blockedOn: null | Container | ActivityInstance | SuspenseInstance,
137 domEventName: DOMEventName,
138 eventSystemFlags: EventSystemFlags,
139 targetContainer: EventTarget,
140 nativeEvent: AnyNativeEvent,
141 ): QueuedReplayableEvent {
142 return {
143 blockedOn,
144 domEventName,
145 eventSystemFlags,
146 nativeEvent,
147 targetContainers: [targetContainer],
148 };
149 }
150
151 // Resets the replaying for this type of continuous event to no event.
152 export function clearIfContinuousEvent(
153 domEventName: DOMEventName,
154 nativeEvent: AnyNativeEvent,
155 ): void {
156 switch (domEventName) {
157 case 'focusin':
158 case 'focusout':
159 queuedFocus = null;
160 break;
161 case 'dragenter':
162 case 'dragleave':
163 queuedDrag = null;
164 break;
165 case 'mouseover':
166 case 'mouseout':
167 queuedMouse = null;
168 break;
169 case 'pointerover':
170 case 'pointerout': {
171 const pointerId = (nativeEvent as any as PointerEventType).pointerId;
172 queuedPointers.delete(pointerId);
173 break;
174 }
175 case 'gotpointercapture':
176 case 'lostpointercapture': {
177 const pointerId = (nativeEvent as any as PointerEventType).pointerId;
178 queuedPointerCaptures.delete(pointerId);
179 break;
180 }
181 }
182 }
183
184 function accumulateOrCreateContinuousQueuedReplayableEvent(
185 existingQueuedEvent: null | QueuedReplayableEvent,
186 blockedOn: null | Container | ActivityInstance | SuspenseInstance,
187 domEventName: DOMEventName,
188 eventSystemFlags: EventSystemFlags,
189 targetContainer: EventTarget,
190 nativeEvent: AnyNativeEvent,
191 ): QueuedReplayableEvent {
192 if (
193 existingQueuedEvent === null ||
194 existingQueuedEvent.nativeEvent !== nativeEvent
195 ) {
196 const queuedEvent = createQueuedReplayableEvent(
197 blockedOn,
198 domEventName,
199 eventSystemFlags,
200 targetContainer,
201 nativeEvent,
202 );
203 if (blockedOn !== null) {
204 const fiber = getInstanceFromNode(blockedOn);
205 if (fiber !== null) {
206 // Attempt to increase the priority of this target.
207 attemptContinuousHydration(fiber);
208 }
209 }
210 return queuedEvent;
211 }
212 // If we have already queued this exact event, then it's because
213 // the different event systems have different DOM event listeners.
214 // We can accumulate the flags, and the targetContainers, and
215 // store a single event to be replayed.
216 existingQueuedEvent.eventSystemFlags |= eventSystemFlags;
217 const targetContainers = existingQueuedEvent.targetContainers;
218 if (
219 // $FlowFixMe[invalid-compare]
220 targetContainer !== null &&
221 targetContainers.indexOf(targetContainer) === -1
222 ) {
223 targetContainers.push(targetContainer);
224 }
225 return existingQueuedEvent;
226 }
227
228 export function queueIfContinuousEvent(
229 blockedOn: null | Container | ActivityInstance | SuspenseInstance,
230 domEventName: DOMEventName,
231 eventSystemFlags: EventSystemFlags,
232 targetContainer: EventTarget,
233 nativeEvent: AnyNativeEvent,
234 ): boolean {
235 // These set relatedTarget to null because the replayed event will be treated as if we
236 // moved from outside the window (no target) onto the target once it hydrates.
237 // Instead of mutating we could clone the event.
238 switch (domEventName) {
239 case 'focusin': {
240 const focusEvent = nativeEvent as any as FocusEvent;
241 queuedFocus = accumulateOrCreateContinuousQueuedReplayableEvent(
242 queuedFocus,
243 blockedOn,
244 domEventName,
245 eventSystemFlags,
246 targetContainer,
247 focusEvent,
248 );
249 return true;
250 }
251 case 'dragenter': {
252 const dragEvent = nativeEvent as any as DragEvent;
253 queuedDrag = accumulateOrCreateContinuousQueuedReplayableEvent(
254 queuedDrag,
255 blockedOn,
256 domEventName,
257 eventSystemFlags,
258 targetContainer,
259 dragEvent,
260 );
261 return true;
262 }
263 case 'mouseover': {
264 const mouseEvent = nativeEvent as any as MouseEvent;
265 queuedMouse = accumulateOrCreateContinuousQueuedReplayableEvent(
266 queuedMouse,
267 blockedOn,
268 domEventName,
269 eventSystemFlags,
270 targetContainer,
271 mouseEvent,
272 );
273 return true;
274 }
275 case 'pointerover': {
276 const pointerEvent = nativeEvent as any as PointerEventType;
277 const pointerId = pointerEvent.pointerId;
278 queuedPointers.set(
279 pointerId,
280 accumulateOrCreateContinuousQueuedReplayableEvent(
281 queuedPointers.get(pointerId) || null,
282 blockedOn,
283 domEventName,
284 eventSystemFlags,
285 targetContainer,
286 pointerEvent,
287 ),
288 );
289 return true;
290 }
291 case 'gotpointercapture': {
292 const pointerEvent = nativeEvent as any as PointerEventType;
293 const pointerId = pointerEvent.pointerId;
294 queuedPointerCaptures.set(
295 pointerId,
296 accumulateOrCreateContinuousQueuedReplayableEvent(
297 queuedPointerCaptures.get(pointerId) || null,
298 blockedOn,
299 domEventName,
300 eventSystemFlags,
301 targetContainer,
302 pointerEvent,
303 ),
304 );
305 return true;
306 }
307 }
308 return false;
309 }
310
311 // Check if this target is unblocked. Returns true if it's unblocked.
312 function attemptExplicitHydrationTarget(
313 queuedTarget: QueuedHydrationTarget,
314 ): void {
315 // TODO: This function shares a lot of logic with findInstanceBlockingEvent.
316 // Try to unify them. It's a bit tricky since it would require two return
317 // values.
318 const targetInst = getClosestInstanceFromNode(queuedTarget.target);
319 if (targetInst !== null) {
320 const nearestMounted = getNearestMountedFiber(targetInst);
321 if (nearestMounted !== null) {
322 const tag = nearestMounted.tag;
323 if (tag === SuspenseComponent) {
324 const instance = getSuspenseInstanceFromFiber(nearestMounted);
325 if (instance !== null) {
326 // We're blocked on hydrating this boundary.
327 // Increase its priority.
328 queuedTarget.blockedOn = instance;
329 attemptHydrationAtPriority(queuedTarget.priority, () => {
330 attemptHydrationAtCurrentPriority(nearestMounted);
331 });
332
333 return;
334 }
335 } else if (tag === ActivityComponent) {
336 const instance = getActivityInstanceFromFiber(nearestMounted);
337 if (instance !== null) {
338 // We're blocked on hydrating this boundary.
339 // Increase its priority.
340 queuedTarget.blockedOn = instance;
341 attemptHydrationAtPriority(queuedTarget.priority, () => {
342 attemptHydrationAtCurrentPriority(nearestMounted);
343 });
344
345 return;
346 }
347 } else if (tag === HostRoot) {
348 const root: FiberRoot = nearestMounted.stateNode;
349 if (isRootDehydrated(root)) {
350 queuedTarget.blockedOn = getContainerFromFiber(nearestMounted);
351 // We don't currently have a way to increase the priority of
352 // a root other than sync.
353 return;
354 }
355 }
356 }
357 }
358 queuedTarget.blockedOn = null;
359 }
360
361 export function queueExplicitHydrationTarget(target: Node): void {
362 const updatePriority = resolveUpdatePriority();
363 const queuedTarget: QueuedHydrationTarget = {
364 blockedOn: null,
365 target: target,
366 priority: updatePriority,
367 };
368 let i = 0;
369 for (; i < queuedExplicitHydrationTargets.length; i++) {
370 // Stop once we hit the first target with lower priority than
371 if (
372 !isHigherEventPriority(
373 updatePriority,
374 queuedExplicitHydrationTargets[i].priority,
375 )
376 ) {
377 break;
378 }
379 }
380 queuedExplicitHydrationTargets.splice(i, 0, queuedTarget);
381 if (i === 0) {
382 attemptExplicitHydrationTarget(queuedTarget);
383 }
384 }
385
386 function attemptReplayContinuousQueuedEvent(
387 queuedEvent: QueuedReplayableEvent,
388 ): boolean {
389 if (queuedEvent.blockedOn !== null) {
390 return false;
391 }
392 const targetContainers = queuedEvent.targetContainers;
393 while (targetContainers.length > 0) {
394 const nextBlockedOn = findInstanceBlockingEvent(queuedEvent.nativeEvent);
395 if (nextBlockedOn === null) {
396 const nativeEvent = queuedEvent.nativeEvent;
397 const nativeEventClone = new nativeEvent.constructor(
398 nativeEvent.type,
399 nativeEvent as any,
400 );
401 setReplayingEvent(nativeEventClone);
402 nativeEvent.target.dispatchEvent(nativeEventClone);
403 resetReplayingEvent();
404 } else {
405 // We're still blocked. Try again later.
406 const fiber = getInstanceFromNode(nextBlockedOn);
407 if (fiber !== null) {
408 attemptContinuousHydration(fiber);
409 }
410 queuedEvent.blockedOn = nextBlockedOn;
411 return false;
412 }
413 // This target container was successfully dispatched. Try the next.
414 targetContainers.shift();
415 }
416 return true;
417 }
418
419 function attemptReplayContinuousQueuedEventInMap(
420 queuedEvent: QueuedReplayableEvent,
421 key: number,
422 map: Map<number, QueuedReplayableEvent>,
423 ): void {
424 if (attemptReplayContinuousQueuedEvent(queuedEvent)) {
425 map.delete(key);
426 }
427 }
428
429 function replayChangeEvent(target: EventTarget): void {
430 // Dispatch a fake "change" event for the input.
431 const element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement =
432 target as any;
433 if (element.nodeName === 'INPUT') {
434 if (element.type === 'checkbox' || element.type === 'radio') {
435 // Checkboxes always fire a click event regardless of how the change was made.
436 const EventCtr =
437 typeof PointerEvent === 'function' ? PointerEvent : Event;
438 target.dispatchEvent(new EventCtr('click', {bubbles: true}));
439 // For checkboxes the input event uses the Event constructor instead of InputEvent.
440 target.dispatchEvent(new Event('input', {bubbles: true}));
441 } else {
442 if (typeof InputEvent === 'function') {
443 target.dispatchEvent(new InputEvent('input', {bubbles: true}));
444 }
445 }
446 } else if (element.nodeName === 'TEXTAREA') {
447 if (typeof InputEvent === 'function') {
448 target.dispatchEvent(new InputEvent('input', {bubbles: true}));
449 }
450 }
451 target.dispatchEvent(new Event('change', {bubbles: true}));
452 }
453
454 function replayUnblockedEvents() {
455 hasScheduledReplayAttempt = false;
456 // Replay any continuous events.
457 if (queuedFocus !== null && attemptReplayContinuousQueuedEvent(queuedFocus)) {
458 queuedFocus = null;
459 }
460 if (queuedDrag !== null && attemptReplayContinuousQueuedEvent(queuedDrag)) {
461 queuedDrag = null;
462 }
463 if (queuedMouse !== null && attemptReplayContinuousQueuedEvent(queuedMouse)) {
464 queuedMouse = null;
465 }
466 queuedPointers.forEach(attemptReplayContinuousQueuedEventInMap);
467 queuedPointerCaptures.forEach(attemptReplayContinuousQueuedEventInMap);
468 if (enableHydrationChangeEvent) {
469 for (let i = 0; i < queuedChangeEventTargets.length; i++) {
470 replayChangeEvent(queuedChangeEventTargets[i]);
471 }
472 queuedChangeEventTargets.length = 0;
473 }
474 }
475
476 export function flushEventReplaying(): void {
477 // Synchronously flush any event replaying so that it gets observed before
478 // any new updates are applied.
479 if (hasScheduledReplayAttempt) {
480 replayUnblockedEvents();
481 }
482 }
483
484 export function queueChangeEvent(target: EventTarget): void {
485 if (enableHydrationChangeEvent) {
486 queuedChangeEventTargets.push(target);
487 if (!hasScheduledReplayAttempt) {
488 hasScheduledReplayAttempt = true;
489 }
490 }
491 }
492
493 function scheduleCallbackIfUnblocked(
494 queuedEvent: QueuedReplayableEvent,
495 unblocked: Container | SuspenseInstance | ActivityInstance,
496 ) {
497 if (queuedEvent.blockedOn === unblocked) {
498 queuedEvent.blockedOn = null;
499 if (!hasScheduledReplayAttempt) {
500 hasScheduledReplayAttempt = true;
501 if (!enableHydrationChangeEvent) {
502 // Schedule a callback to attempt replaying as many events as are
503 // now unblocked. This first might not actually be unblocked yet.
504 // We could check it early to avoid scheduling an unnecessary callback.
505 scheduleCallback(NormalPriority, replayUnblockedEvents);
506 }
507 }
508 }
509 }
510
511 type FormAction = FormData => void | Promise<void>;
512
513 type FormReplayingQueue = Array<any>; // [form, submitter or action, formData...]
514
515 let lastScheduledReplayQueue: null | FormReplayingQueue = null;
516
517 function replayUnblockedFormActions(formReplayingQueue: FormReplayingQueue) {
518 if (lastScheduledReplayQueue === formReplayingQueue) {
519 lastScheduledReplayQueue = null;
520 }
521 for (let i = 0; i < formReplayingQueue.length; i += 3) {
522 const form: HTMLFormElement = formReplayingQueue[i];
523 const submitterOrAction:
524 | null
525 | HTMLInputElement
526 | HTMLButtonElement
527 | FormAction = formReplayingQueue[i + 1];
528 const formData: FormData = formReplayingQueue[i + 2];
529 if (typeof submitterOrAction !== 'function') {
530 // This action is not hydrated yet. This might be because it's blocked on
531 // a different React instance or higher up our tree.
532 const blockedOn = findInstanceBlockingTarget(submitterOrAction || form);
533 if (blockedOn === null) {
534 // We're not blocked but we don't have an action. This must mean that
535 // this is in another React instance. We'll just skip past it.
536 continue;
537 } else {
538 // We're blocked on something in this React instance. We'll retry later.
539 break;
540 }
541 }
542 const formInst = getInstanceFromNode(form);
543 if (formInst !== null) {
544 // This is part of our instance.
545 // We're ready to replay this. Let's delete it from the queue.
546 formReplayingQueue.splice(i, 3);
547 i -= 3;
548 dispatchReplayedFormAction(formInst, form, submitterOrAction, formData);
549 // Continue without incrementing the index.
550 continue;
551 }
552 // This form must've been part of a different React instance.
553 // If we want to preserve ordering between React instances on the same root
554 // we'd need some way for the other instance to ping us when it's done.
555 // We'll just skip this and let the other instance execute it.
556 }
557 }
558
559 function scheduleReplayQueueIfNeeded(formReplayingQueue: FormReplayingQueue) {
560 // Schedule a callback to execute any unblocked form actions in.
561 // We only keep track of the last queue which means that if multiple React oscillate
562 // commits, we could schedule more callbacks than necessary but it's not a big deal
563 // and we only really except one instance.
564 if (lastScheduledReplayQueue !== formReplayingQueue) {
565 lastScheduledReplayQueue = formReplayingQueue;
566 scheduleCallback(NormalPriority, () =>
567 replayUnblockedFormActions(formReplayingQueue),
568 );
569 }
570 }
571
572 export function retryIfBlockedOn(
573 unblocked: Container | SuspenseInstance | ActivityInstance,
574 ): void {
575 if (queuedFocus !== null) {
576 scheduleCallbackIfUnblocked(queuedFocus, unblocked);
577 }
578 if (queuedDrag !== null) {
579 scheduleCallbackIfUnblocked(queuedDrag, unblocked);
580 }
581 if (queuedMouse !== null) {
582 scheduleCallbackIfUnblocked(queuedMouse, unblocked);
583 }
584 const unblock = (queuedEvent: QueuedReplayableEvent) =>
585 scheduleCallbackIfUnblocked(queuedEvent, unblocked);
586 queuedPointers.forEach(unblock);
587 queuedPointerCaptures.forEach(unblock);
588
589 for (let i = 0; i < queuedExplicitHydrationTargets.length; i++) {
590 const queuedTarget = queuedExplicitHydrationTargets[i];
591 if (queuedTarget.blockedOn === unblocked) {
592 queuedTarget.blockedOn = null;
593 }
594 }
595
596 while (queuedExplicitHydrationTargets.length > 0) {
597 const nextExplicitTarget = queuedExplicitHydrationTargets[0];
598 if (nextExplicitTarget.blockedOn !== null) {
599 // We're still blocked.
600 break;
601 } else {
602 attemptExplicitHydrationTarget(nextExplicitTarget);
603 if (nextExplicitTarget.blockedOn === null) {
604 // We're unblocked.
605 queuedExplicitHydrationTargets.shift();
606 }
607 }
608 }
609
610 // Check the document if there are any queued form actions.
611 // If there's no ownerDocument, then this is the document.
612 const root = unblocked.ownerDocument || unblocked;
613 const formReplayingQueue: void | FormReplayingQueue = (root as any)
614 .$$reactFormReplay;
615 if (formReplayingQueue != null) {
616 for (let i = 0; i < formReplayingQueue.length; i += 3) {
617 const form: HTMLFormElement = formReplayingQueue[i];
618 const submitterOrAction:
619 | null
620 | HTMLInputElement
621 | HTMLButtonElement
622 | FormAction = formReplayingQueue[i + 1];
623 const formProps = getFiberCurrentPropsFromNode(form);
624 if (typeof submitterOrAction === 'function') {
625 // This action has already resolved. We're just waiting to dispatch it.
626 if (!formProps) {
627 // This was not part of this React instance. It might have been recently
628 // unblocking us from dispatching our events. So let's make sure we schedule
629 // a retry.
630 scheduleReplayQueueIfNeeded(formReplayingQueue);
631 }
632 continue;
633 }
634 let target: Node = form;
635 if (formProps) {
636 // This form belongs to this React instance but the submitter might
637 // not be done yet.
638 let action: null | FormAction = null;
639 const submitter = submitterOrAction;
640 if (submitter && submitter.hasAttribute('formAction')) {
641 // The submitter is the one that is responsible for the action.
642 target = submitter;
643 const submitterProps = getFiberCurrentPropsFromNode(submitter);
644 if (submitterProps) {
645 // The submitter is part of this instance.
646 action = (submitterProps as any).formAction;
647 } else {
648 const blockedOn = findInstanceBlockingTarget(target);
649 if (blockedOn !== null) {
650 // The submitter is not hydrated yet. We'll wait for it.
651 continue;
652 }
653 // The submitter must have been a part of a different React instance.
654 // Except the form isn't. We don't dispatch actions in this scenario.
655 }
656 } else {
657 action = (formProps as any).action;
658 }
659 if (typeof action === 'function') {
660 formReplayingQueue[i + 1] = action;
661 } else {
662 // Something went wrong so let's just delete this action.
663 formReplayingQueue.splice(i, 3);
664 i -= 3;
665 }
666 // Schedule a replay in case this unblocked something.
667 scheduleReplayQueueIfNeeded(formReplayingQueue);
668 continue;
669 }
670 // Something above this target is still blocked so we can't continue yet.
671 // We're not sure if this target is actually part of this React instance
672 // yet. It could be a different React as a child but at least some parent is.
673 // We must continue for any further queued actions.
674 }
675 }
676 }