mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
Stacked on #28476. We used to `console.error` for every mismatch we found, up until the error we threw for the hydration mismatch. This changes it so that we build up a set of diffs up until we either throw or complete hydrating the root/suspense boundary. If we throw, we append the diff to the error message which gets passed to onRecoverableError (which by default is also logged to console). If we complete, we append it to a `console.error`. Since we early abort when something throws, it effectively means that we can only collect multiple diffs if there were preceding non-throwing mismatches - i.e. only properties mismatched but tag name matched. There can still be multiple logs if multiple siblings Suspense boundaries all error hydrating but then they're separate errors entirely. We still log an extra line about something erroring but I think the goal should be that it leads to a single recoverable or console.error. This doesn't yet actually print the diff as part of this message. That's in a follow up PR.
285 lines
8.2 KiB
JavaScript
285 lines
8.2 KiB
JavaScript
/**
|
|
* 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
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
describe('ReactDOMOption', () => {
|
|
let React;
|
|
let ReactDOMClient;
|
|
let ReactDOMServer;
|
|
let act;
|
|
|
|
beforeEach(() => {
|
|
jest.resetModules();
|
|
React = require('react');
|
|
ReactDOMClient = require('react-dom/client');
|
|
ReactDOMServer = require('react-dom/server');
|
|
act = require('internal-test-utils').act;
|
|
});
|
|
|
|
async function renderIntoDocument(children) {
|
|
const container = document.createElement('div');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(async () => root.render(children));
|
|
return container;
|
|
}
|
|
|
|
it('should flatten children to a string', async () => {
|
|
const stub = (
|
|
<option>
|
|
{1} {'foo'}
|
|
</option>
|
|
);
|
|
const container = await renderIntoDocument(stub);
|
|
|
|
expect(container.firstChild.innerHTML).toBe('1 foo');
|
|
});
|
|
|
|
it('should warn for invalid child tags', async () => {
|
|
const el = (
|
|
<option value="12">
|
|
{1} <div /> {2}
|
|
</option>
|
|
);
|
|
let container;
|
|
await expect(async () => {
|
|
container = await renderIntoDocument(el);
|
|
}).toErrorDev(
|
|
'In HTML, <div> cannot be a child of <option>.\n' +
|
|
'This will cause a hydration error.\n' +
|
|
' in div (at **)\n' +
|
|
' in option (at **)',
|
|
);
|
|
expect(container.firstChild.innerHTML).toBe('1 <div></div> 2');
|
|
await renderIntoDocument(el);
|
|
});
|
|
|
|
it('should warn for component child if no value prop is provided', async () => {
|
|
function Foo() {
|
|
return '2';
|
|
}
|
|
const el = (
|
|
<option>
|
|
{1} <Foo /> {3}
|
|
</option>
|
|
);
|
|
let container;
|
|
await expect(async () => {
|
|
container = await renderIntoDocument(el);
|
|
}).toErrorDev(
|
|
'Cannot infer the option value of complex children. ' +
|
|
'Pass a `value` prop or use a plain string as children to <option>.',
|
|
);
|
|
expect(container.firstChild.innerHTML).toBe('1 2 3');
|
|
await renderIntoDocument(el);
|
|
});
|
|
|
|
it('should not warn for component child if value prop is provided', async () => {
|
|
function Foo() {
|
|
return '2';
|
|
}
|
|
const el = (
|
|
<option value="123">
|
|
{1} <Foo /> {3}
|
|
</option>
|
|
);
|
|
const container = await renderIntoDocument(el);
|
|
expect(container.firstChild.innerHTML).toBe('1 2 3');
|
|
await renderIntoDocument(el);
|
|
});
|
|
|
|
it('should ignore null/undefined/false children without warning', async () => {
|
|
const stub = (
|
|
<option>
|
|
{1} {false}
|
|
{true}
|
|
{null}
|
|
{undefined} {2}
|
|
</option>
|
|
);
|
|
const container = await renderIntoDocument(stub);
|
|
|
|
expect(container.firstChild.innerHTML).toBe('1 2');
|
|
});
|
|
|
|
it('should throw on object children', async () => {
|
|
await expect(async () =>
|
|
renderIntoDocument(<option>{{}}</option>),
|
|
).rejects.toThrow('Objects are not valid as a React child');
|
|
await expect(async () => {
|
|
await renderIntoDocument(<option>{[{}]}</option>);
|
|
}).rejects.toThrow('Objects are not valid as a React child');
|
|
await expect(async () => {
|
|
await renderIntoDocument(
|
|
<option>
|
|
{{}}
|
|
<span />
|
|
</option>,
|
|
);
|
|
}).rejects.toThrow('Objects are not valid as a React child');
|
|
await expect(async () => {
|
|
await renderIntoDocument(
|
|
<option>
|
|
{'1'}
|
|
{{}}
|
|
{2}
|
|
</option>,
|
|
);
|
|
}).rejects.toThrow('Objects are not valid as a React child');
|
|
});
|
|
|
|
it('should support element-ish child', async () => {
|
|
// This is similar to <fbt>.
|
|
// We don't toString it because you must instead provide a value prop.
|
|
const obj = {
|
|
$$typeof: Symbol.for('react.element'),
|
|
type: props => props.content,
|
|
ref: null,
|
|
key: null,
|
|
props: {
|
|
content: 'hello',
|
|
},
|
|
toString() {
|
|
return this.props.content;
|
|
},
|
|
};
|
|
|
|
let container = await renderIntoDocument(<option value="a">{obj}</option>);
|
|
expect(container.firstChild.innerHTML).toBe('hello');
|
|
|
|
container = await renderIntoDocument(<option value="b">{[obj]}</option>);
|
|
expect(container.firstChild.innerHTML).toBe('hello');
|
|
|
|
container = await renderIntoDocument(<option value={obj}>{obj}</option>);
|
|
expect(container.firstChild.innerHTML).toBe('hello');
|
|
expect(container.firstChild.value).toBe('hello');
|
|
|
|
container = await renderIntoDocument(
|
|
<option value={obj}>
|
|
{'1'}
|
|
{obj}
|
|
{2}
|
|
</option>,
|
|
);
|
|
expect(container.firstChild.innerHTML).toBe('1hello2');
|
|
expect(container.firstChild.value).toBe('hello');
|
|
});
|
|
|
|
// @gate enableBigIntSupport
|
|
it('should support bigint values', async () => {
|
|
const container = await renderIntoDocument(<option>{5n}</option>);
|
|
expect(container.firstChild.innerHTML).toBe('5');
|
|
expect(container.firstChild.value).toBe('5');
|
|
});
|
|
|
|
it('should be able to use dangerouslySetInnerHTML on option', async () => {
|
|
const stub = <option dangerouslySetInnerHTML={{__html: 'foobar'}} />;
|
|
let container;
|
|
await expect(async () => {
|
|
container = await renderIntoDocument(stub);
|
|
}).toErrorDev(
|
|
'Pass a `value` prop if you set dangerouslyInnerHTML so React knows which value should be selected.\n' +
|
|
' in option (at **)',
|
|
);
|
|
|
|
expect(container.firstChild.innerHTML).toBe('foobar');
|
|
});
|
|
|
|
it('should set attribute for empty value', async () => {
|
|
const container = document.createElement('div');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
let option;
|
|
await act(() => {
|
|
root.render(<option value="" />);
|
|
});
|
|
option = container.firstChild;
|
|
expect(option.hasAttribute('value')).toBe(true);
|
|
expect(option.getAttribute('value')).toBe('');
|
|
|
|
await act(() => {
|
|
root.render(<option value="lava" />);
|
|
});
|
|
option = container.firstChild;
|
|
expect(option.hasAttribute('value')).toBe(true);
|
|
expect(option.getAttribute('value')).toBe('lava');
|
|
});
|
|
|
|
it('should allow ignoring `value` on option', async () => {
|
|
const a = 'a';
|
|
let node;
|
|
const stub = (
|
|
<select value="giraffe" onChange={() => {}}>
|
|
<option>monkey</option>
|
|
<option>gir{a}ffe</option>
|
|
<option>gorill{a}</option>
|
|
</select>
|
|
);
|
|
const options = stub.props.children;
|
|
const container = document.createElement('div');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(stub);
|
|
});
|
|
node = container.firstChild;
|
|
|
|
expect(node.selectedIndex).toBe(1);
|
|
|
|
await act(() => {
|
|
root.render(<select value="gorilla">{options}</select>);
|
|
});
|
|
node = container.firstChild;
|
|
expect(node.selectedIndex).toEqual(2);
|
|
});
|
|
|
|
it('generates a warning and hydration error when an invalid nested tag is used as a child', async () => {
|
|
const ref = React.createRef();
|
|
const children = (
|
|
<select readOnly={true} value="bar">
|
|
<option value="bar">
|
|
{['Bar', false, 'Foo', <div key="1" ref={ref} />, 'Baz']}
|
|
</option>
|
|
</select>
|
|
);
|
|
|
|
const container = document.createElement('div');
|
|
|
|
container.innerHTML = ReactDOMServer.renderToString(children);
|
|
|
|
expect(container.firstChild.getAttribute('value')).toBe(null);
|
|
expect(container.firstChild.getAttribute('defaultValue')).toBe(null);
|
|
|
|
let option = container.firstChild.firstChild;
|
|
expect(option.nodeName).toBe('OPTION');
|
|
|
|
expect(option.textContent).toBe('BarFooBaz');
|
|
expect(option.selected).toBe(true);
|
|
|
|
await expect(async () => {
|
|
await act(async () => {
|
|
ReactDOMClient.hydrateRoot(container, children, {
|
|
onRecoverableError: () => {},
|
|
});
|
|
});
|
|
}).toErrorDev(
|
|
[
|
|
'Warning: An error occurred during hydration. The server HTML was replaced with client content.',
|
|
'Warning: In HTML, <div> cannot be a child of <option>',
|
|
],
|
|
{withoutStack: 1},
|
|
);
|
|
option = container.firstChild.firstChild;
|
|
|
|
expect(option.textContent).toBe('BarFooBaz');
|
|
expect(option.selected).toBe(true);
|
|
|
|
expect(ref.current.nodeName).toBe('DIV');
|
|
expect(ref.current.parentNode).toBe(option);
|
|
});
|
|
});
|