Skip to content

Commit be1f29f

Browse files
committed
Add test for mouseover replaying
We need to check if the "relatedTarget" is mounted due to how the old event system dispatches from the "out" event.
1 parent 3d8235d commit be1f29f

File tree

2 files changed

+282
-2
lines changed

2 files changed

+282
-2
lines changed

packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,58 @@ let ReactFeatureFlags;
1717
let Suspense;
1818
let SuspenseList;
1919
let act;
20+
let useHover;
21+
22+
function dispatchMouseEvent(to, from) {
23+
if (!to) {
24+
to = null;
25+
}
26+
if (!from) {
27+
from = null;
28+
}
29+
if (from) {
30+
const mouseOutEvent = document.createEvent('MouseEvents');
31+
mouseOutEvent.initMouseEvent(
32+
'mouseout',
33+
true,
34+
true,
35+
window,
36+
0,
37+
50,
38+
50,
39+
50,
40+
50,
41+
false,
42+
false,
43+
false,
44+
false,
45+
0,
46+
to,
47+
);
48+
from.dispatchEvent(mouseOutEvent);
49+
}
50+
if (to) {
51+
const mouseOverEvent = document.createEvent('MouseEvents');
52+
mouseOverEvent.initMouseEvent(
53+
'mouseover',
54+
true,
55+
true,
56+
window,
57+
0,
58+
50,
59+
50,
60+
50,
61+
50,
62+
false,
63+
false,
64+
false,
65+
false,
66+
0,
67+
from,
68+
);
69+
to.dispatchEvent(mouseOverEvent);
70+
}
71+
}
2072

2173
describe('ReactDOMServerPartialHydration', () => {
2274
beforeEach(() => {
@@ -34,6 +86,8 @@ describe('ReactDOMServerPartialHydration', () => {
3486
Scheduler = require('scheduler');
3587
Suspense = React.Suspense;
3688
SuspenseList = React.unstable_SuspenseList;
89+
90+
useHover = require('react-ui/events/hover').useHover;
3791
});
3892

3993
it('hydrates a parent even if a child Suspense boundary is blocked', async () => {
@@ -2040,4 +2094,223 @@ describe('ReactDOMServerPartialHydration', () => {
20402094

20412095
document.body.removeChild(parentContainer);
20422096
});
2097+
2098+
it('blocks only on the last continuous event (legacy system)', async () => {
2099+
let suspend1 = false;
2100+
let resolve1;
2101+
let promise1 = new Promise(resolvePromise => (resolve1 = resolvePromise));
2102+
let suspend2 = false;
2103+
let resolve2;
2104+
let promise2 = new Promise(resolvePromise => (resolve2 = resolvePromise));
2105+
2106+
function First({text}) {
2107+
if (suspend1) {
2108+
throw promise1;
2109+
} else {
2110+
return 'Hello';
2111+
}
2112+
}
2113+
2114+
function Second({text}) {
2115+
if (suspend2) {
2116+
throw promise2;
2117+
} else {
2118+
return 'World';
2119+
}
2120+
}
2121+
2122+
let ops = [];
2123+
2124+
function App() {
2125+
return (
2126+
<div>
2127+
<Suspense fallback="Loading First...">
2128+
<span
2129+
onMouseEnter={() => ops.push('Mouse Enter First')}
2130+
onMouseLeave={() => ops.push('Mouse Leave First')}
2131+
/>
2132+
{/* We suspend after to test what happens when we eager
2133+
attach the listener. */}
2134+
<First />
2135+
</Suspense>
2136+
<Suspense fallback="Loading Second...">
2137+
<span
2138+
onMouseEnter={() => ops.push('Mouse Enter Second')}
2139+
onMouseLeave={() => ops.push('Mouse Leave Second')}>
2140+
<Second />
2141+
</span>
2142+
</Suspense>
2143+
</div>
2144+
);
2145+
}
2146+
2147+
let finalHTML = ReactDOMServer.renderToString(<App />);
2148+
let container = document.createElement('div');
2149+
container.innerHTML = finalHTML;
2150+
2151+
// We need this to be in the document since we'll dispatch events on it.
2152+
document.body.appendChild(container);
2153+
2154+
let appDiv = container.getElementsByTagName('div')[0];
2155+
let firstSpan = appDiv.getElementsByTagName('span')[0];
2156+
let secondSpan = appDiv.getElementsByTagName('span')[1];
2157+
expect(firstSpan.textContent).toBe('');
2158+
expect(secondSpan.textContent).toBe('World');
2159+
2160+
// On the client we don't have all data yet but we want to start
2161+
// hydrating anyway.
2162+
suspend1 = true;
2163+
suspend2 = true;
2164+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
2165+
root.render(<App />);
2166+
2167+
Scheduler.unstable_flushAll();
2168+
jest.runAllTimers();
2169+
2170+
dispatchMouseEvent(appDiv, null);
2171+
dispatchMouseEvent(firstSpan, appDiv);
2172+
dispatchMouseEvent(secondSpan, firstSpan);
2173+
2174+
// Neither target is yet hydrated.
2175+
expect(ops).toEqual([]);
2176+
2177+
// Resolving the second promise so that rendering can complete.
2178+
suspend2 = false;
2179+
resolve2();
2180+
await promise2;
2181+
2182+
Scheduler.unstable_flushAll();
2183+
jest.runAllTimers();
2184+
2185+
// We've unblocked the current hover target so we should be
2186+
// able to replay it now.
2187+
expect(ops).toEqual(['Mouse Enter Second']);
2188+
2189+
// Resolving the first promise has no effect now.
2190+
suspend1 = false;
2191+
resolve1();
2192+
await promise1;
2193+
2194+
Scheduler.unstable_flushAll();
2195+
jest.runAllTimers();
2196+
2197+
expect(ops).toEqual(['Mouse Enter Second']);
2198+
2199+
document.body.removeChild(container);
2200+
});
2201+
2202+
it('blocks only on the last continuous event (Responder system)', async () => {
2203+
let suspend1 = false;
2204+
let resolve1;
2205+
let promise1 = new Promise(resolvePromise => (resolve1 = resolvePromise));
2206+
let suspend2 = false;
2207+
let resolve2;
2208+
let promise2 = new Promise(resolvePromise => (resolve2 = resolvePromise));
2209+
2210+
function First({text}) {
2211+
if (suspend1) {
2212+
throw promise1;
2213+
} else {
2214+
return 'Hello';
2215+
}
2216+
}
2217+
2218+
function Second({text}) {
2219+
if (suspend2) {
2220+
throw promise2;
2221+
} else {
2222+
return 'World';
2223+
}
2224+
}
2225+
2226+
let ops = [];
2227+
2228+
function App() {
2229+
const listener1 = useHover({
2230+
onHoverStart() {
2231+
ops.push('Hover Start First');
2232+
},
2233+
onHoverEnd() {
2234+
ops.push('Hover End First');
2235+
},
2236+
});
2237+
const listener2 = useHover({
2238+
onHoverStart() {
2239+
ops.push('Hover Start Second');
2240+
},
2241+
onHoverEnd() {
2242+
ops.push('Hover End Second');
2243+
},
2244+
});
2245+
return (
2246+
<div>
2247+
<Suspense fallback="Loading First...">
2248+
<span listeners={listener1} />
2249+
{/* We suspend after to test what happens when we eager
2250+
attach the listener. */}
2251+
<First />
2252+
</Suspense>
2253+
<Suspense fallback="Loading Second...">
2254+
<span listeners={listener2}>
2255+
<Second />
2256+
</span>
2257+
</Suspense>
2258+
</div>
2259+
);
2260+
}
2261+
2262+
let finalHTML = ReactDOMServer.renderToString(<App />);
2263+
let container = document.createElement('div');
2264+
container.innerHTML = finalHTML;
2265+
2266+
// We need this to be in the document since we'll dispatch events on it.
2267+
document.body.appendChild(container);
2268+
2269+
let appDiv = container.getElementsByTagName('div')[0];
2270+
let firstSpan = appDiv.getElementsByTagName('span')[0];
2271+
let secondSpan = appDiv.getElementsByTagName('span')[1];
2272+
expect(firstSpan.textContent).toBe('');
2273+
expect(secondSpan.textContent).toBe('World');
2274+
2275+
// On the client we don't have all data yet but we want to start
2276+
// hydrating anyway.
2277+
suspend1 = true;
2278+
suspend2 = true;
2279+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
2280+
root.render(<App />);
2281+
2282+
Scheduler.unstable_flushAll();
2283+
jest.runAllTimers();
2284+
2285+
dispatchMouseEvent(appDiv, null);
2286+
dispatchMouseEvent(firstSpan, appDiv);
2287+
dispatchMouseEvent(secondSpan, firstSpan);
2288+
2289+
// Neither target is yet hydrated.
2290+
expect(ops).toEqual([]);
2291+
2292+
// Resolving the second promise so that rendering can complete.
2293+
suspend2 = false;
2294+
resolve2();
2295+
await promise2;
2296+
2297+
Scheduler.unstable_flushAll();
2298+
jest.runAllTimers();
2299+
2300+
// We've unblocked the current hover target so we should be
2301+
// able to replay it now.
2302+
expect(ops).toEqual(['Hover Start Second']);
2303+
2304+
// Resolving the first promise has no effect now.
2305+
suspend1 = false;
2306+
resolve1();
2307+
await promise1;
2308+
2309+
Scheduler.unstable_flushAll();
2310+
jest.runAllTimers();
2311+
2312+
expect(ops).toEqual(['Hover Start Second']);
2313+
2314+
document.body.removeChild(container);
2315+
});
20432316
});

packages/react-dom/src/events/EnterLeaveEventPlugin.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
getNodeFromInstance,
2222
} from '../client/ReactDOMComponentTree';
2323
import {HostComponent, HostText} from 'shared/ReactWorkTags';
24+
import {getNearestMountedFiber} from 'react-reconciler/reflection';
2425

2526
const eventTypes = {
2627
mouseEnter: {
@@ -100,8 +101,14 @@ const EnterLeaveEventPlugin = {
100101
from = targetInst;
101102
const related = nativeEvent.relatedTarget || nativeEvent.toElement;
102103
to = related ? getClosestInstanceFromNode(related) : null;
103-
if (to !== null && to.tag !== HostComponent && to.tag !== HostText) {
104-
to = null;
104+
if (to !== null) {
105+
const nearestMounted = getNearestMountedFiber(to);
106+
if (
107+
to !== nearestMounted ||
108+
(to.tag !== HostComponent && to.tag !== HostText)
109+
) {
110+
to = null;
111+
}
105112
}
106113
} else {
107114
// Moving to a node from outside the window.

0 commit comments

Comments
 (0)