diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
index d9c7aa787b49b..db4bb2b45fc90 100644
--- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
@@ -17,6 +17,58 @@ let ReactFeatureFlags;
let Suspense;
let SuspenseList;
let act;
+let useHover;
+
+function dispatchMouseEvent(to, from) {
+ if (!to) {
+ to = null;
+ }
+ if (!from) {
+ from = null;
+ }
+ if (from) {
+ const mouseOutEvent = document.createEvent('MouseEvents');
+ mouseOutEvent.initMouseEvent(
+ 'mouseout',
+ true,
+ true,
+ window,
+ 0,
+ 50,
+ 50,
+ 50,
+ 50,
+ false,
+ false,
+ false,
+ false,
+ 0,
+ to,
+ );
+ from.dispatchEvent(mouseOutEvent);
+ }
+ if (to) {
+ const mouseOverEvent = document.createEvent('MouseEvents');
+ mouseOverEvent.initMouseEvent(
+ 'mouseover',
+ true,
+ true,
+ window,
+ 0,
+ 50,
+ 50,
+ 50,
+ 50,
+ false,
+ false,
+ false,
+ false,
+ 0,
+ from,
+ );
+ to.dispatchEvent(mouseOverEvent);
+ }
+}
describe('ReactDOMServerPartialHydration', () => {
beforeEach(() => {
@@ -34,6 +86,8 @@ describe('ReactDOMServerPartialHydration', () => {
Scheduler = require('scheduler');
Suspense = React.Suspense;
SuspenseList = React.unstable_SuspenseList;
+
+ useHover = require('react-events/hover').useHover;
});
it('hydrates a parent even if a child Suspense boundary is blocked', async () => {
@@ -2040,4 +2094,223 @@ describe('ReactDOMServerPartialHydration', () => {
document.body.removeChild(parentContainer);
});
+
+ it('blocks only on the last continuous event (legacy system)', async () => {
+ let suspend1 = false;
+ let resolve1;
+ let promise1 = new Promise(resolvePromise => (resolve1 = resolvePromise));
+ let suspend2 = false;
+ let resolve2;
+ let promise2 = new Promise(resolvePromise => (resolve2 = resolvePromise));
+
+ function First({text}) {
+ if (suspend1) {
+ throw promise1;
+ } else {
+ return 'Hello';
+ }
+ }
+
+ function Second({text}) {
+ if (suspend2) {
+ throw promise2;
+ } else {
+ return 'World';
+ }
+ }
+
+ let ops = [];
+
+ function App() {
+ return (
+
+
+ ops.push('Mouse Enter First')}
+ onMouseLeave={() => ops.push('Mouse Leave First')}
+ />
+ {/* We suspend after to test what happens when we eager
+ attach the listener. */}
+
+
+
+ ops.push('Mouse Enter Second')}
+ onMouseLeave={() => ops.push('Mouse Leave Second')}>
+
+
+
+
+ );
+ }
+
+ let finalHTML = ReactDOMServer.renderToString();
+ let container = document.createElement('div');
+ container.innerHTML = finalHTML;
+
+ // We need this to be in the document since we'll dispatch events on it.
+ document.body.appendChild(container);
+
+ let appDiv = container.getElementsByTagName('div')[0];
+ let firstSpan = appDiv.getElementsByTagName('span')[0];
+ let secondSpan = appDiv.getElementsByTagName('span')[1];
+ expect(firstSpan.textContent).toBe('');
+ expect(secondSpan.textContent).toBe('World');
+
+ // On the client we don't have all data yet but we want to start
+ // hydrating anyway.
+ suspend1 = true;
+ suspend2 = true;
+ let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
+ root.render();
+
+ Scheduler.unstable_flushAll();
+ jest.runAllTimers();
+
+ dispatchMouseEvent(appDiv, null);
+ dispatchMouseEvent(firstSpan, appDiv);
+ dispatchMouseEvent(secondSpan, firstSpan);
+
+ // Neither target is yet hydrated.
+ expect(ops).toEqual([]);
+
+ // Resolving the second promise so that rendering can complete.
+ suspend2 = false;
+ resolve2();
+ await promise2;
+
+ Scheduler.unstable_flushAll();
+ jest.runAllTimers();
+
+ // We've unblocked the current hover target so we should be
+ // able to replay it now.
+ expect(ops).toEqual(['Mouse Enter Second']);
+
+ // Resolving the first promise has no effect now.
+ suspend1 = false;
+ resolve1();
+ await promise1;
+
+ Scheduler.unstable_flushAll();
+ jest.runAllTimers();
+
+ expect(ops).toEqual(['Mouse Enter Second']);
+
+ document.body.removeChild(container);
+ });
+
+ it('blocks only on the last continuous event (Responder system)', async () => {
+ let suspend1 = false;
+ let resolve1;
+ let promise1 = new Promise(resolvePromise => (resolve1 = resolvePromise));
+ let suspend2 = false;
+ let resolve2;
+ let promise2 = new Promise(resolvePromise => (resolve2 = resolvePromise));
+
+ function First({text}) {
+ if (suspend1) {
+ throw promise1;
+ } else {
+ return 'Hello';
+ }
+ }
+
+ function Second({text}) {
+ if (suspend2) {
+ throw promise2;
+ } else {
+ return 'World';
+ }
+ }
+
+ let ops = [];
+
+ function App() {
+ const listener1 = useHover({
+ onHoverStart() {
+ ops.push('Hover Start First');
+ },
+ onHoverEnd() {
+ ops.push('Hover End First');
+ },
+ });
+ const listener2 = useHover({
+ onHoverStart() {
+ ops.push('Hover Start Second');
+ },
+ onHoverEnd() {
+ ops.push('Hover End Second');
+ },
+ });
+ return (
+
+
+
+ {/* We suspend after to test what happens when we eager
+ attach the listener. */}
+
+
+
+
+
+
+
+
+ );
+ }
+
+ let finalHTML = ReactDOMServer.renderToString();
+ let container = document.createElement('div');
+ container.innerHTML = finalHTML;
+
+ // We need this to be in the document since we'll dispatch events on it.
+ document.body.appendChild(container);
+
+ let appDiv = container.getElementsByTagName('div')[0];
+ let firstSpan = appDiv.getElementsByTagName('span')[0];
+ let secondSpan = appDiv.getElementsByTagName('span')[1];
+ expect(firstSpan.textContent).toBe('');
+ expect(secondSpan.textContent).toBe('World');
+
+ // On the client we don't have all data yet but we want to start
+ // hydrating anyway.
+ suspend1 = true;
+ suspend2 = true;
+ let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
+ root.render();
+
+ Scheduler.unstable_flushAll();
+ jest.runAllTimers();
+
+ dispatchMouseEvent(appDiv, null);
+ dispatchMouseEvent(firstSpan, appDiv);
+ dispatchMouseEvent(secondSpan, firstSpan);
+
+ // Neither target is yet hydrated.
+ expect(ops).toEqual([]);
+
+ // Resolving the second promise so that rendering can complete.
+ suspend2 = false;
+ resolve2();
+ await promise2;
+
+ Scheduler.unstable_flushAll();
+ jest.runAllTimers();
+
+ // We've unblocked the current hover target so we should be
+ // able to replay it now.
+ expect(ops).toEqual(['Hover Start Second']);
+
+ // Resolving the first promise has no effect now.
+ suspend1 = false;
+ resolve1();
+ await promise1;
+
+ Scheduler.unstable_flushAll();
+ jest.runAllTimers();
+
+ expect(ops).toEqual(['Hover Start Second']);
+
+ document.body.removeChild(container);
+ });
});
diff --git a/packages/react-dom/src/events/EnterLeaveEventPlugin.js b/packages/react-dom/src/events/EnterLeaveEventPlugin.js
index 4f0b14f455218..57ba9e7659153 100644
--- a/packages/react-dom/src/events/EnterLeaveEventPlugin.js
+++ b/packages/react-dom/src/events/EnterLeaveEventPlugin.js
@@ -21,6 +21,7 @@ import {
getNodeFromInstance,
} from '../client/ReactDOMComponentTree';
import {HostComponent, HostText} from 'shared/ReactWorkTags';
+import {getNearestMountedFiber} from 'react-reconciler/reflection';
const eventTypes = {
mouseEnter: {
@@ -100,8 +101,14 @@ const EnterLeaveEventPlugin = {
from = targetInst;
const related = nativeEvent.relatedTarget || nativeEvent.toElement;
to = related ? getClosestInstanceFromNode(related) : null;
- if (to !== null && to.tag !== HostComponent && to.tag !== HostText) {
- to = null;
+ if (to !== null) {
+ const nearestMounted = getNearestMountedFiber(to);
+ if (
+ to !== nearestMounted ||
+ (to.tag !== HostComponent && to.tag !== HostText)
+ ) {
+ to = null;
+ }
}
} else {
// Moving to a node from outside the window.