diff --git a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js
index 3adf300f9e709..6c4dafbf5c300 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,371 @@ 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.hydrateRoot(outerContainer, );
+ InnerReactDOM.hydrateRoot(innerContainer, );
+
+ 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/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js
index bfca2a9d2e7a8..f228090221ff0 100644
--- a/packages/react-dom/src/events/ReactDOMEventListener.js
+++ b/packages/react-dom/src/events/ReactDOMEventListener.js
@@ -267,31 +267,6 @@ function dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEve
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() &&
- isDiscreteEventThatRequiresHydration(domEventName)
- ) {
- // If we already have a queue of discrete events, and this is another discrete
- // event, then we can't dispatch it regardless of its target, since they
- // need to dispatch in order.
- queueDiscreteEvent(
- null, // Flags that we're not actually blocked on anything as far as we know.
- domEventName,
- eventSystemFlags,
- targetContainer,
- nativeEvent,
- );
- return;
- }
-
let blockedOn = findInstanceBlockingEvent(
domEventName,
eventSystemFlags,
@@ -306,28 +281,25 @@ function dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEve
return_targetInst,
targetContainer,
);
- if (allowReplay) {
- clearIfContinuousEvent(domEventName, nativeEvent);
- }
+ clearIfContinuousEvent(domEventName, nativeEvent);
return;
}
- if (allowReplay) {
- if (
- queueIfContinuousEvent(
- blockedOn,
- domEventName,
- eventSystemFlags,
- targetContainer,
- nativeEvent,
- )
- ) {
- return;
- }
- // We need to clear only if we didn't queue because
- // queueing is accumulative.
- clearIfContinuousEvent(domEventName, nativeEvent);
+ 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 &&
@@ -358,10 +330,10 @@ function dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEve
}
blockedOn = nextBlockedOn;
}
- if (blockedOn) {
+ if (blockedOn !== null) {
nativeEvent.stopPropagation();
- return;
}
+ return;
}
// This is not replayable so we'll invoke it but without a target,
diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js
index bfcc7a4596f41..744f5dfda9d9b 100644
--- a/packages/react-dom/src/events/ReactDOMEventReplaying.js
+++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js
@@ -472,15 +472,26 @@ function attemptReplayContinuousQueuedEvent(
queuedEvent.nativeEvent,
);
if (nextBlockedOn === null) {
- setReplayingEvent(queuedEvent.nativeEvent);
- dispatchEventForPluginEventSystem(
- queuedEvent.domEventName,
- queuedEvent.eventSystemFlags,
- queuedEvent.nativeEvent,
- return_targetInst,
- targetContainer,
- );
- resetReplayingEvent();
+ if (enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay) {
+ const nativeEvent = queuedEvent.nativeEvent;
+ const nativeEventClone = new nativeEvent.constructor(
+ nativeEvent.type,
+ (nativeEvent: any),
+ );
+ setReplayingEvent(nativeEventClone);
+ nativeEvent.target.dispatchEvent(nativeEventClone);
+ resetReplayingEvent();
+ } else {
+ setReplayingEvent(queuedEvent.nativeEvent);
+ dispatchEventForPluginEventSystem(
+ queuedEvent.domEventName,
+ queuedEvent.eventSystemFlags,
+ queuedEvent.nativeEvent,
+ return_targetInst,
+ targetContainer,
+ );
+ resetReplayingEvent();
+ }
} else {
// We're still blocked. Try again later.
const fiber = getInstanceFromNode(nextBlockedOn);
@@ -532,6 +543,8 @@ function replayUnblockedEvents() {
nextDiscreteEvent.nativeEvent,
);
if (nextBlockedOn === null) {
+ // This whole function is in !enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay,
+ // so we don't need the new replay behavior code branch.
setReplayingEvent(nextDiscreteEvent.nativeEvent);
dispatchEventForPluginEventSystem(
nextDiscreteEvent.domEventName,