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."
}