/**
* 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 React;
let ReactDOM;
let ReactDOMClient;
let ReactDOMServer;
let ReactDOMServerBrowser;
let waitForAll;
let act;
// These tests rely both on ReactDOMServer and ReactDOM.
// If a test only needs ReactDOMServer, put it in ReactServerRendering-test instead.
describe('ReactDOMServerHydration', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactDOM = require('react-dom');
ReactDOMClient = require('react-dom/client');
ReactDOMServer = require('react-dom/server');
ReactDOMServerBrowser = require('react-dom/server.browser');
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
act = InternalTestUtils.act;
});
it('should have the correct mounting behavior (new hydrate API)', () => {
let mountCount = 0;
let numClicks = 0;
class TestComponent extends React.Component {
spanRef = React.createRef();
componentDidMount() {
mountCount++;
}
click = () => {
numClicks++;
};
render() {
return (
<span ref={this.spanRef} onClick={this.click}>
Name: {this.props.name}
</span>
);
}
}
const element = document.createElement('div');
document.body.appendChild(element);
try {
ReactDOM.render(<TestComponent />, element);
let lastMarkup = element.innerHTML;
// Exercise the update path. Markup should not change,
// but some lifecycle methods should be run again.
ReactDOM.render(<TestComponent name="x" />, element);
expect(mountCount).toEqual(1);
// Unmount and remount. We should get another mount event and
// we should get different markup, as the IDs are unique each time.
ReactDOM.unmountComponentAtNode(element);
expect(element.innerHTML).toEqual('');
ReactDOM.render(<TestComponent name="x" />, element);
expect(mountCount).toEqual(2);
expect(element.innerHTML).not.toEqual(lastMarkup);
// Now kill the node and render it on top of server-rendered markup, as if
// we used server rendering. We should mount again, but the markup should
// be unchanged. We will append a sentinel at the end of innerHTML to be
// sure that innerHTML was not changed.
ReactDOM.unmountComponentAtNode(element);
expect(element.innerHTML).toEqual('');
lastMarkup = ReactDOMServer.renderToString(<TestComponent name="x" />);
element.innerHTML = lastMarkup;
let instance = ReactDOM.hydrate(<TestComponent name="x" />, element);
expect(mountCount).toEqual(3);
expect(element.innerHTML).toBe(lastMarkup);
// Ensure the events system works after mount into server markup
expect(numClicks).toEqual(0);
instance.spanRef.current.click();
expect(numClicks).toEqual(1);
ReactDOM.unmountComponentAtNode(element);
expect(element.innerHTML).toEqual('');
// Now simulate a situation where the app is not idempotent. React should
// warn but do the right thing.
element.innerHTML = lastMarkup;
expect(() => {
instance = ReactDOM.hydrate(<TestComponent name="y" />, element);
}).toErrorDev('Text content did not match. Server: "x" Client: "y"');
expect(mountCount).toEqual(4);
expect(element.innerHTML.length > 0).toBe(true);
expect(element.innerHTML).not.toEqual(lastMarkup);
// Ensure the events system works after markup mismatch.
expect(numClicks).toEqual(1);
instance.spanRef.current.click();
expect(numClicks).toEqual(2);
} finally {
document.body.removeChild(element);
}
});
// We have a polyfill for autoFocus on the client, but we intentionally don't
// want it to call focus() when hydrating because this can mess up existing
// focus before the JS has loaded.
it('should emit autofocus on the server but not focus() when hydrating', () => {
const element = document.createElement('div');
element.innerHTML = ReactDOMServer.renderToString(
<input autoFocus={true} />,
);
expect(element.firstChild.autofocus).toBe(true);
// It should not be called on mount.
element.firstChild.focus = jest.fn();
ReactDOM.hydrate(<input autoFocus={true} />, element);
expect(element.firstChild.focus).not.toHaveBeenCalled();
// Or during an update.
ReactDOM.render(<input autoFocus={true} />, element);
expect(element.firstChild.focus).not.toHaveBeenCalled();
});
it('should not focus on either server or client with autofocus={false}', () => {
const element = document.createElement('div');
element.innerHTML = ReactDOMServer.renderToString(
<input autoFocus={false} />,
);
expect(element.firstChild.autofocus).toBe(false);
element.firstChild.focus = jest.fn();
ReactDOM.hydrate(<input autoFocus={false} />, element);
expect(element.firstChild.focus).not.toHaveBeenCalled();
ReactDOM.render(<input autoFocus={false} />, element);
expect(element.firstChild.focus).not.toHaveBeenCalled();
});
// Regression test for https://github.com/facebook/react/issues/11726
it('should not focus on either server or client with autofocus={false} even if there is a markup mismatch', () => {
const element = document.createElement('div');
element.innerHTML = ReactDOMServer.renderToString(
<button autoFocus={false}>server</button>,
);
expect(element.firstChild.autofocus).toBe(false);
element.firstChild.focus = jest.fn();
expect(() =>
ReactDOM.hydrate(<button autoFocus={false}>client</button>, element),
).toErrorDev(
'Warning: Text content did not match. Server: "server" Client: "client"',
);
expect(element.firstChild.focus).not.toHaveBeenCalled();
});
it('should warn when the style property differs', () => {
const element = document.createElement('div');
element.innerHTML = ReactDOMServer.renderToString(
<div style={{textDecoration: 'none', color: 'black', height: '10px'}} />,
);
expect(element.firstChild.style.textDecoration).toBe('none');
expect(element.firstChild.style.color).toBe('black');
expect(() =>
ReactDOM.hydrate(
<div
style={{textDecoration: 'none', color: 'white', height: '10px'}}
/>,
element,
),
).toErrorDev(
'Warning: Prop `style` did not match. Server: ' +
'"text-decoration:none;color:black;height:10px" Client: ' +
'"text-decoration:none;color:white;height:10px"',
);
});
// @gate !disableIEWorkarounds || !__DEV__
it('should not warn when the style property differs on whitespace or order in IE', () => {
document.documentMode = 11;
jest.resetModules();
React = require('react');
ReactDOM = require('react-dom');
ReactDOMServer = require('react-dom/server');
try {
const element = document.createElement('div');
// Simulate IE normalizing the style attribute. IE makes it equal to
// what's available under `node.style.cssText`.
element.innerHTML =
'<div style="height: 10px; color: black; text-decoration: none;"></div>';
// We don't expect to see false positive warnings.
// https://github.com/facebook/react/issues/11807
ReactDOM.hydrate(
<div
style={{textDecoration: 'none', color: 'black', height: '10px'}}
/>,
element,
);
} finally {
delete document.documentMode;
}
});
it('should warn when the style property differs on whitespace in non-IE browsers', () => {
const element = document.createElement('div');
element.innerHTML =
'<div style="text-decoration: none; color: black; height: 10px;"></div>';
expect(() =>
ReactDOM.hydrate(
<div
style={{textDecoration: 'none', color: 'black', height: '10px'}}
/>,
element,
),
).toErrorDev(
'Warning: Prop `style` did not match. Server: ' +
'"text-decoration: none; color: black; height: 10px;" Client: ' +
'"text-decoration:none;color:black;height:10px"',
);
});
it('should throw rendering portals on the server', () => {
const div = document.createElement('div');
expect(() => {
ReactDOMServer.renderToString(
<div>{ReactDOM.createPortal(<div />, div)}</div>,
);
}).toThrow(
'Portals are not currently supported by the server renderer. ' +
'Render them conditionally so that they only appear on the client render.',
);
});
it('should be able to render and hydrate Mode components', () => {
class ComponentWithWarning extends React.Component {
componentWillMount() {
// Expected warning
}
render() {
return 'Hi';
}
}
const markup = (
<React.StrictMode>
<ComponentWithWarning />
</React.StrictMode>
);
const element = document.createElement('div');
expect(() => {
element.innerHTML = ReactDOMServer.renderToString(markup);
}).toWarnDev('componentWillMount has been renamed');
expect(element.textContent).toBe('Hi');
expect(() => {
ReactDOM.hydrate(markup, element);
}).toWarnDev('componentWillMount has been renamed', {
withoutStack: true,
});
expect(element.textContent).toBe('Hi');
});
it('should be able to render and hydrate forwardRef components', () => {
const FunctionComponent = ({label, forwardedRef}) => (
<div ref={forwardedRef}>{label}</div>
);
const WrappedFunctionComponent = React.forwardRef((props, ref) => (
<FunctionComponent {...props} forwardedRef={ref} />
));
const ref = React.createRef();
const markup = <WrappedFunctionComponent ref={ref} label="Hi" />;
const element = document.createElement('div');
element.innerHTML = ReactDOMServer.renderToString(markup);
expect(element.textContent).toBe('Hi');
expect(ref.current).toBe(null);
ReactDOM.hydrate(markup, element);
expect(element.textContent).toBe('Hi');
expect(ref.current.tagName).toBe('DIV');
});
it('should be able to render and hydrate Profiler components', () => {
const callback = jest.fn();
const markup = (
<React.Profiler id="profiler" onRender={callback}>
<div>Hi</div>
</React.Profiler>
);
const element = document.createElement('div');
element.innerHTML = ReactDOMServer.renderToString(markup);
expect(element.textContent).toBe('Hi');
expect(callback).not.toHaveBeenCalled();
ReactDOM.hydrate(markup, element);
expect(element.textContent).toBe('Hi');
if (__DEV__) {
expect(callback).toHaveBeenCalledTimes(1);
const [id, phase] = callback.mock.calls[0];
expect(id).toBe('profiler');
expect(phase).toBe('mount');
} else {
expect(callback).toHaveBeenCalledTimes(0);
}
});
// Regression test for https://github.com/facebook/react/issues/11423
it('should ignore noscript content on the client and not warn about mismatches', () => {
const callback = jest.fn();
const TestComponent = ({onRender}) => {
onRender();
return <div>Enable JavaScript to run this app.</div>;
};
const markup = (
<noscript>
<TestComponent onRender={callback} />
</noscript>
);
const element = document.createElement('div');
element.innerHTML = ReactDOMServer.renderToString(markup);
expect(callback).toHaveBeenCalledTimes(1);
expect(element.textContent).toBe(
'<div>Enable JavaScript to run this app.</div>',
);
// On the client we want to keep the existing markup, but not render the
// actual elements for performance reasons and to avoid for example
// downloading images. This should also not warn for hydration mismatches.
ReactDOM.hydrate(markup, element);
expect(callback).toHaveBeenCalledTimes(1);
expect(element.textContent).toBe(
'<div>Enable JavaScript to run this app.</div>',
);
});
it('should be able to use lazy components after hydrating', async () => {
const Lazy = React.lazy(
() =>
new Promise(resolve => {
setTimeout(
() =>
resolve({
default: function World() {
return 'world';
},
}),
1000,
);
}),
);
class HelloWorld extends React.Component {
state = {isClient: false};
componentDidMount() {
this.setState({
isClient: true,
});
}
render() {
return (
<div>
Hello{' '}
{this.state.isClient && (
<React.Suspense fallback="loading">
<Lazy />
</React.Suspense>
)}
</div>
);
}
}
const element = document.createElement('div');
element.innerHTML = ReactDOMServer.renderToString(<HelloWorld />);
expect(element.textContent).toBe('Hello ');
ReactDOM.hydrate(<HelloWorld />, element);
expect(element.textContent).toBe('Hello loading');
// Resolve Lazy component
await act(() => jest.runAllTimers());
expect(element.textContent).toBe('Hello world');
});
it('does not re-enter hydration after committing the first one', async () => {
const finalHTML = ReactDOMServer.renderToString(<div />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const root = await act(() =>
ReactDOMClient.hydrateRoot(container, <div />),
);
await act(() => root.render(null));
// This should not reenter hydration state and therefore not trigger hydration
// warnings.
await act(() => root.render(<div />));
});
it('Suspense + hydration in legacy mode', () => {
const element = document.createElement('div');
element.innerHTML = '<div><div>Hello World</div></div>';
const div = element.firstChild.firstChild;
const ref = React.createRef();
expect(() =>
ReactDOM.hydrate(
<div>
<React.Suspense fallback={null}>
<div ref={ref}>Hello World</div>
</React.Suspense>
</div>,
element,
),
).toErrorDev(
'Warning: Did not expect server HTML to contain a <div> in <div>.',
);
// The content should've been client rendered and replaced the
// existing div.
expect(ref.current).not.toBe(div);
// The HTML should be the same though.
expect(element.innerHTML).toBe('<div><div>Hello World</div></div>');
});
it('Suspense + hydration in legacy mode (at root)', () => {
const element = document.createElement('div');
element.innerHTML = '<div>Hello World</div>';
const div = element.firstChild;
const ref = React.createRef();
ReactDOM.hydrate(
<React.Suspense fallback={null}>
<div ref={ref}>Hello World</div>
</React.Suspense>,
element,
);
// The content should've been client rendered.
expect(ref.current).not.toBe(div);
// Unfortunately, since we don't delete the tail at the root, a duplicate will remain.
expect(element.innerHTML).toBe(
'<div>Hello World</div><div>Hello World</div>',
);
});
it('Suspense + hydration in legacy mode with no fallback', () => {
const element = document.createElement('div');
element.innerHTML = '<div>Hello World</div>';
const div = element.firstChild;
const ref = React.createRef();
ReactDOM.hydrate(
<React.Suspense>
<div ref={ref}>Hello World</div>
</React.Suspense>,
element,
);
// The content should've been client rendered.
expect(ref.current).not.toBe(div);
// Unfortunately, since we don't delete the tail at the root, a duplicate will remain.
expect(element.innerHTML).toBe(
'<div>Hello World</div><div>Hello World</div>',
);
});
// regression test for https://github.com/facebook/react/issues/17170
it('should not warn if dangerouslySetInnerHtml=undefined', () => {
const domElement = document.createElement('div');
const reactElement = (
<div dangerouslySetInnerHTML={undefined}>
<p>Hello, World!</p>
</div>
);
const markup = ReactDOMServer.renderToStaticMarkup(reactElement);
domElement.innerHTML = markup;
ReactDOM.hydrate(reactElement, domElement);
expect(domElement.innerHTML).toEqual(markup);
});
it('should warn if innerHTML mismatches with dangerouslySetInnerHTML=undefined and children on the client', () => {
const domElement = document.createElement('div');
const markup = ReactDOMServer.renderToStaticMarkup(
<div dangerouslySetInnerHTML={{__html: '<p>server</p>'}} />,
);
domElement.innerHTML = markup;
expect(() => {
ReactDOM.hydrate(
<div dangerouslySetInnerHTML={undefined}>
<p>client</p>
</div>,
domElement,
);
expect(domElement.innerHTML).not.toEqual(markup);
}).toErrorDev(
'Warning: Text content did not match. Server: "server" Client: "client"',
);
});
it('should warn if innerHTML mismatches with dangerouslySetInnerHTML=undefined on the client', () => {
const domElement = document.createElement('div');
const markup = ReactDOMServer.renderToStaticMarkup(
<div dangerouslySetInnerHTML={{__html: '<p>server</p>'}} />,
);
domElement.innerHTML = markup;
expect(() => {
ReactDOM.hydrate(<div dangerouslySetInnerHTML={undefined} />, domElement);
expect(domElement.innerHTML).not.toEqual(markup);
}).toErrorDev(
'Warning: Did not expect server HTML to contain a <p> in <div>',
);
});
it('should warn when hydrating read-only properties', () => {
const readOnlyProperties = [
'offsetParent',
'offsetTop',
'offsetLeft',
'offsetWidth',
'offsetHeight',
'isContentEditable',
'outerText',
'outerHTML',
];
readOnlyProperties.forEach(readOnlyProperty => {
const props = {};
props[readOnlyProperty] = 'hello';
const jsx = React.createElement('my-custom-element', props);
const element = document.createElement('div');
element.innerHTML = ReactDOMServer.renderToString(jsx);
if (gate(flags => flags.enableCustomElementPropertySupport)) {
expect(() => ReactDOM.hydrate(jsx, element)).toErrorDev(
`Warning: Assignment to read-only property will result in a no-op: \`${readOnlyProperty}\``,
);
} else {
ReactDOM.hydrate(jsx, element);
}
});
});
// @gate enableCustomElementPropertySupport
it('should not re-assign properties on hydration', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const jsx = React.createElement('my-custom-element', {
str: 'string',
obj: {foo: 'bar'},
});
container.innerHTML = ReactDOMServer.renderToString(jsx);
const customElement = container.querySelector('my-custom-element');
// Install setters to activate `in` check
Object.defineProperty(customElement, 'str', {
set: function (x) {
this._str = x;
},
get: function () {
return this._str;
},
});
Object.defineProperty(customElement, 'obj', {
set: function (x) {
this._obj = x;
},
get: function () {
return this._obj;
},
});
ReactDOM.hydrate(jsx, container);
expect(customElement.getAttribute('str')).toBe('string');
expect(customElement.getAttribute('obj')).toBe(null);
expect(customElement.str).toBe(undefined);
expect(customElement.obj).toBe(undefined);
});
it('refers users to apis that support Suspense when something suspends', async () => {
const theInfinitePromise = new Promise(() => {});
function InfiniteSuspend() {
throw theInfinitePromise;
}
function App({isClient}) {
return (
<div>
<React.Suspense fallback={'fallback'}>
{isClient ? 'resolved' : <InfiniteSuspend />}
</React.Suspense>
</div>
);
}
const container = document.createElement('div');
container.innerHTML = ReactDOMServer.renderToString(
<App isClient={false} />,
);
const errors = [];
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
onRecoverableError(error, errorInfo) {
errors.push(error.message);
},
});
await waitForAll([]);
expect(errors.length).toBe(1);
if (__DEV__) {
expect(errors[0]).toBe(
'The server did not finish this Suspense boundary: The server used "renderToString" ' +
'which does not support Suspense. If you intended for this Suspense boundary to render ' +
'the fallback content on the server consider throwing an Error somewhere within the ' +
'Suspense boundary. If you intended to have the server wait for the suspended component ' +
'please switch to "renderToPipeableStream" which supports Suspense on the server',
);
} else {
expect(errors[0]).toBe(
'The server could not finish this Suspense boundary, likely due to ' +
'an error during server rendering. Switched to client rendering.',
);
}
});
it('refers users to apis that support Suspense when something suspends (browser)', async () => {
const theInfinitePromise = new Promise(() => {});
function InfiniteSuspend() {
throw theInfinitePromise;
}
function App({isClient}) {
return (
<div>
<React.Suspense fallback={'fallback'}>
{isClient ? 'resolved' : <InfiniteSuspend />}
</React.Suspense>
</div>
);
}
const container = document.createElement('div');
container.innerHTML = ReactDOMServerBrowser.renderToString(
<App isClient={false} />,
);
const errors = [];
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
onRecoverableError(error, errorInfo) {
errors.push(error.message);
},
});
await waitForAll([]);
expect(errors.length).toBe(1);
if (__DEV__) {
expect(errors[0]).toBe(
'The server did not finish this Suspense boundary: The server used "renderToString" ' +
'which does not support Suspense. If you intended for this Suspense boundary to render ' +
'the fallback content on the server consider throwing an Error somewhere within the ' +
'Suspense boundary. If you intended to have the server wait for the suspended component ' +
'please switch to "renderToReadableStream" which supports Suspense on the server',
);
} else {
expect(errors[0]).toBe(
'The server could not finish this Suspense boundary, likely due to ' +
'an error during server rendering. Switched to client rendering.',
);
}
});
// @gate enableFormActions
it('allows rendering extra hidden inputs in a form', async () => {
const element = document.createElement('div');
element.innerHTML =
'<form>' +
'<input type="hidden" /><input type="hidden" name="a" value="A" />' +
'<input type="hidden" /><input type="submit" name="b" value="B" />' +
'<input type="hidden" /><button name="c" value="C"></button>' +
'<input type="hidden" />' +
'</form>';
const form = element.firstChild;
const ref = React.createRef();
const a = React.createRef();
const b = React.createRef();
const c = React.createRef();
await act(async () => {
ReactDOMClient.hydrateRoot(
element,
<form ref={ref}>
<input type="hidden" name="a" value="A" ref={a} />
<input type="submit" name="b" value="B" ref={b} />
<button name="c" value="C" ref={c} />
</form>,
);
});
// The content should not have been client rendered.
expect(ref.current).toBe(form);
expect(a.current.name).toBe('a');
expect(a.current.value).toBe('A');
expect(b.current.name).toBe('b');
expect(b.current.value).toBe('B');
expect(c.current.name).toBe('c');
expect(c.current.value).toBe('C');
});
// @gate enableFormActions
it('allows rendering extra hidden inputs immediately before a text instance', async () => {
const element = document.createElement('div');
element.innerHTML =
'<button><input name="a" value="A" type="hidden" />Click <!-- -->me</button>';
const button = element.firstChild;
const ref = React.createRef();
const extraText = 'me';
await act(() => {
ReactDOMClient.hydrateRoot(
element,
<button ref={ref}>Click {extraText}</button>,
);
});
expect(ref.current).toBe(button);
});
});