/**
* Copyright (c) Meta Platforms, Inc. and 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
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
*/
'use strict';
let Activity;
let React = require('react');
let ReactDOM;
let ReactDOMClient;
let ReactDOMServer;
let ReactFeatureFlags;
let Scheduler;
let Suspense;
let useSyncExternalStore;
let act;
let IdleEventPriority;
let waitForAll;
let waitFor;
let assertLog;
let assertConsoleErrorDev;
function normalizeError(msg) {
// Take the first sentence to make it easier to assert on.
const idx = msg.indexOf('.');
if (idx > -1) {
return msg.slice(0, idx + 1);
}
return msg;
}
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('ReactDOMServerPartialHydrationActivity', () => {
beforeEach(() => {
jest.resetModules();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableSuspenseCallback = true;
ReactFeatureFlags.enableCreateEventHandleAPI = true;
React = require('react');
ReactDOM = require('react-dom');
ReactDOMClient = require('react-dom/client');
act = require('internal-test-utils').act;
ReactDOMServer = require('react-dom/server');
Scheduler = require('scheduler');
Activity = React.Activity;
Suspense = React.Suspense;
useSyncExternalStore = React.useSyncExternalStore;
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
assertLog = InternalTestUtils.assertLog;
waitFor = InternalTestUtils.waitFor;
assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
IdleEventPriority = require('react-reconciler/constants').IdleEventPriority;
});
it('hydrates a parent even if a child Activity boundary is blocked', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
function Child() {
if (suspend) {
throw promise;
} else {
return 'Hello';
}
}
function App() {
return (
clicks++} ref={childSlotRef} />;
}
function Child({text}) {
if (suspend) {
throw promise;
} else {
return
Click me;
}
}
function App() {
// The root is a Suspense boundary.
return (
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(
);
const parentContainer = document.createElement('div');
const childContainer = document.createElement('div');
// We need this to be in the document since we'll dispatch events on it.
document.body.appendChild(parentContainer);
// We're going to use a different root as a parent.
// This lets us detect whether an event goes through React's event system.
const parentRoot = ReactDOMClient.createRoot(parentContainer);
await act(() => parentRoot.render(
));
childSlotRef.current.appendChild(childContainer);
childContainer.innerHTML = finalHTML;
const a = childContainer.getElementsByTagName('a')[0];
suspend = true;
// Hydrate asynchronously.
await act(() => ReactDOMClient.hydrateRoot(childContainer,
));
// The Suspense boundary is not yet hydrated.
await act(() => {
a.click();
});
expect(clicks).toBe(0);
// Resolving the promise so that rendering can complete.
await act(async () => {
suspend = false;
resolve();
await promise;
});
expect(clicks).toBe(0);
document.body.removeChild(parentContainer);
});
it('blocks only on the last continuous event (legacy system)', async () => {
let suspend1 = false;
let resolve1;
const promise1 = new Promise(resolvePromise => (resolve1 = resolvePromise));
let suspend2 = false;
let resolve2;
const 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';
}
}
const 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')}>
);
}
const finalHTML = ReactDOMServer.renderToString(
);
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 appDiv = container.getElementsByTagName('div')[0];
const firstSpan = appDiv.getElementsByTagName('span')[0];
const 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;
ReactDOMClient.hydrateRoot(container,
);
await waitForAll([]);
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;
await waitForAll([]);
// 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;
await waitForAll([]);
expect(ops).toEqual(['Mouse Enter Second']);
document.body.removeChild(container);
});
it('finishes normal pri work before continuing to hydrate a retry', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
function Child() {
if (suspend) {
throw promise;
} else {
Scheduler.log('Child');
return 'Hello';
}
}
function Sibling() {
Scheduler.log('Sibling');
React.useLayoutEffect(() => {
Scheduler.log('Commit Sibling');
});
return 'World';
}
// Avoid rerendering the tree by hoisting it.
const tree = (
);
function App({showSibling}) {
return (
{tree}
{showSibling ? : null}
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(
);
assertLog(['Child']);
const container = document.createElement('div');
container.innerHTML = finalHTML;
suspend = true;
const root = ReactDOMClient.hydrateRoot(
container,
,
);
await waitForAll([]);
expect(ref.current).toBe(null);
expect(container.textContent).toBe('Hello');
// Resolving the promise should continue hydration
suspend = false;
resolve();
await promise;
Scheduler.unstable_advanceTime(100);
// Before we have a chance to flush it, we'll also render an update.
root.render(
);
// When we flush we expect the Normal pri render to take priority
// over hydration.
await waitFor(['Sibling', 'Commit Sibling']);
// We shouldn't have hydrated the child yet.
expect(ref.current).toBe(null);
// But we did have a chance to update the content.
expect(container.textContent).toBe('HelloWorld');
await waitForAll(['Child']);
// Now we're hydrated.
expect(ref.current).not.toBe(null);
});
it('regression test: does not overfire non-bubbling browser events', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
function Sibling({text}) {
if (suspend) {
throw promise;
} else {
return 'Hello';
}
}
let submits = 0;
function Form() {
const [submitted, setSubmitted] = React.useState(false);
if (submitted) {
return null;
}
return (
);
}
function App() {
return (
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(
);
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 form = container.getElementsByTagName('form')[0];
// On the client we don't have all data yet but we want to start
// hydrating anyway.
suspend = true;
ReactDOMClient.hydrateRoot(container,
);
await waitForAll([]);
expect(container.textContent).toBe('Click meHello');
// We're now partially hydrated.
await act(() => {
form.dispatchEvent(
new window.Event('submit', {
bubbles: true,
}),
);
});
expect(submits).toBe(0);
// Resolving the promise so that rendering can complete.
await act(async () => {
suspend = false;
resolve();
await promise;
});
// discrete event not replayed
expect(submits).toBe(0);
expect(container.textContent).toBe('Click meHello');
document.body.removeChild(container);
});
it('fallback to client render on hydration mismatch at root', async () => {
let suspend = true;
let resolve;
const promise = new Promise((res, rej) => {
resolve = () => {
suspend = false;
res();
};
});
function App({isClient}) {
return (
<>
{isClient ?
client :
server
}
>
);
}
function ChildThatSuspends({id, isClient}) {
if (isClient && suspend) {
throw promise;
}
return
{id}
;
}
const finalHTML = ReactDOMServer.renderToString(
);
const container = document.createElement('div');
document.body.appendChild(container);
container.innerHTML = finalHTML;
await act(() => {
ReactDOMClient.hydrateRoot(container,
, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
});
// We suspend the root while we wait for the promises to resolve, leaving the
// existing content in place.
expect(container.innerHTML).toEqual(
'
1
server
2
',
);
await act(async () => {
resolve();
await promise;
});
assertLog([
"onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
]);
expect(container.innerHTML).toEqual(
'
1
client2
',
);
});
it('commits new suspending content next to a dehydrated Activity that hides', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
function Second() {
if (suspend) {
throw promise;
}
return
Second;
}
function App({showSecondOnMount}) {
const [active, setActive] = React.useState('first');
React.useEffect(() => {
if (showSecondOnMount) {
// Not a transition: this update reaches the dehydrated Activity at
// default priority, before it has hydrated.
setActive('second');
}
}, [showSecondOnMount]);
return (
{active === 'second' ? : null}
First
);
}
// Don't suspend on the server.
suspend = false;
const finalHTML = ReactDOMServer.renderToString(
,
);
const container = document.createElement('div');
container.innerHTML = finalHTML;
expect(container.textContent).toBe('First');
// Hydrate. The first effect mounts new content (still loading) and hides
// the server-rendered Activity while its subtree is still dehydrated.
suspend = true;
await act(() => {
ReactDOMClient.hydrateRoot(container,
);
});
// The data for the new row arrives.
suspend = false;
await act(async () => {
resolve();
await promise;
});
// The new row should be visible and the old row hidden.
const second = container.querySelector('#second');
const first = container.querySelector('#first');
expect(second).not.toBe(null);
expect(second.style.display).not.toBe('none');
expect(first === null || first.style.display === 'none').toBe(true);
});
it('commits new suspending content next to a dehydrated Activity that hides (transition)', async () => {
// Same as the previous test, except the update is wrapped
// in startTransition.
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
function Second() {
if (suspend) {
throw promise;
}
return
Second;
}
function App({showSecondOnMount}) {
const [active, setActive] = React.useState('first');
React.useEffect(() => {
if (showSecondOnMount) {
React.startTransition(() => {
setActive('second');
});
}
}, [showSecondOnMount]);
return (
{active === 'second' ? : null}
First
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(
,
);
const container = document.createElement('div');
container.innerHTML = finalHTML;
expect(container.textContent).toBe('First');
suspend = true;
await act(() => {
ReactDOMClient.hydrateRoot(container,
);
});
suspend = false;
await act(async () => {
resolve();
await promise;
});
const second = container.querySelector('#second');
const first = container.querySelector('#first');
expect(second).not.toBe(null);
expect(second.style.display).not.toBe('none');
expect(first === null || first.style.display === 'none').toBe(true);
});
});