diff --git a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js index 3adf300f9e709..9c434ed5d0952 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js @@ -684,7 +684,6 @@ describe('ReactDOMServerSelectiveHydration', () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); - function Child({text}) { if ((text === 'A' || text === 'D') && suspend) { throw promise; @@ -724,11 +723,8 @@ describe('ReactDOMServerSelectiveHydration', () => { ); } - const finalHTML = ReactDOMServer.renderToString(); - expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']); - const container = document.createElement('div'); // We need this to be in the document since we'll dispatch events on it. document.body.appendChild(container); @@ -746,6 +742,156 @@ describe('ReactDOMServerSelectiveHydration', () => { const root = ReactDOM.createRoot(container, {hydrate: true}); root.render(); + // Nothing has been hydrated so far. + expect(Scheduler).toHaveYielded([]); + // Click D + dispatchMouseHoverEvent(spanD, null); + dispatchClickEvent(spanD); + // Hover over B and then C. + dispatchMouseHoverEvent(spanB, spanD); + dispatchMouseHoverEvent(spanC, spanB); + expect(Scheduler).toHaveYielded(['App']); + await act(async () => { + suspend = false; + resolve(); + await promise; + }); + if ( + gate( + flags => + flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + ) + ) { + // We should prioritize hydrating D first because we clicked it. + // but event isnt replayed + expect(Scheduler).toHaveYielded([ + 'D', + 'B', // Ideally this should be later. + 'C', + 'Hover C', + 'A', + ]); + } else { + // We should prioritize hydrating D first because we clicked it. + // Next we should hydrate C since that's the current hover target. + // To simplify implementation details we hydrate both B and C at + // the same time since B was already scheduled. + // This is ok because it will at least not continue for nested + // boundary. See the next test below. + expect(Scheduler).toHaveYielded([ + 'D', + 'Clicked D', + 'B', // Ideally this should be later. + 'C', + 'Hover C', + 'A', + ]); + } + + document.body.removeChild(container); + }); + + it('replays capture phase for continuous events and respects stopPropagation', async () => { + let suspend = false; + let resolve; + const promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + function Child({text}) { + if ((text === 'A' || text === 'D') && suspend) { + throw promise; + } + Scheduler.unstable_yieldValue(text); + return ( + { + e.preventDefault(); + Scheduler.unstable_yieldValue('Capture Clicked ' + text); + }} + onClick={e => { + e.preventDefault(); + Scheduler.unstable_yieldValue('Clicked ' + text); + }} + onMouseEnter={e => { + e.preventDefault(); + Scheduler.unstable_yieldValue('Mouse Enter ' + text); + }} + onMouseOut={e => { + e.preventDefault(); + Scheduler.unstable_yieldValue('Mouse Out ' + text); + }} + onMouseOutCapture={e => { + e.preventDefault(); + e.stopPropagation(); + Scheduler.unstable_yieldValue('Mouse Out Capture ' + text); + }} + onMouseOverCapture={e => { + e.preventDefault(); + e.stopPropagation(); + Scheduler.unstable_yieldValue('Mouse Over Capture ' + text); + }} + onMouseOver={e => { + e.preventDefault(); + Scheduler.unstable_yieldValue('Mouse Over ' + text); + }}> +
{ + e.preventDefault(); + Scheduler.unstable_yieldValue('Mouse Over Capture Inner ' + text); + }}> + {text} +
+
+ ); + } + + function App() { + Scheduler.unstable_yieldValue('App'); + return ( +
{ + e.preventDefault(); + Scheduler.unstable_yieldValue('Capture Clicked Parent'); + }} + onMouseOverCapture={e => { + Scheduler.unstable_yieldValue('Mouse Over Capture Parent'); + }}> + + + + + + + + + + + + +
+ ); + } + + const finalHTML = ReactDOMServer.renderToString(); + + expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']); + + const container = document.createElement('div'); + // We need this to be in the document since we'll dispatch events on it. + document.body.appendChild(container); + + container.innerHTML = finalHTML; + + const spanB = document.getElementById('B').firstChild; + const spanC = document.getElementById('C').firstChild; + const spanD = document.getElementById('D').firstChild; + + suspend = true; + + // A and D will be suspended. We'll click on D which should take + // priority, after we unsuspend. + ReactDOM.hydrateRoot(container, ); + // Nothing has been hydrated so far. expect(Scheduler).toHaveYielded([]); @@ -776,7 +922,14 @@ describe('ReactDOMServerSelectiveHydration', () => { 'D', 'B', // Ideally this should be later. 'C', - 'Hover C', + // Mouse out events aren't replayed + // 'Mouse Out Capture B', + // 'Mouse Out B', + 'Mouse Over Capture Parent', + 'Mouse Over Capture C', + // Stop propagation stops these + // 'Mouse Over Capture Inner C', + // 'Mouse Over C', 'A', ]); } else { @@ -791,11 +944,375 @@ describe('ReactDOMServerSelectiveHydration', () => { 'Clicked D', 'B', // Ideally this should be later. 'C', - 'Hover C', + // Capture phase isn't replayed + // Mouseout isn't replayed + 'Mouse Over C', + 'Mouse Enter C', 'A', ]); } + // This test shows existing quirk where stopPropagation on mouseout + // prevents mouseEnter from firing + dispatchMouseHoverEvent(spanC, spanB); + expect(Scheduler).toHaveYielded([ + 'Mouse Out Capture B', + // stopPropagation stops these + // 'Mouse Out B', + // 'Mouse Enter C', + 'Mouse Over Capture Parent', + 'Mouse Over Capture C', + // Stop propagation stops these + // 'Mouse Over Capture Inner C', + // 'Mouse Over C', + ]); + + document.body.removeChild(container); + }); + + describe('can handle replaying events as part of multiple instances of React', () => { + let resolveInner; + let resolveOuter; + let innerPromise; + let outerPromise; + let OuterScheduler; + let InnerScheduler; + let innerDiv; + + beforeEach(async () => { + document.body.innerHTML = ''; + jest.resetModuleRegistry(); + let OuterReactDOM; + let InnerReactDOM; + jest.isolateModules(() => { + OuterReactDOM = require('react-dom'); + OuterScheduler = require('scheduler'); + }); + jest.isolateModules(() => { + InnerReactDOM = require('react-dom'); + InnerScheduler = require('scheduler'); + }); + + expect(OuterReactDOM).not.toBe(InnerReactDOM); + expect(OuterScheduler).not.toBe(InnerScheduler); + + const outerContainer = document.createElement('div'); + const innerContainer = document.createElement('div'); + + let suspendOuter = false; + outerPromise = new Promise(res => { + resolveOuter = () => { + suspendOuter = false; + res(); + }; + }); + + function Outer() { + if (suspendOuter) { + OuterScheduler.unstable_yieldValue('Suspend Outer'); + throw outerPromise; + } + OuterScheduler.unstable_yieldValue('Outer'); + const innerRoot = outerContainer.querySelector('#inner-root'); + return ( +
{ + Scheduler.unstable_yieldValue('Outer Mouse Enter'); + }} + dangerouslySetInnerHTML={{ + __html: innerRoot ? innerRoot.innerHTML : '', + }} + /> + ); + } + const OuterApp = () => { + return ( + Loading
}> + + + ); + }; + + let suspendInner = false; + innerPromise = new Promise(res => { + resolveInner = () => { + suspendInner = false; + res(); + }; + }); + function Inner() { + if (suspendInner) { + InnerScheduler.unstable_yieldValue('Suspend Inner'); + throw innerPromise; + } + InnerScheduler.unstable_yieldValue('Inner'); + return ( +
{ + Scheduler.unstable_yieldValue('Inner Mouse Enter'); + }} + /> + ); + } + const InnerApp = () => { + return ( + Loading
}> + + + ); + }; + + document.body.appendChild(outerContainer); + const outerHTML = ReactDOMServer.renderToString(); + outerContainer.innerHTML = outerHTML; + + const innerWrapper = document.querySelector('#inner-root'); + innerWrapper.appendChild(innerContainer); + const innerHTML = ReactDOMServer.renderToString(); + innerContainer.innerHTML = innerHTML; + + expect(OuterScheduler).toHaveYielded(['Outer']); + expect(InnerScheduler).toHaveYielded(['Inner']); + + suspendOuter = true; + suspendInner = true; + + OuterReactDOM.createRoot(outerContainer, {hydrate: true}).render( + , + ); + InnerReactDOM.hydrateRoot(innerContainer, {hydrate: true}).render( + , + ); + + expect(OuterScheduler).toFlushAndYield(['Suspend Outer']); + expect(InnerScheduler).toFlushAndYield(['Suspend Inner']); + + innerDiv = document.querySelector('#inner'); + + dispatchClickEvent(innerDiv); + + await act(async () => { + jest.runAllTimers(); + Scheduler.unstable_flushAllWithoutAsserting(); + OuterScheduler.unstable_flushAllWithoutAsserting(); + InnerScheduler.unstable_flushAllWithoutAsserting(); + }); + + expect(OuterScheduler).toHaveYielded(['Suspend Outer']); + if ( + gate( + flags => + flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + ) + ) { + // InnerApp doesn't see the event because OuterApp calls stopPropagation in + // capture phase since the event is blocked on suspended component + expect(InnerScheduler).toHaveYielded([]); + } else { + // no stopPropagation + expect(InnerScheduler).toHaveYielded(['Suspend Inner']); + } + + expect(Scheduler).toHaveYielded([]); + }); + afterEach(async () => { + document.body.innerHTML = ''; + }); + + // @gate enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay + it('Inner hydrates first then Outer', async () => { + dispatchMouseHoverEvent(innerDiv); + + await act(async () => { + resolveInner(); + await innerPromise; + jest.runAllTimers(); + Scheduler.unstable_flushAllWithoutAsserting(); + OuterScheduler.unstable_flushAllWithoutAsserting(); + InnerScheduler.unstable_flushAllWithoutAsserting(); + }); + + expect(OuterScheduler).toHaveYielded(['Suspend Outer']); + // Inner App renders because it is unblocked + expect(InnerScheduler).toHaveYielded(['Inner']); + // No event is replayed yet + expect(Scheduler).toHaveYielded([]); + + dispatchMouseHoverEvent(innerDiv); + expect(OuterScheduler).toHaveYielded([]); + expect(InnerScheduler).toHaveYielded([]); + // No event is replayed yet + expect(Scheduler).toHaveYielded([]); + + await act(async () => { + resolveOuter(); + await outerPromise; + jest.runAllTimers(); + Scheduler.unstable_flushAllWithoutAsserting(); + OuterScheduler.unstable_flushAllWithoutAsserting(); + InnerScheduler.unstable_flushAllWithoutAsserting(); + }); + + // Nothing happens to inner app yet. + // Its blocked on the outer app replaying the event + expect(InnerScheduler).toHaveYielded([]); + // Outer hydrates and schedules Replay + expect(OuterScheduler).toHaveYielded(['Outer']); + // No event is replayed yet + expect(Scheduler).toHaveYielded([]); + + // fire scheduled Replay + await act(async () => { + jest.runAllTimers(); + Scheduler.unstable_flushAllWithoutAsserting(); + OuterScheduler.unstable_flushAllWithoutAsserting(); + InnerScheduler.unstable_flushAllWithoutAsserting(); + }); + + // First Inner Mouse Enter fires then Outer Mouse Enter + expect(Scheduler).toHaveYielded([ + 'Inner Mouse Enter', + 'Outer Mouse Enter', + ]); + }); + + // @gate enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay + it('Outer hydrates first then Inner', async () => { + dispatchMouseHoverEvent(innerDiv); + + await act(async () => { + resolveOuter(); + await outerPromise; + Scheduler.unstable_flushAllWithoutAsserting(); + OuterScheduler.unstable_flushAllWithoutAsserting(); + InnerScheduler.unstable_flushAllWithoutAsserting(); + }); + + // Outer resolves and scheduled replay + expect(OuterScheduler).toHaveYielded(['Outer']); + // Inner App is still blocked + expect(InnerScheduler).toHaveYielded([]); + + // Replay outer event + await act(async () => { + Scheduler.unstable_flushAllWithoutAsserting(); + OuterScheduler.unstable_flushAllWithoutAsserting(); + InnerScheduler.unstable_flushAllWithoutAsserting(); + }); + + // Inner is still blocked so when Outer replays the event in capture phase + // inner ends up caling stopPropagation + expect(Scheduler).toHaveYielded([]); + expect(OuterScheduler).toHaveYielded([]); + expect(InnerScheduler).toHaveYielded(['Suspend Inner']); + + dispatchMouseHoverEvent(innerDiv); + expect(OuterScheduler).toHaveYielded([]); + expect(InnerScheduler).toHaveYielded([]); + expect(Scheduler).toHaveYielded([]); + + await act(async () => { + resolveInner(); + await innerPromise; + Scheduler.unstable_flushAllWithoutAsserting(); + OuterScheduler.unstable_flushAllWithoutAsserting(); + InnerScheduler.unstable_flushAllWithoutAsserting(); + }); + + // Inner hydrates + expect(InnerScheduler).toHaveYielded(['Inner']); + // Outer was hydrated earlier + expect(OuterScheduler).toHaveYielded([]); + + await act(async () => { + Scheduler.unstable_flushAllWithoutAsserting(); + OuterScheduler.unstable_flushAllWithoutAsserting(); + InnerScheduler.unstable_flushAllWithoutAsserting(); + }); + + // First Inner Mouse Enter fires then Outer Mouse Enter + expect(Scheduler).toHaveYielded([ + 'Inner Mouse Enter', + 'Outer Mouse Enter', + ]); + }); + }); + + // @gate enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay + it('replays event with null target when tree is dismounted', async () => { + let suspend = false; + let resolve; + const promise = new Promise(resolvePromise => { + resolve = () => { + suspend = false; + resolvePromise(); + }; + }); + + function Child() { + if (suspend) { + throw promise; + } + Scheduler.unstable_yieldValue('Child'); + return ( +
{ + Scheduler.unstable_yieldValue('on mouse over'); + }}> + Child +
+ ); + } + + function App() { + return ( + + + + ); + } + + const finalHTML = ReactDOMServer.renderToString(); + expect(Scheduler).toHaveYielded(['Child']); + + const container = document.createElement('div'); + + document.body.appendChild(container); + container.innerHTML = finalHTML; + suspend = true; + + ReactDOM.hydrateRoot(container, ); + + const childDiv = container.firstElementChild; + dispatchMouseHoverEvent(childDiv); + + // Not hydrated so event is saved for replay and stopPropagation is called + expect(Scheduler).toHaveYielded([]); + + resolve(); + Scheduler.unstable_flushNumberOfYields(1); + expect(Scheduler).toHaveYielded(['Child']); + + Scheduler.unstable_scheduleCallback( + Scheduler.unstable_ImmediatePriority, + () => { + container.removeChild(childDiv); + + const container2 = document.createElement('div'); + container2.addEventListener('mouseover', () => { + Scheduler.unstable_yieldValue('container2 mouse over'); + }); + container2.appendChild(childDiv); + }, + ); + Scheduler.unstable_flushAllWithoutAsserting(); + + // Even though the tree is remove the event is still dispatched with native event handler + // on the container firing. + expect(Scheduler).toHaveYielded(['container2 mouse over']); + document.body.removeChild(container); }); diff --git a/packages/react-dom/src/events/DOMPluginEventSystem.js b/packages/react-dom/src/events/DOMPluginEventSystem.js index 7f7f77183726a..be180d5b730d8 100644 --- a/packages/react-dom/src/events/DOMPluginEventSystem.js +++ b/packages/react-dom/src/events/DOMPluginEventSystem.js @@ -70,6 +70,7 @@ import * as ChangeEventPlugin from './plugins/ChangeEventPlugin'; import * as EnterLeaveEventPlugin from './plugins/EnterLeaveEventPlugin'; import * as SelectEventPlugin from './plugins/SelectEventPlugin'; import * as SimpleEventPlugin from './plugins/SimpleEventPlugin'; +import {isReplayingEvent} from './replayedEvent'; type DispatchListener = {| instance: null | Fiber, @@ -557,7 +558,8 @@ export function dispatchEventForPluginEventSystem( // for legacy FB support, where the expected behavior was to // match React < 16 behavior of delegated clicks to the doc. domEventName === 'click' && - (eventSystemFlags & SHOULD_NOT_DEFER_CLICK_FOR_FB_SUPPORT_MODE) === 0 + (eventSystemFlags & SHOULD_NOT_DEFER_CLICK_FOR_FB_SUPPORT_MODE) === 0 && + !isReplayingEvent(nativeEvent) ) { deferClickToDocumentForLegacyFBSupport(domEventName, targetContainer); return; diff --git a/packages/react-dom/src/events/EventSystemFlags.js b/packages/react-dom/src/events/EventSystemFlags.js index 150a73d35ded4..2e2600dfb5062 100644 --- a/packages/react-dom/src/events/EventSystemFlags.js +++ b/packages/react-dom/src/events/EventSystemFlags.js @@ -13,11 +13,10 @@ export const IS_EVENT_HANDLE_NON_MANAGED_NODE = 1; export const IS_NON_DELEGATED = 1 << 1; export const IS_CAPTURE_PHASE = 1 << 2; export const IS_PASSIVE = 1 << 3; -export const IS_REPLAYED = 1 << 4; -export const IS_LEGACY_FB_SUPPORT_MODE = 1 << 5; +export const IS_LEGACY_FB_SUPPORT_MODE = 1 << 4; export const SHOULD_NOT_DEFER_CLICK_FOR_FB_SUPPORT_MODE = - IS_LEGACY_FB_SUPPORT_MODE | IS_REPLAYED | IS_CAPTURE_PHASE; + IS_LEGACY_FB_SUPPORT_MODE | IS_CAPTURE_PHASE; // We do not want to defer if the event system has already been // set to LEGACY_FB_SUPPORT. LEGACY_FB_SUPPORT only gets set when diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index 86924e7c2673d..756e0cc894438 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -11,8 +11,11 @@ import type {AnyNativeEvent} from '../events/PluginModuleType'; import type {FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; import type {Container, SuspenseInstance} from '../client/ReactDOMHostConfig'; import type {DOMEventName} from '../events/DOMEventNames'; +import type {NullTarget} from './ReactDOMEventReplaying'; import {enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay} from 'shared/ReactFeatureFlags'; import { + nullTarget, + isBlocked, isDiscreteEventThatRequiresHydration, queueDiscreteEvent, hasQueuedDiscreteEvents, @@ -155,13 +158,128 @@ export function dispatchEvent( return; } + if (enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay) { + dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay( + domEventName, + eventSystemFlags, + targetContainer, + nativeEvent, + ); + } else { + dispatchEventOriginal( + domEventName, + eventSystemFlags, + targetContainer, + nativeEvent, + ); + } +} + +function dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay( + domEventName: DOMEventName, + eventSystemFlags: EventSystemFlags, + targetContainer: EventTarget, + nativeEvent: AnyNativeEvent, +) { + let blockedOn = findInstanceBlockingEvent( + domEventName, + eventSystemFlags, + targetContainer, + nativeEvent, + ); + + // We can dispatch the event now + let blockedOnInst = isBlocked(blockedOn); + if (!blockedOnInst) { + clearIfContinuousEvent(domEventName, nativeEvent); + dispatchEventForPluginEventSystem( + domEventName, + eventSystemFlags, + nativeEvent, + blockedOn === null + ? getClosestInstanceFromNode(getEventTarget(nativeEvent)) + : null, + targetContainer, + ); + return; + } + + if ( + queueIfContinuousEvent( + blockedOn, + domEventName, + eventSystemFlags, + targetContainer, + nativeEvent, + ) + ) { + nativeEvent.stopPropagation(); + return; + } + // We need to clear only if we didn't queue because + // queueing is accumulative. + clearIfContinuousEvent(domEventName, nativeEvent); + + if ( + eventSystemFlags & IS_CAPTURE_PHASE && + isDiscreteEventThatRequiresHydration(domEventName) + ) { + while (blockedOnInst) { + const fiber = getInstanceFromNode(blockedOnInst); + if (fiber !== null) { + attemptSynchronousHydration(fiber); + } + const nextBlockedOn = findInstanceBlockingEvent( + domEventName, + eventSystemFlags, + targetContainer, + nativeEvent, + ); + if (nextBlockedOn === blockedOn) { + break; + } + blockedOn = nextBlockedOn; + blockedOnInst = isBlocked(blockedOn); + } + if (blockedOnInst) { + nativeEvent.stopPropagation(); + return; + } + dispatchEventForPluginEventSystem( + domEventName, + eventSystemFlags, + nativeEvent, + blockedOn === null + ? getClosestInstanceFromNode(getEventTarget(nativeEvent)) + : null, + targetContainer, + ); + return; + } + + // This is not replayable so we'll invoke it but without a target, + // in case the event system needs to trace it. + dispatchEventForPluginEventSystem( + domEventName, + eventSystemFlags, + nativeEvent, + null, + targetContainer, + ); +} + +function dispatchEventOriginal( + domEventName: DOMEventName, + eventSystemFlags: EventSystemFlags, + targetContainer: EventTarget, + nativeEvent: AnyNativeEvent, +) { // TODO: replaying capture phase events is currently broken // because we used to do it during top-level native bubble handlers // but now we use different bubble and capture handlers. // In eager mode, we attach capture listeners early, so we need // to filter them out until we fix the logic to handle them correctly. const allowReplay = (eventSystemFlags & IS_CAPTURE_PHASE) === 0; - if ( allowReplay && hasQueuedDiscreteEvents() && @@ -180,14 +298,23 @@ export function dispatchEvent( return; } - let blockedOn = attemptToDispatchEvent( + const blockedOn = findInstanceBlockingEvent( domEventName, eventSystemFlags, targetContainer, nativeEvent, ); - - if (blockedOn === null) { + const blockedOnInst = isBlocked(blockedOn); + if (!blockedOnInst) { + dispatchEventForPluginEventSystem( + domEventName, + eventSystemFlags, + nativeEvent, + blockedOn === null + ? getClosestInstanceFromNode(getEventTarget(nativeEvent)) + : null, + targetContainer, + ); // We successfully dispatched this event. if (allowReplay) { clearIfContinuousEvent(domEventName, nativeEvent); @@ -196,10 +323,7 @@ export function dispatchEvent( } if (allowReplay) { - if ( - !enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay && - isDiscreteEventThatRequiresHydration(domEventName) - ) { + if (isDiscreteEventThatRequiresHydration(domEventName)) { // This this to be replayed later once the target is available. queueDiscreteEvent( blockedOn, @@ -226,33 +350,6 @@ export function dispatchEvent( clearIfContinuousEvent(domEventName, nativeEvent); } - if ( - enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay && - eventSystemFlags & IS_CAPTURE_PHASE && - isDiscreteEventThatRequiresHydration(domEventName) - ) { - while (blockedOn !== null) { - const fiber = getInstanceFromNode(blockedOn); - if (fiber !== null) { - attemptSynchronousHydration(fiber); - } - const nextBlockedOn = attemptToDispatchEvent( - domEventName, - eventSystemFlags, - targetContainer, - nativeEvent, - ); - if (nextBlockedOn === blockedOn) { - break; - } - blockedOn = nextBlockedOn; - } - if (blockedOn) { - nativeEvent.stopPropagation(); - return; - } - } - // This is not replayable so we'll invoke it but without a target, // in case the event system needs to trace it. dispatchEventForPluginEventSystem( @@ -264,38 +361,35 @@ export function dispatchEvent( ); } -// Attempt dispatching an event. Returns a SuspenseInstance or Container if it's blocked. -export function attemptToDispatchEvent( +// Returns a SuspenseInstance or Container if it's blocked. +// Returns null if not blocked and we should use closestInstance +// Returns nullTarget if not blocked but we should dispatch without a targetInst +export function findInstanceBlockingEvent( domEventName: DOMEventName, eventSystemFlags: EventSystemFlags, targetContainer: EventTarget, nativeEvent: AnyNativeEvent, -): null | Container | SuspenseInstance { +): NullTarget | null | Container | SuspenseInstance { // TODO: Warn if _enabled is false. const nativeEventTarget = getEventTarget(nativeEvent); - let targetInst = getClosestInstanceFromNode(nativeEventTarget); + const targetInst = getClosestInstanceFromNode(nativeEventTarget); if (targetInst !== null) { const nearestMounted = getNearestMountedFiber(targetInst); - if (nearestMounted === null) { - // This tree has been unmounted already. Dispatch without a target. - targetInst = null; - } else { + if (nearestMounted !== null) { const tag = nearestMounted.tag; if (tag === SuspenseComponent) { const instance = getSuspenseInstanceFromFiber(nearestMounted); if (instance !== null) { // Queue the event to be replayed later. Abort dispatching since we // don't want this event dispatched twice through the event system. - // TODO: If this is the first discrete event in the queue. Schedule an increased - // priority for this boundary. return instance; } // This shouldn't happen, something went wrong but to avoid blocking // the whole system, dispatch the event without a target. // TODO: Warn. - targetInst = null; + return nullTarget; } else if (tag === HostRoot) { const root: FiberRoot = nearestMounted.stateNode; if (root.isDehydrated) { @@ -303,23 +397,19 @@ export function attemptToDispatchEvent( // the whole system. return getContainerFromFiber(nearestMounted); } - targetInst = null; + return nullTarget; } else if (nearestMounted !== targetInst) { // If we get an event (ex: img onload) before committing that // component's mount, ignore it for now (that is, treat it as if it was an // event on a non-React tree). We might also consider queueing events and // dispatching them after the mount. - targetInst = null; + return nullTarget; } + } else { + // This tree has been unmounted already. Dispatch without a target. + return nullTarget; } } - dispatchEventForPluginEventSystem( - domEventName, - eventSystemFlags, - nativeEvent, - targetInst, - targetContainer, - ); // We're not blocked on anything. return null; } diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index ed31f20e51185..834862643fc13 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -13,6 +13,9 @@ import type {DOMEventName} from '../events/DOMEventNames'; import type {EventSystemFlags} from './EventSystemFlags'; import type {FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; import type {EventPriority} from 'react-reconciler/src/ReactEventPriorities'; +import {dispatchEventForPluginEventSystem} from './DOMPluginEventSystem'; +import getEventTarget from './getEventTarget'; +import {isReplayingEvent, replayEventWrapper} from './replayedEvent'; import { enableSelectiveHydration, @@ -27,7 +30,7 @@ import { getContainerFromFiber, getSuspenseInstanceFromFiber, } from 'react-reconciler/src/ReactFiberTreeReflection'; -import {attemptToDispatchEvent} from './ReactDOMEventListener'; +import {findInstanceBlockingEvent} from './ReactDOMEventListener'; import { getInstanceFromNode, getClosestInstanceFromNode, @@ -87,10 +90,18 @@ type PointerEvent = Event & { ... }; -import {IS_REPLAYED} from './EventSystemFlags'; +declare export class NullTarget {} +export const nullTarget: NullTarget = ({}: any); +export function isBlocked( + blockedOn: NullTarget | null | Container | SuspenseInstance, +): Container | SuspenseInstance | void { + if (blockedOn !== nullTarget && blockedOn !== null) { + return ((blockedOn: any): Container | SuspenseInstance); + } +} type QueuedReplayableEvent = {| - blockedOn: null | Container | SuspenseInstance, + blockedOn: NullTarget | null | Container | SuspenseInstance, domEventName: DOMEventName, eventSystemFlags: EventSystemFlags, nativeEvent: AnyNativeEvent, @@ -115,7 +126,7 @@ const queuedPointerCaptures: Map = new Map(); // We could consider replaying selectionchange and touchmoves too. type QueuedHydrationTarget = {| - blockedOn: null | Container | SuspenseInstance, + blockedOn: NullTarget | null | Container | SuspenseInstance, target: Node, priority: EventPriority, |}; @@ -167,7 +178,7 @@ export function isDiscreteEventThatRequiresHydration( } function createQueuedReplayableEvent( - blockedOn: null | Container | SuspenseInstance, + blockedOn: NullTarget | null | Container | SuspenseInstance, domEventName: DOMEventName, eventSystemFlags: EventSystemFlags, targetContainer: EventTarget, @@ -176,14 +187,14 @@ function createQueuedReplayableEvent( return { blockedOn, domEventName, - eventSystemFlags: eventSystemFlags | IS_REPLAYED, + eventSystemFlags, nativeEvent, targetContainers: [targetContainer], }; } export function queueDiscreteEvent( - blockedOn: null | Container | SuspenseInstance, + blockedOn: NullTarget | null | Container | SuspenseInstance, domEventName: DOMEventName, eventSystemFlags: EventSystemFlags, targetContainer: EventTarget, @@ -204,13 +215,15 @@ export function queueDiscreteEvent( if (queuedDiscreteEvents.length === 1) { // If this was the first discrete event, we might be able to // synchronously unblock it so that preventDefault still works. - while (queuedEvent.blockedOn !== null) { - const fiber = getInstanceFromNode(queuedEvent.blockedOn); + let blockedOnInst; + while ((blockedOnInst = isBlocked(queuedEvent.blockedOn))) { + const fiber = getInstanceFromNode(blockedOnInst); if (fiber === null) { break; } attemptSynchronousHydration(fiber); - if (queuedEvent.blockedOn === null) { + blockedOnInst = isBlocked(queuedEvent.blockedOn); + if (!blockedOnInst) { // We got unblocked by hydration. Let's try again. replayUnblockedEvents(); // If we're reblocked, on an inner boundary, we might need @@ -261,7 +274,7 @@ export function clearIfContinuousEvent( function accumulateOrCreateContinuousQueuedReplayableEvent( existingQueuedEvent: null | QueuedReplayableEvent, - blockedOn: null | Container | SuspenseInstance, + blockedOn: NullTarget | null | Container | SuspenseInstance, domEventName: DOMEventName, eventSystemFlags: EventSystemFlags, targetContainer: EventTarget, @@ -278,8 +291,9 @@ function accumulateOrCreateContinuousQueuedReplayableEvent( targetContainer, nativeEvent, ); - if (blockedOn !== null) { - const fiber = getInstanceFromNode(blockedOn); + const blockedOnInst = isBlocked(blockedOn); + if (blockedOnInst) { + const fiber = getInstanceFromNode(blockedOnInst); if (fiber !== null) { // Attempt to increase the priority of this target. attemptContinuousHydration(fiber); @@ -303,7 +317,7 @@ function accumulateOrCreateContinuousQueuedReplayableEvent( } export function queueIfContinuousEvent( - blockedOn: null | Container | SuspenseInstance, + blockedOn: NullTarget | null | Container | SuspenseInstance, domEventName: DOMEventName, eventSystemFlags: EventSystemFlags, targetContainer: EventTarget, @@ -385,13 +399,9 @@ export function queueIfContinuousEvent( return false; } -// Check if this target is unblocked. Returns true if it's unblocked. function attemptExplicitHydrationTarget( queuedTarget: QueuedHydrationTarget, ): void { - // TODO: This function shares a lot of logic with attemptToDispatchEvent. - // Try to unify them. It's a bit tricky since it would require two return - // values. const targetInst = getClosestInstanceFromNode(queuedTarget.target); if (targetInst !== null) { const nearestMounted = getNearestMountedFiber(targetInst); @@ -456,27 +466,30 @@ export function queueExplicitHydrationTarget(target: Node): void { function attemptReplayContinuousQueuedEvent( queuedEvent: QueuedReplayableEvent, ): boolean { - if (queuedEvent.blockedOn !== null) { + const blockedOnInst = isBlocked(queuedEvent.blockedOn); + if (blockedOnInst) { return false; } const targetContainers = queuedEvent.targetContainers; while (targetContainers.length > 0) { const targetContainer = targetContainers[0]; - const nextBlockedOn = attemptToDispatchEvent( + const nextBlockedOn = findInstanceBlockingEvent( queuedEvent.domEventName, queuedEvent.eventSystemFlags, targetContainer, queuedEvent.nativeEvent, ); - if (nextBlockedOn !== null) { + const nextBlockedOnInst = isBlocked(nextBlockedOn); + if (nextBlockedOnInst) { // We're still blocked. Try again later. - const fiber = getInstanceFromNode(nextBlockedOn); + const fiber = getInstanceFromNode(nextBlockedOnInst); if (fiber !== null) { attemptContinuousHydration(fiber); } queuedEvent.blockedOn = nextBlockedOn; return false; } + replayEvent(queuedEvent, targetContainer, nextBlockedOn); // This target container was successfully dispatched. Try the next. targetContainers.shift(); } @@ -499,11 +512,13 @@ function replayUnblockedEvents() { // First replay discrete events. while (queuedDiscreteEvents.length > 0) { const nextDiscreteEvent = queuedDiscreteEvents[0]; - if (nextDiscreteEvent.blockedOn !== null) { + const blockedOn = nextDiscreteEvent.blockedOn; + const blockedOnInst = isBlocked(blockedOn); + if (blockedOnInst) { // We're still blocked. // Increase the priority of this boundary to unblock // the next discrete event. - const fiber = getInstanceFromNode(nextDiscreteEvent.blockedOn); + const fiber = getInstanceFromNode(blockedOnInst); if (fiber !== null) { attemptDiscreteHydration(fiber); } @@ -512,21 +527,26 @@ function replayUnblockedEvents() { const targetContainers = nextDiscreteEvent.targetContainers; while (targetContainers.length > 0) { const targetContainer = targetContainers[0]; - const nextBlockedOn = attemptToDispatchEvent( + const nextBlockedOn = findInstanceBlockingEvent( nextDiscreteEvent.domEventName, nextDiscreteEvent.eventSystemFlags, targetContainer, nextDiscreteEvent.nativeEvent, ); - if (nextBlockedOn !== null) { + const nextBlockedOnInst = isBlocked(nextBlockedOn); + if (nextBlockedOnInst) { // We're still blocked. Try again later. - nextDiscreteEvent.blockedOn = nextBlockedOn; + nextDiscreteEvent.blockedOn = nextBlockedOnInst; break; } + replayEvent(nextDiscreteEvent, targetContainer, nextBlockedOn); // This target container was successfully dispatched. Try the next. targetContainers.shift(); } - if (nextDiscreteEvent.blockedOn === null) { + if ( + nextDiscreteEvent.blockedOn === null || + nextDiscreteEvent.blockedOn === nullTarget + ) { // We've successfully replayed the first event. Let's try the next one. queuedDiscreteEvents.shift(); } @@ -603,15 +623,52 @@ export function retryIfBlockedOn( while (queuedExplicitHydrationTargets.length > 0) { const nextExplicitTarget = queuedExplicitHydrationTargets[0]; - if (nextExplicitTarget.blockedOn !== null) { + const blockedOnInst = isBlocked(nextExplicitTarget.blockedOn); + if (blockedOnInst) { // We're still blocked. break; } else { attemptExplicitHydrationTarget(nextExplicitTarget); - if (nextExplicitTarget.blockedOn === null) { + if (!isBlocked(nextExplicitTarget.blockedOn)) { // We're unblocked. queuedExplicitHydrationTargets.shift(); } } } } + +function replayEvent( + queuedEvent: QueuedReplayableEvent, + targetContainer: EventTarget, + targetInst: null | NullTarget | Container | SuspenseInstance, +) { + const event = queuedEvent.nativeEvent; + if (isReplayingEvent(event)) { + // If an event reaches this codepath then it was recently unblocked. + // If the event is unblocked then it should never hit this codepath again after + // the initial unblocking since we'll just dispatch it directly without queueing it + // for replay. + throw new Error( + 'Attempting to replay event that is already replaying. ' + + 'This should never happen. This is a bug in React.', + ); + } + if (enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay) { + const eventClone = new event.constructor(event.type, (event: any)); + replayEventWrapper(eventClone, () => { + event.target.dispatchEvent(eventClone); + }); + } else { + replayEventWrapper(event, () => { + dispatchEventForPluginEventSystem( + queuedEvent.domEventName, + queuedEvent.eventSystemFlags, + event, + targetInst === nullTarget + ? null + : getClosestInstanceFromNode(getEventTarget(queuedEvent.nativeEvent)), + targetContainer, + ); + }); + } +} diff --git a/packages/react-dom/src/events/__tests__/DOMPluginEventSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMPluginEventSystem-test.internal.js index 1b2bfbb0bef4d..358a7ac9b2203 100644 --- a/packages/react-dom/src/events/__tests__/DOMPluginEventSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMPluginEventSystem-test.internal.js @@ -647,8 +647,7 @@ describe('DOMPluginEventSystem', () => { await promise; }); - // We're now full hydrated. - + // We're now fully hydrated. if ( gate( flags => diff --git a/packages/react-dom/src/events/plugins/EnterLeaveEventPlugin.js b/packages/react-dom/src/events/plugins/EnterLeaveEventPlugin.js index be5d94da806cc..44b1c00039722 100644 --- a/packages/react-dom/src/events/plugins/EnterLeaveEventPlugin.js +++ b/packages/react-dom/src/events/plugins/EnterLeaveEventPlugin.js @@ -13,7 +13,6 @@ import type {DispatchQueue} from '../DOMPluginEventSystem'; import type {EventSystemFlags} from '../EventSystemFlags'; import {registerDirectEvent} from '../EventRegistry'; -import {IS_REPLAYED} from 'react-dom/src/events/EventSystemFlags'; import {SyntheticMouseEvent, SyntheticPointerEvent} from '../SyntheticEvent'; import { getClosestInstanceFromNode, @@ -22,6 +21,7 @@ import { } from '../../client/ReactDOMComponentTree'; import {accumulateEnterLeaveTwoPhaseListeners} from '../DOMPluginEventSystem'; import type {KnownReactSyntheticEvent} from '../ReactSyntheticEventType'; +import {isReplayingEvent} from '../replayedEvent'; import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags'; import {getNearestMountedFiber} from 'react-reconciler/src/ReactFiberTreeReflection'; @@ -54,11 +54,9 @@ function extractEvents( const isOutEvent = domEventName === 'mouseout' || domEventName === 'pointerout'; - if (isOverEvent && (eventSystemFlags & IS_REPLAYED) === 0) { + if (isOverEvent && !isReplayingEvent(nativeEvent)) { // If this is an over event with a target, we might have already dispatched - // the event in the out event of the other target. If this is replayed, - // then it's because we couldn't dispatch against this target previously - // so we have to do it now instead. + // the event in the out event of the other target. const related = (nativeEvent: any).relatedTarget || (nativeEvent: any).fromElement; if (related) { diff --git a/packages/react-dom/src/events/replayedEvent.js b/packages/react-dom/src/events/replayedEvent.js new file mode 100644 index 0000000000000..c7ab51ca4415f --- /dev/null +++ b/packages/react-dom/src/events/replayedEvent.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ +import type {AnyNativeEvent} from '../events/PluginModuleType'; + +const eventsReplaying = new Set(); + +// This exists to avoid circular dependency between ReactDOMEventReplaying +// and DOMPluginEventSystem +export function replayEventWrapper(event: AnyNativeEvent, replay: () => void) { + eventsReplaying.add(event); + replay(); + eventsReplaying.delete(event); +} + +export const isReplayingEvent = (event: AnyNativeEvent) => { + return eventsReplaying.has(event); +}; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 6703e2cd36539..9cb347958495b 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -403,5 +403,6 @@ "415": "Error parsing the data. It's probably an error code or network corruption.", "416": "This environment don't support binary chunks.", "417": "React currently only supports piping to one writable stream.", - "418": "An error occurred during hydration. The server HTML was replaced with client content" + "418": "An error occurred during hydration. The server HTML was replaced with client content", + "419": "Attempting to replay event that is already replaying. This should never happen. This is a bug in React." }