/**
* 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;
let ReactDOM;
let ReactDOMClient;
let Scheduler;
let act;
let container;
let waitForAll;
let assertLog;
let fakeModuleCache;
describe('ReactSuspenseEffectsSemanticsDOM', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactDOM = require('react-dom');
ReactDOMClient = require('react-dom/client');
Scheduler = require('scheduler');
act = require('internal-test-utils').act;
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
assertLog = InternalTestUtils.assertLog;
container = document.createElement('div');
document.body.appendChild(container);
fakeModuleCache = new Map();
});
afterEach(() => {
document.body.removeChild(container);
});
async function fakeImport(Component) {
const record = fakeModuleCache.get(Component);
if (record === undefined) {
const newRecord = {
status: 'pending',
value: {default: Component},
pings: [],
then(ping) {
switch (newRecord.status) {
case 'pending': {
newRecord.pings.push(ping);
return;
}
case 'resolved': {
ping(newRecord.value);
return;
}
case 'rejected': {
throw newRecord.value;
}
}
},
};
fakeModuleCache.set(Component, newRecord);
return newRecord;
}
return record;
}
function resolveFakeImport(moduleName) {
const record = fakeModuleCache.get(moduleName);
if (record === undefined) {
throw new Error('Module not found');
}
if (record.status !== 'pending') {
throw new Error('Module already resolved');
}
record.status = 'resolved';
record.pings.forEach(ping => ping(record.value));
}
function Text(props) {
Scheduler.log(props.text);
return props.text;
}
it('should not cause a cycle when combined with a render phase update', async () => {
let scheduleSuspendingUpdate;
function App() {
const [value, setValue] = React.useState(true);
scheduleSuspendingUpdate = () => setValue(!value);
return (
<>
<React.Suspense fallback="Loading...">
<ComponentThatCausesBug value={value} />
<ComponentThatSuspendsOnUpdate shouldSuspend={!value} />
</React.Suspense>
</>
);
}
function ComponentThatCausesBug({value}) {
const [mirroredValue, setMirroredValue] = React.useState(value);
if (mirroredValue !== value) {
setMirroredValue(value);
}
// eslint-disable-next-line no-unused-vars
const [_, setRef] = React.useState(null);
return <div ref={setRef} />;
}
const neverResolves = {then() {}};
function ComponentThatSuspendsOnUpdate({shouldSuspend}) {
if (shouldSuspend) {
// Fake Suspend
throw neverResolves;
}
return null;
}
await act(() => {
const root = ReactDOMClient.createRoot(container);
root.render(<App />);
});
await act(() => {
scheduleSuspendingUpdate();
});
});
it('does not destroy ref cleanup twice when hidden child is removed', async () => {
function ChildA({label}) {
return (
<span
ref={node => {
if (node) {
Scheduler.log('Ref mount: ' + label);
} else {
Scheduler.log('Ref unmount: ' + label);
}
}}>
<Text text={label} />
</span>
);
}
function ChildB({label}) {
return (
<span
ref={node => {
if (node) {
Scheduler.log('Ref mount: ' + label);
} else {
Scheduler.log('Ref unmount: ' + label);
}
}}>
<Text text={label} />
</span>
);
}
const LazyChildA = React.lazy(() => fakeImport(ChildA));
const LazyChildB = React.lazy(() => fakeImport(ChildB));
function Parent({swap}) {
return (
<React.Suspense fallback={<Text text="Loading..." />}>
{swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
</React.Suspense>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<Parent swap={false} />);
});
assertLog(['Loading...']);
await act(() => resolveFakeImport(ChildA));
assertLog(['A', 'Ref mount: A']);
expect(container.innerHTML).toBe('<span>A</span>');
// Swap the position of A and B
ReactDOM.flushSync(() => {
root.render(<Parent swap={true} />);
});
assertLog(['Loading...', 'Ref unmount: A']);
expect(container.innerHTML).toBe(
'<span style="display: none;">A</span>Loading...',
);
await act(() => resolveFakeImport(ChildB));
assertLog(['B', 'Ref mount: B']);
expect(container.innerHTML).toBe('<span>B</span>');
});
it('does not call componentWillUnmount twice when hidden child is removed', async () => {
class ChildA extends React.Component {
componentDidMount() {
Scheduler.log('Did mount: ' + this.props.label);
}
componentWillUnmount() {
Scheduler.log('Will unmount: ' + this.props.label);
}
render() {
return <Text text={this.props.label} />;
}
}
class ChildB extends React.Component {
componentDidMount() {
Scheduler.log('Did mount: ' + this.props.label);
}
componentWillUnmount() {
Scheduler.log('Will unmount: ' + this.props.label);
}
render() {
return <Text text={this.props.label} />;
}
}
const LazyChildA = React.lazy(() => fakeImport(ChildA));
const LazyChildB = React.lazy(() => fakeImport(ChildB));
function Parent({swap}) {
return (
<React.Suspense fallback={<Text text="Loading..." />}>
{swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
</React.Suspense>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<Parent swap={false} />);
});
assertLog(['Loading...']);
await act(() => resolveFakeImport(ChildA));
assertLog(['A', 'Did mount: A']);
expect(container.innerHTML).toBe('A');
// Swap the position of A and B
ReactDOM.flushSync(() => {
root.render(<Parent swap={true} />);
});
assertLog(['Loading...', 'Will unmount: A']);
expect(container.innerHTML).toBe('Loading...');
await act(() => resolveFakeImport(ChildB));
assertLog(['B', 'Did mount: B']);
expect(container.innerHTML).toBe('B');
});
it('does not destroy layout effects twice when parent suspense is removed', async () => {
function ChildA({label}) {
React.useLayoutEffect(() => {
Scheduler.log('Did mount: ' + label);
return () => {
Scheduler.log('Will unmount: ' + label);
};
}, []);
return <Text text={label} />;
}
function ChildB({label}) {
React.useLayoutEffect(() => {
Scheduler.log('Did mount: ' + label);
return () => {
Scheduler.log('Will unmount: ' + label);
};
}, []);
return <Text text={label} />;
}
const LazyChildA = React.lazy(() => fakeImport(ChildA));
const LazyChildB = React.lazy(() => fakeImport(ChildB));
function Parent({swap}) {
return (
<React.Suspense fallback={<Text text="Loading..." />}>
{swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
</React.Suspense>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<Parent swap={false} />);
});
assertLog(['Loading...']);
await act(() => resolveFakeImport(ChildA));
assertLog(['A', 'Did mount: A']);
expect(container.innerHTML).toBe('A');
// Swap the position of A and B
ReactDOM.flushSync(() => {
root.render(<Parent swap={true} />);
});
assertLog(['Loading...', 'Will unmount: A']);
expect(container.innerHTML).toBe('Loading...');
// Destroy the whole tree, including the hidden A
ReactDOM.flushSync(() => {
root.render(<h1>Hello</h1>);
});
await waitForAll([]);
expect(container.innerHTML).toBe('<h1>Hello</h1>');
});
it('does not destroy ref cleanup twice when parent suspense is removed', async () => {
function ChildA({label}) {
return (
<span
ref={node => {
if (node) {
Scheduler.log('Ref mount: ' + label);
} else {
Scheduler.log('Ref unmount: ' + label);
}
}}>
<Text text={label} />
</span>
);
}
function ChildB({label}) {
return (
<span
ref={node => {
if (node) {
Scheduler.log('Ref mount: ' + label);
} else {
Scheduler.log('Ref unmount: ' + label);
}
}}>
<Text text={label} />
</span>
);
}
const LazyChildA = React.lazy(() => fakeImport(ChildA));
const LazyChildB = React.lazy(() => fakeImport(ChildB));
function Parent({swap}) {
return (
<React.Suspense fallback={<Text text="Loading..." />}>
{swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
</React.Suspense>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<Parent swap={false} />);
});
assertLog(['Loading...']);
await act(() => resolveFakeImport(ChildA));
assertLog(['A', 'Ref mount: A']);
expect(container.innerHTML).toBe('<span>A</span>');
// Swap the position of A and B
ReactDOM.flushSync(() => {
root.render(<Parent swap={true} />);
});
assertLog(['Loading...', 'Ref unmount: A']);
expect(container.innerHTML).toBe(
'<span style="display: none;">A</span>Loading...',
);
// Destroy the whole tree, including the hidden A
ReactDOM.flushSync(() => {
root.render(<h1>Hello</h1>);
});
await waitForAll([]);
expect(container.innerHTML).toBe('<h1>Hello</h1>');
});
it('does not call componentWillUnmount twice when parent suspense is removed', async () => {
class ChildA extends React.Component {
componentDidMount() {
Scheduler.log('Did mount: ' + this.props.label);
}
componentWillUnmount() {
Scheduler.log('Will unmount: ' + this.props.label);
}
render() {
return <Text text={this.props.label} />;
}
}
class ChildB extends React.Component {
componentDidMount() {
Scheduler.log('Did mount: ' + this.props.label);
}
componentWillUnmount() {
Scheduler.log('Will unmount: ' + this.props.label);
}
render() {
return <Text text={this.props.label} />;
}
}
const LazyChildA = React.lazy(() => fakeImport(ChildA));
const LazyChildB = React.lazy(() => fakeImport(ChildB));
function Parent({swap}) {
return (
<React.Suspense fallback={<Text text="Loading..." />}>
{swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
</React.Suspense>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<Parent swap={false} />);
});
assertLog(['Loading...']);
await act(() => resolveFakeImport(ChildA));
assertLog(['A', 'Did mount: A']);
expect(container.innerHTML).toBe('A');
// Swap the position of A and B
ReactDOM.flushSync(() => {
root.render(<Parent swap={true} />);
});
assertLog(['Loading...', 'Will unmount: A']);
expect(container.innerHTML).toBe('Loading...');
// Destroy the whole tree, including the hidden A
ReactDOM.flushSync(() => {
root.render(<h1>Hello</h1>);
});
await waitForAll([]);
expect(container.innerHTML).toBe('<h1>Hello</h1>');
});
it('regression: unmount hidden tree, in legacy mode', async () => {
// In legacy mode, when a tree suspends and switches to a fallback, the
// effects are not unmounted. So we have to unmount them during a deletion.
function Child() {
React.useLayoutEffect(() => {
Scheduler.log('Mount');
return () => {
Scheduler.log('Unmount');
};
}, []);
return <Text text="Child" />;
}
function Sibling() {
return <Text text="Sibling" />;
}
const LazySibling = React.lazy(() => fakeImport(Sibling));
function App({showMore}) {
return (
<React.Suspense fallback={<Text text="Loading..." />}>
<Child />
{showMore ? <LazySibling /> : null}
</React.Suspense>
);
}
// Initial render
ReactDOM.render(<App showMore={false} />, container);
assertLog(['Child', 'Mount']);
// Update that suspends, causing the existing tree to switches it to
// a fallback.
ReactDOM.render(<App showMore={true} />, container);
assertLog([
'Child',
'Loading...',
// In a concurrent root, the effect would unmount here. But this is legacy
// mode, so it doesn't.
// Unmount
]);
// Delete the tree and unmount the effect
ReactDOM.render(null, container);
assertLog(['Unmount']);
});
it('does not call cleanup effects twice after a bailout', async () => {
const never = new Promise(resolve => {});
function Never() {
throw never;
}
let setSuspended;
let setLetter;
function App() {
const [suspended, _setSuspended] = React.useState(false);
setSuspended = _setSuspended;
const [letter, _setLetter] = React.useState('A');
setLetter = _setLetter;
return (
<React.Suspense fallback="Loading...">
<Child letter={letter} />
{suspended && <Never />}
</React.Suspense>
);
}
let nextId = 0;
const freed = new Set();
let setStep;
function Child({letter}) {
const [, _setStep] = React.useState(0);
setStep = _setStep;
React.useLayoutEffect(() => {
const localId = nextId++;
Scheduler.log('Did mount: ' + letter + localId);
return () => {
if (freed.has(localId)) {
throw Error('Double free: ' + letter + localId);
}
freed.add(localId);
Scheduler.log('Will unmount: ' + letter + localId);
};
}, [letter]);
}
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<App />);
});
assertLog(['Did mount: A0']);
await act(() => {
setStep(1);
setSuspended(false);
});
assertLog([]);
await act(() => {
setStep(1);
});
assertLog([]);
await act(() => {
setSuspended(true);
});
assertLog(['Will unmount: A0']);
await act(() => {
setSuspended(false);
setLetter('B');
});
assertLog(['Did mount: B1']);
await act(() => {
root.unmount();
});
assertLog(['Will unmount: B1']);
});
});