mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
* Move createRoot/hydrateRoot to /client We want these APIs ideally to be imported separately from things you might use in arbitrary components (like flushSync). Those other methods are "isomorphic" to how the ReactDOM tree is rendered. Similar to hooks. E.g. importing flushSync into a component that only uses it on the client should ideally not also pull in the entry client implementation on the server. This also creates a nicer parity with /server where the roots are in a separate entry point. Unfortunately, I can't quite do this yet because we have some legacy APIs that we plan on removing (like findDOMNode) and we also haven't implemented flushSync using a flag like startTransition does yet. Another problem is that we currently encourage these APIs to be aliased by /profiling (or unstable_testing). In the future you don't have to alias them because you can just change your roots to just import those APIs and they'll still work with the isomorphic forms. Although we might also just use export conditions for them. For that all to work, I went with a different strategy for now where the real API is in / but it comes with a warning if you use it. If you instead import /client it disables the warning in a wrapper. That means that if you alias / then import /client that will inturn import the alias and it'll just work. In a future breaking changes (likely when we switch to ESM) we can just remove createRoot/hydrateRoot from / and move away from the aliasing strategy. * Update tests to import from react-dom/client * Fix fixtures * Update warnings * Add test for the warning * Update devtools * Change order of react-dom, react-dom/client alias I think the order matters here. The first one takes precedence. * Require react-dom through client so it can be aliased Co-authored-by: Andrew Clark <git@andrewclark.io>
647 lines
19 KiB
JavaScript
647 lines
19 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';
|
|
|
|
let React;
|
|
|
|
let ReactDOM;
|
|
let ReactDOMClient;
|
|
let Scheduler;
|
|
let act;
|
|
|
|
const setUntrackedInputValue = Object.getOwnPropertyDescriptor(
|
|
HTMLInputElement.prototype,
|
|
'value',
|
|
).set;
|
|
|
|
describe('ReactDOMFiberAsync', () => {
|
|
let container;
|
|
|
|
beforeEach(() => {
|
|
jest.resetModules();
|
|
container = document.createElement('div');
|
|
React = require('react');
|
|
ReactDOM = require('react-dom');
|
|
ReactDOMClient = require('react-dom/client');
|
|
act = require('jest-react').act;
|
|
Scheduler = require('scheduler');
|
|
|
|
document.body.appendChild(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.removeChild(container);
|
|
});
|
|
|
|
it('renders synchronously by default', () => {
|
|
const ops = [];
|
|
ReactDOM.render(<div>Hi</div>, container, () => {
|
|
ops.push(container.textContent);
|
|
});
|
|
ReactDOM.render(<div>Bye</div>, container, () => {
|
|
ops.push(container.textContent);
|
|
});
|
|
expect(ops).toEqual(['Hi', 'Bye']);
|
|
});
|
|
|
|
it('flushSync batches sync updates and flushes them at the end of the batch', () => {
|
|
const ops = [];
|
|
let instance;
|
|
|
|
class Component extends React.Component {
|
|
state = {text: ''};
|
|
push(val) {
|
|
this.setState(state => ({text: state.text + val}));
|
|
}
|
|
componentDidUpdate() {
|
|
ops.push(this.state.text);
|
|
}
|
|
render() {
|
|
instance = this;
|
|
return <span>{this.state.text}</span>;
|
|
}
|
|
}
|
|
|
|
ReactDOM.render(<Component />, container);
|
|
|
|
instance.push('A');
|
|
expect(ops).toEqual(['A']);
|
|
expect(container.textContent).toEqual('A');
|
|
|
|
ReactDOM.flushSync(() => {
|
|
instance.push('B');
|
|
instance.push('C');
|
|
// Not flushed yet
|
|
expect(container.textContent).toEqual('A');
|
|
expect(ops).toEqual(['A']);
|
|
});
|
|
expect(container.textContent).toEqual('ABC');
|
|
expect(ops).toEqual(['A', 'ABC']);
|
|
instance.push('D');
|
|
expect(container.textContent).toEqual('ABCD');
|
|
expect(ops).toEqual(['A', 'ABC', 'ABCD']);
|
|
});
|
|
|
|
it('flushSync flushes updates even if nested inside another flushSync', () => {
|
|
const ops = [];
|
|
let instance;
|
|
|
|
class Component extends React.Component {
|
|
state = {text: ''};
|
|
push(val) {
|
|
this.setState(state => ({text: state.text + val}));
|
|
}
|
|
componentDidUpdate() {
|
|
ops.push(this.state.text);
|
|
}
|
|
render() {
|
|
instance = this;
|
|
return <span>{this.state.text}</span>;
|
|
}
|
|
}
|
|
|
|
ReactDOM.render(<Component />, container);
|
|
|
|
instance.push('A');
|
|
expect(ops).toEqual(['A']);
|
|
expect(container.textContent).toEqual('A');
|
|
|
|
ReactDOM.flushSync(() => {
|
|
instance.push('B');
|
|
instance.push('C');
|
|
// Not flushed yet
|
|
expect(container.textContent).toEqual('A');
|
|
expect(ops).toEqual(['A']);
|
|
|
|
ReactDOM.flushSync(() => {
|
|
instance.push('D');
|
|
});
|
|
// The nested flushSync caused everything to flush.
|
|
expect(container.textContent).toEqual('ABCD');
|
|
expect(ops).toEqual(['A', 'ABCD']);
|
|
});
|
|
expect(container.textContent).toEqual('ABCD');
|
|
expect(ops).toEqual(['A', 'ABCD']);
|
|
});
|
|
|
|
it('flushSync logs an error if already performing work', () => {
|
|
class Component extends React.Component {
|
|
componentDidUpdate() {
|
|
ReactDOM.flushSync();
|
|
}
|
|
render() {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Initial mount
|
|
ReactDOM.render(<Component />, container);
|
|
// Update
|
|
expect(() => ReactDOM.render(<Component />, container)).toErrorDev(
|
|
'flushSync was called from inside a lifecycle method',
|
|
);
|
|
});
|
|
|
|
describe('concurrent mode', () => {
|
|
it('does not perform deferred updates synchronously', () => {
|
|
const inputRef = React.createRef();
|
|
const asyncValueRef = React.createRef();
|
|
const syncValueRef = React.createRef();
|
|
|
|
class Counter extends React.Component {
|
|
state = {asyncValue: '', syncValue: ''};
|
|
|
|
handleChange = e => {
|
|
const nextValue = e.target.value;
|
|
requestIdleCallback(() => {
|
|
this.setState({
|
|
asyncValue: nextValue,
|
|
});
|
|
// It should not be flushed yet.
|
|
expect(asyncValueRef.current.textContent).toBe('');
|
|
});
|
|
this.setState({
|
|
syncValue: nextValue,
|
|
});
|
|
};
|
|
|
|
render() {
|
|
return (
|
|
<div>
|
|
<input
|
|
ref={inputRef}
|
|
onChange={this.handleChange}
|
|
defaultValue=""
|
|
/>
|
|
<p ref={asyncValueRef}>{this.state.asyncValue}</p>
|
|
<p ref={syncValueRef}>{this.state.syncValue}</p>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<Counter />);
|
|
Scheduler.unstable_flushAll();
|
|
expect(asyncValueRef.current.textContent).toBe('');
|
|
expect(syncValueRef.current.textContent).toBe('');
|
|
|
|
setUntrackedInputValue.call(inputRef.current, 'hello');
|
|
inputRef.current.dispatchEvent(new MouseEvent('input', {bubbles: true}));
|
|
// Should only flush non-deferred update.
|
|
expect(asyncValueRef.current.textContent).toBe('');
|
|
expect(syncValueRef.current.textContent).toBe('hello');
|
|
|
|
// Should flush both updates now.
|
|
jest.runAllTimers();
|
|
Scheduler.unstable_flushAll();
|
|
expect(asyncValueRef.current.textContent).toBe('hello');
|
|
expect(syncValueRef.current.textContent).toBe('hello');
|
|
});
|
|
|
|
it('top-level updates are concurrent', () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<div>Hi</div>);
|
|
expect(container.textContent).toEqual('');
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('Hi');
|
|
|
|
root.render(<div>Bye</div>);
|
|
expect(container.textContent).toEqual('Hi');
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('Bye');
|
|
});
|
|
|
|
it('deep updates (setState) are concurrent', () => {
|
|
let instance;
|
|
class Component extends React.Component {
|
|
state = {step: 0};
|
|
render() {
|
|
instance = this;
|
|
return <div>{this.state.step}</div>;
|
|
}
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<Component />);
|
|
expect(container.textContent).toEqual('');
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('0');
|
|
|
|
instance.setState({step: 1});
|
|
expect(container.textContent).toEqual('0');
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('1');
|
|
});
|
|
|
|
it('flushSync flushes updates before end of the tick', () => {
|
|
const ops = [];
|
|
let instance;
|
|
|
|
class Component extends React.Component {
|
|
state = {text: ''};
|
|
push(val) {
|
|
this.setState(state => ({text: state.text + val}));
|
|
}
|
|
componentDidUpdate() {
|
|
ops.push(this.state.text);
|
|
}
|
|
render() {
|
|
instance = this;
|
|
return <span>{this.state.text}</span>;
|
|
}
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<Component />);
|
|
Scheduler.unstable_flushAll();
|
|
|
|
// Updates are async by default
|
|
instance.push('A');
|
|
expect(ops).toEqual([]);
|
|
expect(container.textContent).toEqual('');
|
|
|
|
ReactDOM.flushSync(() => {
|
|
instance.push('B');
|
|
instance.push('C');
|
|
// Not flushed yet
|
|
expect(container.textContent).toEqual('');
|
|
expect(ops).toEqual([]);
|
|
});
|
|
// Only the active updates have flushed
|
|
expect(container.textContent).toEqual('BC');
|
|
expect(ops).toEqual(['BC']);
|
|
|
|
instance.push('D');
|
|
expect(container.textContent).toEqual('BC');
|
|
expect(ops).toEqual(['BC']);
|
|
|
|
// Flush the async updates
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('ABCD');
|
|
expect(ops).toEqual(['BC', 'ABCD']);
|
|
});
|
|
|
|
// @gate www
|
|
it('flushControlled flushes updates before yielding to browser', () => {
|
|
let inst;
|
|
class Counter extends React.Component {
|
|
state = {counter: 0};
|
|
increment = () =>
|
|
this.setState(state => ({counter: state.counter + 1}));
|
|
render() {
|
|
inst = this;
|
|
return this.state.counter;
|
|
}
|
|
}
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<Counter />);
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('0');
|
|
|
|
// Test that a normal update is async
|
|
inst.increment();
|
|
expect(container.textContent).toEqual('0');
|
|
Scheduler.unstable_flushAll();
|
|
expect(container.textContent).toEqual('1');
|
|
|
|
const ops = [];
|
|
ReactDOM.unstable_flushControlled(() => {
|
|
inst.increment();
|
|
ReactDOM.unstable_flushControlled(() => {
|
|
inst.increment();
|
|
ops.push('end of inner flush: ' + container.textContent);
|
|
});
|
|
ops.push('end of outer flush: ' + container.textContent);
|
|
});
|
|
ops.push('after outer flush: ' + container.textContent);
|
|
expect(ops).toEqual([
|
|
'end of inner flush: 1',
|
|
'end of outer flush: 1',
|
|
'after outer flush: 3',
|
|
]);
|
|
});
|
|
|
|
// @gate www
|
|
it('flushControlled does not flush until end of outermost batchedUpdates', () => {
|
|
let inst;
|
|
class Counter extends React.Component {
|
|
state = {counter: 0};
|
|
increment = () =>
|
|
this.setState(state => ({counter: state.counter + 1}));
|
|
render() {
|
|
inst = this;
|
|
return this.state.counter;
|
|
}
|
|
}
|
|
ReactDOM.render(<Counter />, container);
|
|
|
|
const ops = [];
|
|
ReactDOM.unstable_batchedUpdates(() => {
|
|
inst.increment();
|
|
ReactDOM.unstable_flushControlled(() => {
|
|
inst.increment();
|
|
ops.push('end of flushControlled fn: ' + container.textContent);
|
|
});
|
|
ops.push('end of batchedUpdates fn: ' + container.textContent);
|
|
});
|
|
ops.push('after batchedUpdates: ' + container.textContent);
|
|
expect(ops).toEqual([
|
|
'end of flushControlled fn: 0',
|
|
'end of batchedUpdates fn: 0',
|
|
'after batchedUpdates: 2',
|
|
]);
|
|
});
|
|
|
|
// @gate www
|
|
it('flushControlled returns nothing', () => {
|
|
// In the future, we may want to return a thenable "work" object.
|
|
let inst;
|
|
class Counter extends React.Component {
|
|
state = {counter: 0};
|
|
increment = () =>
|
|
this.setState(state => ({counter: state.counter + 1}));
|
|
render() {
|
|
inst = this;
|
|
return this.state.counter;
|
|
}
|
|
}
|
|
ReactDOM.render(<Counter />, container);
|
|
expect(container.textContent).toEqual('0');
|
|
|
|
const returnValue = ReactDOM.unstable_flushControlled(() => {
|
|
inst.increment();
|
|
return 'something';
|
|
});
|
|
expect(container.textContent).toEqual('1');
|
|
expect(returnValue).toBe(undefined);
|
|
});
|
|
|
|
it('ignores discrete events on a pending removed element', async () => {
|
|
const disableButtonRef = React.createRef();
|
|
const submitButtonRef = React.createRef();
|
|
|
|
function Form() {
|
|
const [active, setActive] = React.useState(true);
|
|
function disableForm() {
|
|
setActive(false);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<button onClick={disableForm} ref={disableButtonRef}>
|
|
Disable
|
|
</button>
|
|
{active ? <button ref={submitButtonRef}>Submit</button> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(async () => {
|
|
root.render(<Form />);
|
|
});
|
|
|
|
const disableButton = disableButtonRef.current;
|
|
expect(disableButton.tagName).toBe('BUTTON');
|
|
|
|
const submitButton = submitButtonRef.current;
|
|
expect(submitButton.tagName).toBe('BUTTON');
|
|
|
|
// Dispatch a click event on the Disable-button.
|
|
const firstEvent = document.createEvent('Event');
|
|
firstEvent.initEvent('click', true, true);
|
|
disableButton.dispatchEvent(firstEvent);
|
|
|
|
// The click event is flushed synchronously, even in concurrent mode.
|
|
expect(submitButton.current).toBe(undefined);
|
|
});
|
|
|
|
it('ignores discrete events on a pending removed event listener', async () => {
|
|
const disableButtonRef = React.createRef();
|
|
const submitButtonRef = React.createRef();
|
|
|
|
let formSubmitted = false;
|
|
|
|
function Form() {
|
|
const [active, setActive] = React.useState(true);
|
|
function disableForm() {
|
|
setActive(false);
|
|
}
|
|
function submitForm() {
|
|
formSubmitted = true; // This should not get invoked
|
|
}
|
|
function disabledSubmitForm() {
|
|
// The form is disabled.
|
|
}
|
|
return (
|
|
<div>
|
|
<button onClick={disableForm} ref={disableButtonRef}>
|
|
Disable
|
|
</button>
|
|
<button
|
|
onClick={active ? submitForm : disabledSubmitForm}
|
|
ref={submitButtonRef}>
|
|
Submit
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(async () => {
|
|
root.render(<Form />);
|
|
});
|
|
|
|
const disableButton = disableButtonRef.current;
|
|
expect(disableButton.tagName).toBe('BUTTON');
|
|
|
|
// Dispatch a click event on the Disable-button.
|
|
const firstEvent = document.createEvent('Event');
|
|
firstEvent.initEvent('click', true, true);
|
|
await act(async () => {
|
|
disableButton.dispatchEvent(firstEvent);
|
|
});
|
|
|
|
// There should now be a pending update to disable the form.
|
|
|
|
// This should not have flushed yet since it's in concurrent mode.
|
|
const submitButton = submitButtonRef.current;
|
|
expect(submitButton.tagName).toBe('BUTTON');
|
|
|
|
// In the meantime, we can dispatch a new client event on the submit button.
|
|
const secondEvent = document.createEvent('Event');
|
|
secondEvent.initEvent('click', true, true);
|
|
// This should force the pending update to flush which disables the submit button before the event is invoked.
|
|
await act(async () => {
|
|
submitButton.dispatchEvent(secondEvent);
|
|
});
|
|
|
|
// Therefore the form should never have been submitted.
|
|
expect(formSubmitted).toBe(false);
|
|
});
|
|
|
|
it('uses the newest discrete events on a pending changed event listener', async () => {
|
|
const enableButtonRef = React.createRef();
|
|
const submitButtonRef = React.createRef();
|
|
|
|
let formSubmitted = false;
|
|
|
|
function Form() {
|
|
const [active, setActive] = React.useState(false);
|
|
function enableForm() {
|
|
setActive(true);
|
|
}
|
|
function submitForm() {
|
|
formSubmitted = true; // This should not get invoked
|
|
}
|
|
return (
|
|
<div>
|
|
<button onClick={enableForm} ref={enableButtonRef}>
|
|
Enable
|
|
</button>
|
|
<button onClick={active ? submitForm : null} ref={submitButtonRef}>
|
|
Submit
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(async () => {
|
|
root.render(<Form />);
|
|
});
|
|
|
|
const enableButton = enableButtonRef.current;
|
|
expect(enableButton.tagName).toBe('BUTTON');
|
|
|
|
// Dispatch a click event on the Enable-button.
|
|
const firstEvent = document.createEvent('Event');
|
|
firstEvent.initEvent('click', true, true);
|
|
await act(async () => {
|
|
enableButton.dispatchEvent(firstEvent);
|
|
});
|
|
|
|
// There should now be a pending update to enable the form.
|
|
|
|
// This should not have flushed yet since it's in concurrent mode.
|
|
const submitButton = submitButtonRef.current;
|
|
expect(submitButton.tagName).toBe('BUTTON');
|
|
|
|
// In the meantime, we can dispatch a new client event on the submit button.
|
|
const secondEvent = document.createEvent('Event');
|
|
secondEvent.initEvent('click', true, true);
|
|
// This should force the pending update to flush which enables the submit button before the event is invoked.
|
|
await act(async () => {
|
|
submitButton.dispatchEvent(secondEvent);
|
|
});
|
|
|
|
// Therefore the form should have been submitted.
|
|
expect(formSubmitted).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('regression test: does not drop passive effects across roots (#17066)', () => {
|
|
const {useState, useEffect} = React;
|
|
|
|
function App({label}) {
|
|
const [step, setStep] = useState(0);
|
|
useEffect(() => {
|
|
if (step < 3) {
|
|
setStep(step + 1);
|
|
}
|
|
}, [step]);
|
|
|
|
// The component should keep re-rendering itself until `step` is 3.
|
|
return step === 3 ? 'Finished' : 'Unresolved';
|
|
}
|
|
|
|
const containerA = document.createElement('div');
|
|
const containerB = document.createElement('div');
|
|
const containerC = document.createElement('div');
|
|
|
|
ReactDOM.render(<App label="A" />, containerA);
|
|
ReactDOM.render(<App label="B" />, containerB);
|
|
ReactDOM.render(<App label="C" />, containerC);
|
|
|
|
Scheduler.unstable_flushAll();
|
|
|
|
expect(containerA.textContent).toEqual('Finished');
|
|
expect(containerB.textContent).toEqual('Finished');
|
|
expect(containerC.textContent).toEqual('Finished');
|
|
});
|
|
|
|
it('updates flush without yielding in the next event', () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
|
|
function Text(props) {
|
|
Scheduler.unstable_yieldValue(props.text);
|
|
return props.text;
|
|
}
|
|
|
|
root.render(
|
|
<>
|
|
<Text text="A" />
|
|
<Text text="B" />
|
|
<Text text="C" />
|
|
</>,
|
|
);
|
|
|
|
// Nothing should have rendered yet
|
|
expect(container.textContent).toEqual('');
|
|
|
|
// Everything should render immediately in the next event
|
|
expect(Scheduler).toFlushAndYield(['A', 'B', 'C']);
|
|
expect(container.textContent).toEqual('ABC');
|
|
});
|
|
|
|
it('unmounted roots should never clear newer root content from a container', () => {
|
|
const ref = React.createRef();
|
|
|
|
function OldApp() {
|
|
const [value, setValue] = React.useState('old');
|
|
function hideOnClick() {
|
|
// Schedule a discrete update.
|
|
setValue('update');
|
|
// Synchronously unmount this root.
|
|
ReactDOM.flushSync(() => oldRoot.unmount());
|
|
}
|
|
return (
|
|
<button onClick={hideOnClick} ref={ref}>
|
|
{value}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function NewApp() {
|
|
return <button ref={ref}>new</button>;
|
|
}
|
|
|
|
const oldRoot = ReactDOMClient.createRoot(container);
|
|
act(() => {
|
|
oldRoot.render(<OldApp />);
|
|
});
|
|
|
|
// Invoke discrete event.
|
|
ref.current.click();
|
|
|
|
// The root should now be unmounted.
|
|
expect(container.textContent).toBe('');
|
|
|
|
// We can now render a new one.
|
|
const newRoot = ReactDOMClient.createRoot(container);
|
|
ReactDOM.flushSync(() => {
|
|
newRoot.render(<NewApp />);
|
|
});
|
|
ref.current.click();
|
|
|
|
expect(container.textContent).toBe('new');
|
|
});
|
|
});
|