Files
react/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js
T
Andrew Clark 03ede83d2e Use EventPriority to track update priority (#21082)
Instead of LanePriority. Internally, EventPriority is just a lane, so
this skips an extra conversion. Since EventPriority is a "public" (to
the host config) type, I was also able to remove some deep imports
of the Lane module.

This gets us most of the way to deleting the LanePriority entirely.
2021-03-25 09:21:41 -07:00

981 lines
27 KiB
JavaScript

/**
* 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.
*
* @emails react-core
*/
'use strict';
import {createEventTarget} from 'dom-event-testing-library';
let React;
let ReactDOM;
let ReactDOMServer;
let ReactTestUtils;
let Scheduler;
let Suspense;
let act;
let IdleEventPriority;
function dispatchMouseHoverEvent(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);
}
}
function dispatchClickEvent(target) {
const mouseOutEvent = document.createEvent('MouseEvents');
mouseOutEvent.initMouseEvent(
'click',
true,
true,
window,
0,
50,
50,
50,
50,
false,
false,
false,
false,
0,
target,
);
return target.dispatchEvent(mouseOutEvent);
}
// TODO: There's currently no React DOM API to opt into Idle priority updates,
// and there's no native DOM event that maps to idle priority, so this is a
// temporary workaround. Need something like ReactDOM.unstable_IdleUpdates.
function TODO_scheduleIdleDOMSchedulerTask(fn) {
ReactDOM.unstable_runWithPriority(IdleEventPriority, () => {
const prevEvent = window.event;
window.event = {type: 'message'};
try {
fn();
} finally {
window.event = prevEvent;
}
});
}
describe('ReactDOMServerSelectiveHydration', () => {
beforeEach(() => {
jest.resetModuleRegistry();
const ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableCreateEventHandleAPI = true;
React = require('react');
ReactDOM = require('react-dom');
ReactDOMServer = require('react-dom/server');
ReactTestUtils = require('react-dom/test-utils');
act = ReactTestUtils.unstable_concurrentAct;
Scheduler = require('scheduler');
Suspense = React.Suspense;
IdleEventPriority = require('react-reconciler/constants').IdleEventPriority;
});
// @gate experimental
it('hydrates the target boundary synchronously during a click', async () => {
function Child({text}) {
Scheduler.unstable_yieldValue(text);
return (
<span
onClick={e => {
e.preventDefault();
Scheduler.unstable_yieldValue('Clicked ' + text);
}}>
{text}
</span>
);
}
function App() {
Scheduler.unstable_yieldValue('App');
return (
<div>
<Suspense fallback="Loading...">
<Child text="A" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="B" />
</Suspense>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
expect(Scheduler).toHaveYielded(['App', 'A', 'B']);
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 span = container.getElementsByTagName('span')[1];
const root = ReactDOM.createRoot(container, {hydrate: true});
root.render(<App />);
// Nothing has been hydrated so far.
expect(Scheduler).toHaveYielded([]);
// This should synchronously hydrate the root App and the second suspense
// boundary.
const result = dispatchClickEvent(span);
// The event should have been canceled because we called preventDefault.
expect(result).toBe(false);
// We rendered App, B and then invoked the event without rendering A.
expect(Scheduler).toHaveYielded(['App', 'B', 'Clicked B']);
// After continuing the scheduler, we finally hydrate A.
expect(Scheduler).toFlushAndYield(['A']);
document.body.removeChild(container);
});
// @gate experimental
it('hydrates at higher pri if sync did not work first time', 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 (
<span
onClick={e => {
e.preventDefault();
Scheduler.unstable_yieldValue('Clicked ' + text);
}}>
{text}
</span>
);
}
function App() {
Scheduler.unstable_yieldValue('App');
return (
<div>
<Suspense fallback="Loading...">
<Child text="A" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="B" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="C" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="D" />
</Suspense>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
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 spanD = container.getElementsByTagName('span')[3];
suspend = true;
// A and D will be suspended. We'll click on D which should take
// priority, after we unsuspend.
const root = ReactDOM.createRoot(container, {hydrate: true});
root.render(<App />);
// Nothing has been hydrated so far.
expect(Scheduler).toHaveYielded([]);
// This click target cannot be hydrated yet because it's suspended.
await act(async () => {
const result = dispatchClickEvent(spanD);
expect(result).toBe(true);
});
expect(Scheduler).toHaveYielded([
'App',
// Continuing rendering will render B next.
'B',
'C',
]);
await act(async () => {
suspend = false;
resolve();
await promise;
});
// After the click, we should prioritize D and the Click first,
// and only after that render A and C.
expect(Scheduler).toHaveYielded(['D', 'Clicked D', 'A']);
document.body.removeChild(container);
});
// @gate experimental
it('hydrates at higher pri for secondary discrete events', 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 (
<span
onClick={e => {
e.preventDefault();
Scheduler.unstable_yieldValue('Clicked ' + text);
}}>
{text}
</span>
);
}
function App() {
Scheduler.unstable_yieldValue('App');
return (
<div>
<Suspense fallback="Loading...">
<Child text="A" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="B" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="C" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="D" />
</Suspense>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
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 spanA = container.getElementsByTagName('span')[0];
const spanC = container.getElementsByTagName('span')[2];
const spanD = container.getElementsByTagName('span')[3];
suspend = true;
// A and D will be suspended. We'll click on D which should take
// priority, after we unsuspend.
const root = ReactDOM.createRoot(container, {hydrate: true});
root.render(<App />);
// Nothing has been hydrated so far.
expect(Scheduler).toHaveYielded([]);
// This click target cannot be hydrated yet because the first is Suspended.
dispatchClickEvent(spanA);
dispatchClickEvent(spanC);
dispatchClickEvent(spanD);
expect(Scheduler).toHaveYielded(['App']);
await act(async () => {
suspend = false;
resolve();
await promise;
});
// We should prioritize hydrating A, C and D first since we clicked in
// them. Only after they're done will we hydrate B.
expect(Scheduler).toHaveYielded([
'A',
'Clicked A',
'C',
'Clicked C',
'D',
'Clicked D',
// B should render last since it wasn't clicked.
'B',
]);
document.body.removeChild(container);
});
// @gate experimental
it('hydrates the target boundary synchronously during a click (createEventHandle)', async () => {
const setClick = ReactDOM.unstable_createEventHandle('click');
let isServerRendering = true;
function Child({text}) {
const ref = React.useRef(null);
Scheduler.unstable_yieldValue(text);
if (!isServerRendering) {
React.useLayoutEffect(() => {
return setClick(ref.current, () => {
Scheduler.unstable_yieldValue('Clicked ' + text);
});
});
}
return <span ref={ref}>{text}</span>;
}
function App() {
Scheduler.unstable_yieldValue('App');
return (
<div>
<Suspense fallback="Loading...">
<Child text="A" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="B" />
</Suspense>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
expect(Scheduler).toHaveYielded(['App', 'A', 'B']);
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;
isServerRendering = false;
const root = ReactDOM.createRoot(container, {hydrate: true});
root.render(<App />);
// Nothing has been hydrated so far.
expect(Scheduler).toHaveYielded([]);
const span = container.getElementsByTagName('span')[1];
const target = createEventTarget(span);
// This should synchronously hydrate the root App and the second suspense
// boundary.
target.virtualclick();
// We rendered App, B and then invoked the event without rendering A.
expect(Scheduler).toHaveYielded(['App', 'B', 'Clicked B']);
// After continuing the scheduler, we finally hydrate A.
expect(Scheduler).toFlushAndYield(['A']);
document.body.removeChild(container);
});
// @gate experimental
it('hydrates at higher pri if sync did not work first time (createEventHandle)', async () => {
let suspend = false;
let isServerRendering = true;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const setClick = ReactDOM.unstable_createEventHandle('click');
function Child({text}) {
const ref = React.useRef(null);
if ((text === 'A' || text === 'D') && suspend) {
throw promise;
}
Scheduler.unstable_yieldValue(text);
if (!isServerRendering) {
React.useLayoutEffect(() => {
return setClick(ref.current, () => {
Scheduler.unstable_yieldValue('Clicked ' + text);
});
});
}
return <span ref={ref}>{text}</span>;
}
function App() {
Scheduler.unstable_yieldValue('App');
return (
<div>
<Suspense fallback="Loading...">
<Child text="A" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="B" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="C" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="D" />
</Suspense>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
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 spanD = container.getElementsByTagName('span')[3];
suspend = true;
isServerRendering = false;
// A and D will be suspended. We'll click on D which should take
// priority, after we unsuspend.
const root = ReactDOM.createRoot(container, {hydrate: true});
root.render(<App />);
// Nothing has been hydrated so far.
expect(Scheduler).toHaveYielded([]);
// Continuing rendering will render B next.
await act(async () => {
const target = createEventTarget(spanD);
target.virtualclick();
});
expect(Scheduler).toHaveYielded(['App', 'B', 'C']);
// After the click, we should prioritize D and the Click first,
// and only after that render A and C.
await act(async () => {
suspend = false;
resolve();
await promise;
});
expect(Scheduler).toHaveYielded(['D', 'Clicked D', 'A']);
document.body.removeChild(container);
});
// @gate experimental
it('hydrates at higher pri for secondary discrete events (createEventHandle)', async () => {
const setClick = ReactDOM.unstable_createEventHandle('click');
let suspend = false;
let isServerRendering = true;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
function Child({text}) {
const ref = React.useRef(null);
if ((text === 'A' || text === 'D') && suspend) {
throw promise;
}
Scheduler.unstable_yieldValue(text);
if (!isServerRendering) {
React.useLayoutEffect(() => {
return setClick(ref.current, () => {
Scheduler.unstable_yieldValue('Clicked ' + text);
});
});
}
return <span ref={ref}>{text}</span>;
}
function App() {
Scheduler.unstable_yieldValue('App');
return (
<div>
<Suspense fallback="Loading...">
<Child text="A" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="B" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="C" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="D" />
</Suspense>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
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 spanA = container.getElementsByTagName('span')[0];
const spanC = container.getElementsByTagName('span')[2];
const spanD = container.getElementsByTagName('span')[3];
suspend = true;
isServerRendering = false;
// A and D will be suspended. We'll click on D which should take
// priority, after we unsuspend.
const root = ReactDOM.createRoot(container, {hydrate: true});
root.render(<App />);
// Nothing has been hydrated so far.
expect(Scheduler).toHaveYielded([]);
// This click target cannot be hydrated yet because the first is Suspended.
createEventTarget(spanA).virtualclick();
createEventTarget(spanC).virtualclick();
createEventTarget(spanD).virtualclick();
expect(Scheduler).toHaveYielded(['App']);
await act(async () => {
suspend = false;
resolve();
await promise;
});
// We should prioritize hydrating A, C and D first since we clicked in
// them. Only after they're done will we hydrate B.
expect(Scheduler).toHaveYielded([
'A',
'Clicked A',
'C',
'Clicked C',
'D',
'Clicked D',
// B should render last since it wasn't clicked.
'B',
]);
document.body.removeChild(container);
});
// @gate experimental
it('hydrates the hovered targets as higher priority for continuous events', 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 (
<span
onClick={e => {
e.preventDefault();
Scheduler.unstable_yieldValue('Clicked ' + text);
}}
onMouseEnter={e => {
e.preventDefault();
Scheduler.unstable_yieldValue('Hover ' + text);
}}>
{text}
</span>
);
}
function App() {
Scheduler.unstable_yieldValue('App');
return (
<div>
<Suspense fallback="Loading...">
<Child text="A" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="B" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="C" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="D" />
</Suspense>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
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 = container.getElementsByTagName('span')[1];
const spanC = container.getElementsByTagName('span')[2];
const spanD = container.getElementsByTagName('span')[3];
suspend = true;
// A and D will be suspended. We'll click on D which should take
// priority, after we unsuspend.
const root = ReactDOM.createRoot(container, {hydrate: true});
root.render(<App />);
// 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;
});
// 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);
});
// @gate experimental
it('hydrates the last target path first for continuous events', 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 (
<span
onMouseEnter={e => {
e.preventDefault();
Scheduler.unstable_yieldValue('Hover ' + text);
}}>
{text}
</span>
);
}
function App() {
Scheduler.unstable_yieldValue('App');
return (
<div>
<Suspense fallback="Loading...">
<Child text="A" />
</Suspense>
<Suspense fallback="Loading...">
<div>
<Suspense fallback="Loading...">
<Child text="B" />
</Suspense>
</div>
<Child text="C" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="D" />
</Suspense>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
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 = container.getElementsByTagName('span')[1];
const spanC = container.getElementsByTagName('span')[2];
const spanD = container.getElementsByTagName('span')[3];
suspend = true;
// A and D will be suspended. We'll click on D which should take
// priority, after we unsuspend.
const root = ReactDOM.createRoot(container, {hydrate: true});
root.render(<App />);
// Nothing has been hydrated so far.
expect(Scheduler).toHaveYielded([]);
// Hover over B and then C.
dispatchMouseHoverEvent(spanB, spanD);
dispatchMouseHoverEvent(spanC, spanB);
suspend = false;
resolve();
await promise;
// We should prioritize hydrating D first because we clicked it.
// Next we should hydrate C since that's the current hover target.
// Next it doesn't matter if we hydrate A or B first but as an
// implementation detail we're currently hydrating B first since
// we at one point hovered over it and we never deprioritized it.
expect(Scheduler).toFlushAndYield(['App', 'C', 'Hover C', 'A', 'B', 'D']);
document.body.removeChild(container);
});
// @gate experimental
it('hydrates the last explicitly hydrated target at higher priority', async () => {
function Child({text}) {
Scheduler.unstable_yieldValue(text);
return <span>{text}</span>;
}
function App() {
Scheduler.unstable_yieldValue('App');
return (
<div>
<Suspense fallback="Loading...">
<Child text="A" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="B" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="C" />
</Suspense>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C']);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const spanB = container.getElementsByTagName('span')[1];
const spanC = container.getElementsByTagName('span')[2];
const root = ReactDOM.createRoot(container, {hydrate: true});
root.render(<App />);
// Nothing has been hydrated so far.
expect(Scheduler).toHaveYielded([]);
// Increase priority of B and then C.
ReactDOM.unstable_scheduleHydration(spanB);
ReactDOM.unstable_scheduleHydration(spanC);
// We should prioritize hydrating C first because the last added
// gets highest priority followed by the next added.
expect(Scheduler).toFlushAndYield(['App', 'C', 'B', 'A']);
});
// @gate experimental
it('hydrates before an update even if hydration moves away from it', async () => {
function Child({text}) {
Scheduler.unstable_yieldValue(text);
return <span>{text}</span>;
}
const ChildWithBoundary = React.memo(function({text}) {
return (
<Suspense fallback="Loading...">
<Child text={text} />
<Child text={text.toLowerCase()} />
</Suspense>
);
});
function App({a}) {
Scheduler.unstable_yieldValue('App');
React.useEffect(() => {
Scheduler.unstable_yieldValue('Commit');
});
return (
<div>
<ChildWithBoundary text={a} />
<ChildWithBoundary text="B" />
<ChildWithBoundary text="C" />
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App a="A" />);
expect(Scheduler).toHaveYielded(['App', 'A', 'a', 'B', 'b', 'C', 'c']);
const 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);
const spanA = container.getElementsByTagName('span')[0];
const spanB = container.getElementsByTagName('span')[2];
const spanC = container.getElementsByTagName('span')[4];
const root = ReactDOM.createRoot(container, {hydrate: true});
act(() => {
root.render(<App a="A" />);
// Hydrate the shell.
expect(Scheduler).toFlushAndYieldThrough(['App', 'Commit']);
// Render an update at Idle priority that needs to update A.
TODO_scheduleIdleDOMSchedulerTask(() => {
root.render(<App a="AA" />);
});
// Start rendering. This will force the first boundary to hydrate
// by scheduling it at one higher pri than Idle.
expect(Scheduler).toFlushAndYieldThrough([
// An update was scheduled to force hydrate the boundary, but React will
// continue rendering at Idle until the next time React yields. This is
// fine though because it will switch to the hydration level when it
// re-enters the work loop.
'App',
'AA',
]);
// Hover over A which (could) schedule at one higher pri than Idle.
dispatchMouseHoverEvent(spanA, null);
// Before, we're done we now switch to hover over B.
// This is meant to test that this doesn't cause us to forget that
// we still have to hydrate A. The first boundary.
// This also tests that we don't do the -1 down-prioritization of
// continuous hover events because that would decrease its priority
// to Idle.
dispatchMouseHoverEvent(spanB, spanA);
// Also click C to prioritize that even higher which resets the
// priority levels.
dispatchClickEvent(spanC);
expect(Scheduler).toHaveYielded([
// Hydrate C first since we clicked it.
'C',
'c',
]);
expect(Scheduler).toFlushAndYield([
// Finish hydration of A since we forced it to hydrate.
'A',
'a',
// Also, hydrate B since we hovered over it.
// It's not important which one comes first. A or B.
// As long as they both happen before the Idle update.
'B',
'b',
// Begin the Idle update again.
'App',
'AA',
'aa',
'Commit',
]);
});
const spanA2 = container.getElementsByTagName('span')[0];
// This is supposed to have been hydrated, not replaced.
expect(spanA).toBe(spanA2);
document.body.removeChild(container);
});
});