'use strict';
let React;
let ReactDOM;
let ReactDOMClient;
let Scheduler;
let act;
let waitForAll;
let waitFor;
let waitForMicrotasks;
let assertLog;
let assertConsoleErrorDev;
const setUntrackedInputValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
).set;
describe('ReactDOMFiberAsync', () => {
let container;
beforeEach(() => {
container = document.createElement('div');
React = require('react');
ReactDOM = require('react-dom');
ReactDOMClient = require('react-dom/client');
act = require('internal-test-utils').act;
assertConsoleErrorDev =
require('internal-test-utils').assertConsoleErrorDev;
Scheduler = require('scheduler');
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
waitFor = InternalTestUtils.waitFor;
waitForMicrotasks = InternalTestUtils.waitForMicrotasks;
assertLog = InternalTestUtils.assertLog;
document.body.appendChild(container);
window.event = undefined;
});
afterEach(() => {
document.body.removeChild(container);
});
it('renders synchronously by default in legacy mode', () => {
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', async () => {
const ops = [];
let instance;
class Component extends React.Component {
state = {text: ''};
componentDidMount() {
instance = this;
}
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);
await act(() => root.render(<Component />));
await act(() => {
instance.push('A');
});
expect(ops).toEqual(['A']);
expect(container.textContent).toEqual('A');
ReactDOM.flushSync(() => {
instance.push('B');
instance.push('C');
expect(container.textContent).toEqual('A');
expect(ops).toEqual(['A']);
});
expect(container.textContent).toEqual('ABC');
expect(ops).toEqual(['A', 'ABC']);
await act(() => {
instance.push('D');
});
expect(container.textContent).toEqual('ABCD');
expect(ops).toEqual(['A', 'ABC', 'ABCD']);
});
it('flushSync flushes updates even if nested inside another flushSync', async () => {
const ops = [];
let instance;
class Component extends React.Component {
state = {text: ''};
componentDidMount() {
instance = this;
}
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);
await act(() => root.render(<Component />));
await act(() => {
instance.push('A');
});
expect(ops).toEqual(['A']);
expect(container.textContent).toEqual('A');
ReactDOM.flushSync(() => {
instance.push('B');
instance.push('C');
expect(container.textContent).toEqual('A');
expect(ops).toEqual(['A']);
ReactDOM.flushSync(() => {
instance.push('D');
});
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', async () => {
class Component extends React.Component {
componentDidUpdate() {
ReactDOM.flushSync();
}
render() {
return null;
}
}
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<Component />);
});
ReactDOM.flushSync(() => {
root.render(<Component />);
});
assertConsoleErrorDev([
'flushSync was called from inside a lifecycle method. ' +
'React cannot flush when React is already rendering. ' +
'Consider moving this call to a scheduler task or micro task.\n' +
' in Component (at **)',
]);
});
describe('concurrent mode', () => {
it('does not perform deferred updates synchronously', async () => {
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;
React.startTransition(() => {
this.setState({
asyncValue: nextValue,
});
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);
await act(() => root.render(<Counter />));
expect(asyncValueRef.current.textContent).toBe('');
expect(syncValueRef.current.textContent).toBe('');
await act(() => {
setUntrackedInputValue.call(inputRef.current, 'hello');
inputRef.current.dispatchEvent(
new MouseEvent('input', {bubbles: true}),
);
expect(asyncValueRef.current.textContent).toBe('');
expect(syncValueRef.current.textContent).toBe('hello');
});
expect(asyncValueRef.current.textContent).toBe('hello');
expect(syncValueRef.current.textContent).toBe('hello');
});
it('top-level updates are concurrent', async () => {
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<div>Hi</div>);
expect(container.textContent).toEqual('');
});
expect(container.textContent).toEqual('Hi');
await act(() => {
root.render(<div>Bye</div>);
expect(container.textContent).toEqual('Hi');
});
expect(container.textContent).toEqual('Bye');
});
it('deep updates (setState) are concurrent', async () => {
let instance;
class Component extends React.Component {
state = {step: 0};
render() {
instance = this;
return <div>{this.state.step}</div>;
}
}
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<Component />);
expect(container.textContent).toEqual('');
});
expect(container.textContent).toEqual('0');
await act(() => {
instance.setState({step: 1});
expect(container.textContent).toEqual('0');
});
expect(container.textContent).toEqual('1');
});
it('flushSync flushes updates before end of the tick', async () => {
let instance;
class Component extends React.Component {
state = {text: ''};
push(val) {
this.setState(state => ({text: state.text + val}));
}
componentDidUpdate() {
Scheduler.log(this.state.text);
}
render() {
instance = this;
return <span>{this.state.text}</span>;
}
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<Component />));
instance.push('A');
assertLog([]);
expect(container.textContent).toEqual('');
ReactDOM.flushSync(() => {
instance.push('B');
instance.push('C');
expect(container.textContent).toEqual('');
assertLog([]);
});
expect(container.textContent).toEqual('ABC');
assertLog(['ABC']);
await act(() => {
instance.push('D');
expect(container.textContent).toEqual('ABC');
assertLog([]);
});
assertLog(['ABCD']);
expect(container.textContent).toEqual('ABCD');
});
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(() => {
root.render(<Form />);
});
const disableButton = disableButtonRef.current;
expect(disableButton.tagName).toBe('BUTTON');
const submitButton = submitButtonRef.current;
expect(submitButton.tagName).toBe('BUTTON');
const firstEvent = document.createEvent('Event');
firstEvent.initEvent('click', true, true);
disableButton.dispatchEvent(firstEvent);
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;
}
function disabledSubmitForm() {
}
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(() => {
root.render(<Form />);
});
const disableButton = disableButtonRef.current;
expect(disableButton.tagName).toBe('BUTTON');
const firstEvent = document.createEvent('Event');
firstEvent.initEvent('click', true, true);
await act(() => {
disableButton.dispatchEvent(firstEvent);
});
const submitButton = submitButtonRef.current;
expect(submitButton.tagName).toBe('BUTTON');
const secondEvent = document.createEvent('Event');
secondEvent.initEvent('click', true, true);
await act(() => {
submitButton.dispatchEvent(secondEvent);
});
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;
}
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(() => {
root.render(<Form />);
});
const enableButton = enableButtonRef.current;
expect(enableButton.tagName).toBe('BUTTON');
const firstEvent = document.createEvent('Event');
firstEvent.initEvent('click', true, true);
await act(() => {
enableButton.dispatchEvent(firstEvent);
});
const submitButton = submitButtonRef.current;
expect(submitButton.tagName).toBe('BUTTON');
const secondEvent = document.createEvent('Event');
secondEvent.initEvent('click', true, true);
await act(() => {
submitButton.dispatchEvent(secondEvent);
});
expect(formSubmitted).toBe(true);
});
});
it('regression test: does not drop passive effects across roots (#17066)', async () => {
const {useState, useEffect} = React;
function App({label}) {
const [step, setStep] = useState(0);
useEffect(() => {
if (step < 3) {
setStep(step + 1);
}
}, [step]);
return step === 3 ? 'Finished' : 'Unresolved';
}
const containerA = document.createElement('div');
const containerB = document.createElement('div');
const containerC = document.createElement('div');
const rootA = ReactDOMClient.createRoot(containerA);
const rootB = ReactDOMClient.createRoot(containerB);
const rootC = ReactDOMClient.createRoot(containerC);
await act(() => {
rootA.render(<App label="A" />);
rootB.render(<App label="B" />);
rootC.render(<App label="C" />);
});
expect(containerA.textContent).toEqual('Finished');
expect(containerB.textContent).toEqual('Finished');
expect(containerC.textContent).toEqual('Finished');
});
it('updates flush without yielding in the next event', async () => {
const root = ReactDOMClient.createRoot(container);
function Text(props) {
Scheduler.log(props.text);
return props.text;
}
root.render(
<>
<Text text="A" />
<Text text="B" />
<Text text="C" />
</>,
);
expect(container.textContent).toEqual('');
await waitForAll(['A', 'B', 'C']);
expect(container.textContent).toEqual('ABC');
});
it('unmounted roots should never clear newer root content from a container', async () => {
const ref = React.createRef();
function OldApp() {
const [value, setValue] = React.useState('old');
function hideOnClick() {
setValue('update');
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);
await act(() => {
oldRoot.render(<OldApp />);
});
ref.current.click();
expect(container.textContent).toBe('');
const newRoot = ReactDOMClient.createRoot(container);
ReactDOM.flushSync(() => {
newRoot.render(<NewApp />);
});
ref.current.click();
expect(container.textContent).toBe('new');
});
it('should synchronously render the transition lane scheduled in a popState', async () => {
function App() {
const [syncState, setSyncState] = React.useState(false);
const [hasNavigated, setHasNavigated] = React.useState(false);
function onPopstate() {
Scheduler.log(`popState`);
React.startTransition(() => {
setHasNavigated(true);
});
setSyncState(true);
}
React.useEffect(() => {
window.addEventListener('popstate', onPopstate);
return () => {
window.removeEventListener('popstate', onPopstate);
};
}, []);
Scheduler.log(`render:${hasNavigated}/${syncState}`);
return null;
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(<App />);
});
assertLog(['render:false/false']);
await act(async () => {
const popStateEvent = new Event('popstate');
window.event = popStateEvent;
window.dispatchEvent(popStateEvent);
queueMicrotask(() => {
window.event = undefined;
});
});
assertLog(['popState', 'render:true/true']);
await act(() => {
root.unmount();
});
});
it('Should not flush transition lanes if there is no transition scheduled in popState', async () => {
let setHasNavigated;
function App() {
const [syncState, setSyncState] = React.useState(false);
const [hasNavigated, _setHasNavigated] = React.useState(false);
setHasNavigated = _setHasNavigated;
function onPopstate() {
setSyncState(true);
}
React.useEffect(() => {
window.addEventListener('popstate', onPopstate);
return () => {
window.removeEventListener('popstate', onPopstate);
};
}, []);
Scheduler.log(`render:${hasNavigated}/${syncState}`);
return null;
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(<App />);
});
assertLog(['render:false/false']);
React.startTransition(() => {
setHasNavigated(true);
});
await act(async () => {
const popStateEvent = new Event('popstate');
window.event = popStateEvent;
window.dispatchEvent(popStateEvent);
queueMicrotask(() => {
window.event = undefined;
});
});
assertLog(['render:false/true', 'render:true/true']);
await act(() => {
root.unmount();
});
});
it('transition lane in popState should be allowed to suspend', async () => {
let resolvePromise;
const promise = new Promise(res => {
resolvePromise = res;
});
function Text({text}) {
Scheduler.log(text);
return text;
}
function App() {
const [pathname, setPathname] = React.useState('/path/a');
if (pathname !== '/path/a') {
try {
React.use(promise);
} catch (e) {
Scheduler.log(`Suspend! [${pathname}]`);
throw e;
}
}
React.useEffect(() => {
function onPopstate() {
React.startTransition(() => {
setPathname('/path/b');
});
}
window.addEventListener('popstate', onPopstate);
return () => window.removeEventListener('popstate', onPopstate);
}, []);
return (
<>
<Text text="Before" />
<div>
<Text text={pathname} />
</div>
<Text text="After" />
</>
);
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(<App />);
});
assertLog(['Before', '/path/a', 'After']);
const div = container.getElementsByTagName('div')[0];
expect(div.textContent).toBe('/path/a');
await act(async () => {
const popStateEvent = new Event('popstate');
window.event = popStateEvent;
window.dispatchEvent(popStateEvent);
await waitForMicrotasks();
window.event = undefined;
assertLog(['Suspend! [/path/b]']);
expect(div.textContent).toBe('/path/a');
});
assertLog(['Suspend! [/path/b]']);
await act(async () => {
resolvePromise();
await waitForMicrotasks();
assertLog([]);
await waitFor(['Before']);
await waitFor(['/path/b']);
await waitFor(['After']);
});
assertLog([]);
expect(div.textContent).toBe('/path/b');
await act(() => {
root.unmount();
});
});
it('regression: useDeferredValue in popState leads to infinite deferral loop', async () => {
let browserPathname = '/path/a';
let setPathname;
function App({initialPathname}) {
const [pathname, _setPathname] = React.useState('/path/a');
setPathname = _setPathname;
const deferredPathname = React.useDeferredValue(pathname);
React.useEffect(() => {
function onPopstate() {
React.startTransition(() => {
setPathname(browserPathname);
});
}
window.addEventListener('popstate', onPopstate);
return () => window.removeEventListener('popstate', onPopstate);
}, []);
return `Current: ${pathname}\nDeferred: ${deferredPathname}`;
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(<App initialPathname={browserPathname} />);
});
setPathname(browserPathname);
for (let i = 0; i < 50; i++) {
await act(async () => {
browserPathname = browserPathname === '/path/a' ? '/path/b' : '/path/a';
const popStateEvent = new Event('popstate');
window.event = popStateEvent;
window.dispatchEvent(popStateEvent);
await waitForMicrotasks();
window.event = undefined;
});
}
});
it('regression: infinite deferral loop caused by unstable useDeferredValue input', async () => {
function Text({text}) {
Scheduler.log(text);
return text;
}
let i = 0;
function App() {
const [pathname, setPathname] = React.useState('/path/a');
const {value: deferredPathname} = React.useDeferredValue({
value: pathname,
});
if (i++ > 100) {
throw new Error('Infinite loop detected');
}
React.useEffect(() => {
function onPopstate() {
React.startTransition(() => {
setPathname('/path/b');
});
}
window.addEventListener('popstate', onPopstate);
return () => window.removeEventListener('popstate', onPopstate);
}, []);
return <Text text={deferredPathname} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<App />);
});
assertLog(['/path/a']);
expect(container.textContent).toBe('/path/a');
await act(async () => {
const popStateEvent = new Event('popstate');
window.event = popStateEvent;
window.dispatchEvent(popStateEvent);
await waitForMicrotasks();
window.event = undefined;
assertLog(['/path/a']);
});
assertLog(['/path/b']);
expect(container.textContent).toBe('/path/b');
await act(() => {
root.unmount();
});
});
});