mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
6396b66411
## Hoistables In the original implementation of Float, all hoisted elements were treated like Resources. They had deduplication semantics and hydrated based on a key. This made certain kinds of hoists very challenging such as sequences of meta tags for `og:image:...` metadata. The reason is each tag along is not dedupable based on only it's intrinsic properties. two identical tags may need to be included and hoisted together with preceding meta tags that describe a semantic object with a linear set of html nodes. It was clear that the concept of Browser Resources (stylesheets / scripts / preloads) did not extend universally to all hositable tags (title, meta, other links, etc...) Additionally while Resources benefit from deduping they suffer an inability to update because while we may have multiple rendered elements that refer to a single Resource it isn't unambiguous which element owns the props on the underlying resource. We could try merging props, but that is still really hard to reason about for authors. Instead we restrict Resource semantics to freezing the props at the time the Resource is first constructed and warn if you attempt to render the same Resource with different props via another rendered element or by updating an existing element for that Resource. This lack of updating restriction is however way more extreme than necessary for instances that get hoisted but otherwise do not dedupe; where there is a well defined DOM instance for each rendered element. We should be able to update props on these instances. Hoistable is a generalization of what Float tries to model for hoisting. Instead of assuming every hoistable element is a Resource we now have two distinct categories, hoistable elements and hoistable resources. As one might guess the former has semantics that match regular Host Components except the placement of the node is usually in the <head>. The latter continues to behave how the original implementation of HostResource behaved with the first iteration of Float ### Hoistable Element On the server hoistable elements render just like regular tags except the output is stored in special queues that can be emitted in the stream earlier than they otherwise would be if rendered in place. This also allow for instance the ability to render a hoistable before even rendering the <html> tag because the queues for hoistable elements won't flush until after we have flushed the preamble (`<DOCTYPE html><html><head>`). On the client, hoistable elements largely operate like HostComponents. The most notable difference is in the hydration strategy. If we are hydrating and encounter a hoistable element we will look for all tags in the document that could potentially be a match and we check whether the attributes match the props for this particular instance. We also do this in the commit phase rather than the render phase. The reason hydration can be done for HostComponents in render is the instance will be removed from the document if hydration fails so mutating it in render is safe. For hoistables the nodes are not in a hydration boundary (Root or SuspenseBoundary at time of writing) and thus if hydration fails and we may have an instance marked as bound to some Fiber when that Fiber never commits. Moving the hydration matching to commit ensures we will always succeed in pairing the hoisted DOM instance with a Fiber that has committed. ### Hoistable Resource On the server and client the semantics of Resources are largely the same they just don't apply to title, meta, and most link tags anymore. Resources hoist and dedupe via an `href` key and are ref counted. In a future update we will add a garbage collector so we can clean up Resources that no longer have any references ## `<style>` support In earlier implementations there was no support for <style> tags. This PR adds support for treating `<style href="..." precedence="...">...</style>` as a Resource analagous to `<link rel="stylesheet" href="..." precedence="..." />` It may seem odd at first to require an href to get Resource semantics for a style tag. The rationale is that these are for inlining of actual external stylesheets as an optimization and for URI like scoping of inline styles for css-in-js libraries. The href indicates that the key space for `<style>` and `<link rel="stylesheet" />` Resources is shared. and the precedence is there to allow for interleaving of both kinds of Style resources. This is an advanced feature that we do not expect most app developers to use directly but will be quite handy for various styling libraries and for folks who want to inline as much as possible once Fizz supports this feature. ## refactor notes * HostResource Fiber type is renamed HostHoistable to reflect the generalization of the concept * The Resource object representation is modified to reduce hidden class checks and to use less memory overall * The thing that distinguishes a resource from an element is whether the Fiber has a memoizedState. If it does, it will use resource semantics, otherwise element semantics * The time complexity of matching hositable elements for hydration should be improved
514 lines
16 KiB
JavaScript
514 lines
16 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';
|
|
|
|
let React = require('react');
|
|
let ReactDOM = require('react-dom');
|
|
let ReactDOMClient = require('react-dom/client');
|
|
let ReactDOMServer = require('react-dom/server');
|
|
let Scheduler = require('scheduler');
|
|
let act;
|
|
let useEffect;
|
|
|
|
describe('ReactDOMRoot', () => {
|
|
let container;
|
|
|
|
beforeEach(() => {
|
|
jest.resetModules();
|
|
container = document.createElement('div');
|
|
React = require('react');
|
|
ReactDOM = require('react-dom');
|
|
ReactDOMClient = require('react-dom/client');
|
|
ReactDOMServer = require('react-dom/server');
|
|
Scheduler = require('scheduler');
|
|
act = require('jest-react').act;
|
|
useEffect = React.useEffect;
|
|
});
|
|
|
|
it('renders children', () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<div>Hi</div>);
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('Hi');
|
|
});
|
|
|
|
it('warns if you import createRoot from react-dom', async () => {
|
|
expect(() => ReactDOM.createRoot(container)).toErrorDev(
|
|
'You are importing createRoot from "react-dom" which is not supported. ' +
|
|
'You should instead import it from "react-dom/client".',
|
|
{
|
|
withoutStack: true,
|
|
},
|
|
);
|
|
});
|
|
|
|
it('warns if you import hydrateRoot from react-dom', async () => {
|
|
expect(() => ReactDOM.hydrateRoot(container, null)).toErrorDev(
|
|
'You are importing hydrateRoot from "react-dom" which is not supported. ' +
|
|
'You should instead import it from "react-dom/client".',
|
|
{
|
|
withoutStack: true,
|
|
},
|
|
);
|
|
});
|
|
|
|
it('warns if a callback parameter is provided to render', () => {
|
|
const callback = jest.fn();
|
|
const root = ReactDOMClient.createRoot(container);
|
|
expect(() => root.render(<div>Hi</div>, callback)).toErrorDev(
|
|
'render(...): does not support the second callback argument. ' +
|
|
'To execute a side effect after rendering, declare it in a component body with useEffect().',
|
|
{withoutStack: true},
|
|
);
|
|
Scheduler.unstable_flushAll();
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('warn if a container is passed to root.render(...)', async () => {
|
|
function App() {
|
|
return 'Child';
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(container);
|
|
expect(() => root.render(<App />, {})).toErrorDev(
|
|
'You passed a second argument to root.render(...) but it only accepts ' +
|
|
'one argument.',
|
|
{
|
|
withoutStack: true,
|
|
},
|
|
);
|
|
});
|
|
|
|
it('warn if a container is passed to root.render(...)', async () => {
|
|
function App() {
|
|
return 'Child';
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(container);
|
|
expect(() => root.render(<App />, container)).toErrorDev(
|
|
'You passed a container to the second argument of root.render(...). ' +
|
|
"You don't need to pass it again since you already passed it to create " +
|
|
'the root.',
|
|
{
|
|
withoutStack: true,
|
|
},
|
|
);
|
|
});
|
|
|
|
it('warns if a callback parameter is provided to unmount', () => {
|
|
const callback = jest.fn();
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<div>Hi</div>);
|
|
expect(() => root.unmount(callback)).toErrorDev(
|
|
'unmount(...): does not support a callback argument. ' +
|
|
'To execute a side effect after rendering, declare it in a component body with useEffect().',
|
|
{withoutStack: true},
|
|
);
|
|
Scheduler.unstable_flushAll();
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('unmounts children', () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<div>Hi</div>);
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('Hi');
|
|
root.unmount();
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('');
|
|
});
|
|
|
|
it('supports hydration', async () => {
|
|
const markup = await new Promise(resolve =>
|
|
resolve(
|
|
ReactDOMServer.renderToString(
|
|
<div>
|
|
<span className="extra" />
|
|
</div>,
|
|
),
|
|
),
|
|
);
|
|
|
|
// Does not hydrate by default
|
|
const container1 = document.createElement('div');
|
|
container1.innerHTML = markup;
|
|
const root1 = ReactDOMClient.createRoot(container1);
|
|
root1.render(
|
|
<div>
|
|
<span />
|
|
</div>,
|
|
);
|
|
Scheduler.unstable_flushAll();
|
|
|
|
const container2 = document.createElement('div');
|
|
container2.innerHTML = markup;
|
|
ReactDOMClient.hydrateRoot(
|
|
container2,
|
|
<div>
|
|
<span />
|
|
</div>,
|
|
);
|
|
expect(() => Scheduler.unstable_flushAll()).toErrorDev('Extra attributes');
|
|
});
|
|
|
|
it('clears existing children with legacy API', async () => {
|
|
container.innerHTML = '<div>a</div><div>b</div>';
|
|
ReactDOM.render(
|
|
<div>
|
|
<span>c</span>
|
|
<span>d</span>
|
|
</div>,
|
|
container,
|
|
);
|
|
expect(container.textContent).toEqual('cd');
|
|
ReactDOM.render(
|
|
<div>
|
|
<span>d</span>
|
|
<span>c</span>
|
|
</div>,
|
|
container,
|
|
);
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('dc');
|
|
});
|
|
|
|
it('clears existing children', async () => {
|
|
container.innerHTML = '<div>a</div><div>b</div>';
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(
|
|
<div>
|
|
<span>c</span>
|
|
<span>d</span>
|
|
</div>,
|
|
);
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('cd');
|
|
root.render(
|
|
<div>
|
|
<span>d</span>
|
|
<span>c</span>
|
|
</div>,
|
|
);
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('dc');
|
|
});
|
|
|
|
it('throws a good message on invalid containers', () => {
|
|
expect(() => {
|
|
ReactDOMClient.createRoot(<div>Hi</div>);
|
|
}).toThrow('createRoot(...): Target container is not a DOM element.');
|
|
});
|
|
|
|
it('warns when rendering with legacy API into createRoot() container', () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<div>Hi</div>);
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('Hi');
|
|
expect(() => {
|
|
ReactDOM.render(<div>Bye</div>, container);
|
|
}).toErrorDev(
|
|
[
|
|
// We care about this warning:
|
|
'You are calling ReactDOM.render() on a container that was previously ' +
|
|
'passed to ReactDOMClient.createRoot(). This is not supported. ' +
|
|
'Did you mean to call root.render(element)?',
|
|
// This is more of a symptom but restructuring the code to avoid it isn't worth it:
|
|
'Replacing React-rendered children with a new root component.',
|
|
],
|
|
{withoutStack: true},
|
|
);
|
|
Scheduler.unstable_flushAll();
|
|
// This works now but we could disallow it:
|
|
expect(container.textContent).toEqual('Bye');
|
|
});
|
|
|
|
it('warns when hydrating with legacy API into createRoot() container', () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<div>Hi</div>);
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('Hi');
|
|
expect(() => {
|
|
ReactDOM.hydrate(<div>Hi</div>, container);
|
|
}).toErrorDev(
|
|
[
|
|
// We care about this warning:
|
|
'You are calling ReactDOM.hydrate() on a container that was previously ' +
|
|
'passed to ReactDOMClient.createRoot(). This is not supported. ' +
|
|
'Did you mean to call hydrateRoot(container, element)?',
|
|
// This is more of a symptom but restructuring the code to avoid it isn't worth it:
|
|
'Replacing React-rendered children with a new root component.',
|
|
],
|
|
{withoutStack: true},
|
|
);
|
|
});
|
|
|
|
it('callback passed to legacy hydrate() API', () => {
|
|
container.innerHTML = '<div>Hi</div>';
|
|
ReactDOM.hydrate(<div>Hi</div>, container, () => {
|
|
Scheduler.unstable_yieldValue('callback');
|
|
});
|
|
expect(container.textContent).toEqual('Hi');
|
|
expect(Scheduler).toHaveYielded(['callback']);
|
|
});
|
|
|
|
it('warns when unmounting with legacy API (no previous content)', () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<div>Hi</div>);
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('Hi');
|
|
let unmounted = false;
|
|
expect(() => {
|
|
unmounted = ReactDOM.unmountComponentAtNode(container);
|
|
}).toErrorDev(
|
|
[
|
|
// We care about this warning:
|
|
'You are calling ReactDOM.unmountComponentAtNode() on a container that was previously ' +
|
|
'passed to ReactDOMClient.createRoot(). This is not supported. Did you mean to call root.unmount()?',
|
|
// This is more of a symptom but restructuring the code to avoid it isn't worth it:
|
|
"The node you're attempting to unmount was rendered by React and is not a top-level container.",
|
|
],
|
|
{withoutStack: true},
|
|
);
|
|
expect(unmounted).toBe(false);
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('Hi');
|
|
root.unmount();
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('');
|
|
});
|
|
|
|
it('warns when unmounting with legacy API (has previous content)', () => {
|
|
// Currently createRoot().render() doesn't clear this.
|
|
container.appendChild(document.createElement('div'));
|
|
// The rest is the same as test above.
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<div>Hi</div>);
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('Hi');
|
|
let unmounted = false;
|
|
expect(() => {
|
|
unmounted = ReactDOM.unmountComponentAtNode(container);
|
|
}).toErrorDev(
|
|
[
|
|
'Did you mean to call root.unmount()?',
|
|
// This is more of a symptom but restructuring the code to avoid it isn't worth it:
|
|
"The node you're attempting to unmount was rendered by React and is not a top-level container.",
|
|
],
|
|
{withoutStack: true},
|
|
);
|
|
expect(unmounted).toBe(false);
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('Hi');
|
|
root.unmount();
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('');
|
|
});
|
|
|
|
it('warns when passing legacy container to createRoot()', () => {
|
|
ReactDOM.render(<div>Hi</div>, container);
|
|
expect(() => {
|
|
ReactDOMClient.createRoot(container);
|
|
}).toErrorDev(
|
|
'You are calling ReactDOMClient.createRoot() on a container that was previously ' +
|
|
'passed to ReactDOM.render(). This is not supported.',
|
|
{withoutStack: true},
|
|
);
|
|
});
|
|
|
|
it('warns when creating two roots managing the same container', () => {
|
|
ReactDOMClient.createRoot(container);
|
|
expect(() => {
|
|
ReactDOMClient.createRoot(container);
|
|
}).toErrorDev(
|
|
'You are calling ReactDOMClient.createRoot() on a container that ' +
|
|
'has already been passed to createRoot() before. Instead, call ' +
|
|
'root.render() on the existing root instead if you want to update it.',
|
|
{withoutStack: true},
|
|
);
|
|
});
|
|
|
|
it('does not warn when creating second root after first one is unmounted', () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.unmount();
|
|
Scheduler.unstable_flushAll();
|
|
ReactDOMClient.createRoot(container); // No warning
|
|
});
|
|
|
|
it('warns if creating a root on the document.body', async () => {
|
|
if (gate(flags => flags.enableFloat)) {
|
|
// we no longer expect an error for this if float is enabled
|
|
ReactDOMClient.createRoot(document.body);
|
|
} else {
|
|
expect(() => {
|
|
ReactDOMClient.createRoot(document.body);
|
|
}).toErrorDev(
|
|
'createRoot(): Creating roots directly with document.body is ' +
|
|
'discouraged, since its children are often manipulated by third-party ' +
|
|
'scripts and browser extensions. This may lead to subtle ' +
|
|
'reconciliation issues. Try using a container element created ' +
|
|
'for your app.',
|
|
{withoutStack: true},
|
|
);
|
|
}
|
|
});
|
|
|
|
it('warns if updating a root that has had its contents removed', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<div>Hi</div>);
|
|
Scheduler.unstable_flushAll();
|
|
container.innerHTML = '';
|
|
|
|
if (gate(flags => flags.enableFloat || flags.enableHostSingletons)) {
|
|
// When either of these flags are on this validation is turned off so we
|
|
// expect there to be no warnings
|
|
root.render(<div>Hi</div>);
|
|
} else {
|
|
expect(() => {
|
|
root.render(<div>Hi</div>);
|
|
}).toErrorDev(
|
|
'render(...): It looks like the React-rendered content of the ' +
|
|
'root container was removed without using React. This is not ' +
|
|
'supported and will cause errors. Instead, call ' +
|
|
"root.unmount() to empty a root's container.",
|
|
{withoutStack: true},
|
|
);
|
|
}
|
|
});
|
|
|
|
it('opts-in to concurrent default updates', async () => {
|
|
const root = ReactDOMClient.createRoot(container, {
|
|
unstable_concurrentUpdatesByDefault: true,
|
|
});
|
|
|
|
function Foo({value}) {
|
|
Scheduler.unstable_yieldValue(value);
|
|
return <div>{value}</div>;
|
|
}
|
|
|
|
await act(async () => {
|
|
root.render(<Foo value="a" />);
|
|
});
|
|
|
|
expect(container.textContent).toEqual('a');
|
|
|
|
await act(async () => {
|
|
root.render(<Foo value="b" />);
|
|
|
|
expect(Scheduler).toHaveYielded(['a']);
|
|
expect(container.textContent).toEqual('a');
|
|
|
|
expect(Scheduler).toFlushAndYieldThrough(['b']);
|
|
if (gate(flags => flags.allowConcurrentByDefault)) {
|
|
expect(container.textContent).toEqual('a');
|
|
} else {
|
|
expect(container.textContent).toEqual('b');
|
|
}
|
|
});
|
|
expect(container.textContent).toEqual('b');
|
|
});
|
|
|
|
it('unmount is synchronous', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(async () => {
|
|
root.render('Hi');
|
|
});
|
|
expect(container.textContent).toEqual('Hi');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
// Should have already unmounted
|
|
expect(container.textContent).toEqual('');
|
|
});
|
|
});
|
|
|
|
it('throws if an unmounted root is updated', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(async () => {
|
|
root.render('Hi');
|
|
});
|
|
expect(container.textContent).toEqual('Hi');
|
|
|
|
root.unmount();
|
|
|
|
expect(() => root.render("I'm back")).toThrow(
|
|
'Cannot update an unmounted root.',
|
|
);
|
|
});
|
|
|
|
it('warns if root is unmounted inside an effect', async () => {
|
|
const container1 = document.createElement('div');
|
|
const root1 = ReactDOMClient.createRoot(container1);
|
|
const container2 = document.createElement('div');
|
|
const root2 = ReactDOMClient.createRoot(container2);
|
|
|
|
function App({step}) {
|
|
useEffect(() => {
|
|
if (step === 2) {
|
|
root2.unmount();
|
|
}
|
|
}, [step]);
|
|
return 'Hi';
|
|
}
|
|
|
|
await act(async () => {
|
|
root1.render(<App step={1} />);
|
|
});
|
|
expect(container1.textContent).toEqual('Hi');
|
|
|
|
expect(() => {
|
|
ReactDOM.flushSync(() => {
|
|
root1.render(<App step={2} />);
|
|
});
|
|
}).toErrorDev(
|
|
'Attempted to synchronously unmount a root while React was ' +
|
|
'already rendering.',
|
|
);
|
|
});
|
|
|
|
// @gate disableCommentsAsDOMContainers
|
|
it('errors if container is a comment node', () => {
|
|
// This is an old feature used by www. Disabled in the open source build.
|
|
const div = document.createElement('div');
|
|
div.innerHTML = '<!-- react-mount-point-unstable -->';
|
|
const commentNode = div.childNodes[0];
|
|
|
|
expect(() => ReactDOMClient.createRoot(commentNode)).toThrow(
|
|
'createRoot(...): Target container is not a DOM element.',
|
|
);
|
|
expect(() => ReactDOMClient.hydrateRoot(commentNode)).toThrow(
|
|
'hydrateRoot(...): Target container is not a DOM element.',
|
|
);
|
|
|
|
// Still works in the legacy API
|
|
ReactDOM.render(<div />, commentNode);
|
|
});
|
|
|
|
it('warn if no children passed to hydrateRoot', async () => {
|
|
expect(() => ReactDOMClient.hydrateRoot(container)).toErrorDev(
|
|
'Must provide initial children as second argument to hydrateRoot.',
|
|
{withoutStack: true},
|
|
);
|
|
});
|
|
|
|
it('warn if JSX passed to createRoot', async () => {
|
|
function App() {
|
|
return 'Child';
|
|
}
|
|
|
|
expect(() => ReactDOMClient.createRoot(container, <App />)).toErrorDev(
|
|
'You passed a JSX element to createRoot. You probably meant to call ' +
|
|
'root.render instead',
|
|
{
|
|
withoutStack: true,
|
|
},
|
|
);
|
|
});
|
|
});
|