'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);
}
const [_, setRef] = React.useState(null);
return <div ref={setRef} />;
}
const neverResolves = {then() {}};
function ComponentThatSuspendsOnUpdate({shouldSuspend}) {
if (shouldSuspend) {
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 resolveFakeImport(ChildA);
await waitForAll(['A', 'Ref mount: A']);
expect(container.innerHTML).toBe('<span>A</span>');
ReactDOM.flushSync(() => {
root.render(<Parent swap={true} />);
});
assertLog(['Loading...', 'Ref unmount: A']);
expect(container.innerHTML).toBe(
'<span style="display: none;">A</span>Loading...',
);
await resolveFakeImport(ChildB);
await waitForAll(['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 resolveFakeImport(ChildA);
await waitForAll(['A', 'Did mount: A']);
expect(container.innerHTML).toBe('A');
ReactDOM.flushSync(() => {
root.render(<Parent swap={true} />);
});
assertLog(['Loading...', 'Will unmount: A']);
expect(container.innerHTML).toBe('Loading...');
await resolveFakeImport(ChildB);
await waitForAll(['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 resolveFakeImport(ChildA);
await waitForAll(['A', 'Did mount: A']);
expect(container.innerHTML).toBe('A');
ReactDOM.flushSync(() => {
root.render(<Parent swap={true} />);
});
assertLog(['Loading...', 'Will unmount: A']);
expect(container.innerHTML).toBe('Loading...');
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 resolveFakeImport(ChildA);
await waitForAll(['A', 'Ref mount: A']);
expect(container.innerHTML).toBe('<span>A</span>');
ReactDOM.flushSync(() => {
root.render(<Parent swap={true} />);
});
assertLog(['Loading...', 'Ref unmount: A']);
expect(container.innerHTML).toBe(
'<span style="display: none;">A</span>Loading...',
);
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 resolveFakeImport(ChildA);
await waitForAll(['A', 'Did mount: A']);
expect(container.innerHTML).toBe('A');
ReactDOM.flushSync(() => {
root.render(<Parent swap={true} />);
});
assertLog(['Loading...', 'Will unmount: A']);
expect(container.innerHTML).toBe('Loading...');
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 () => {
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>
);
}
ReactDOM.render(<App showMore={false} />, container);
assertLog(['Child', 'Mount']);
ReactDOM.render(<App showMore={true} />, container);
assertLog([
'Child',
'Loading...',
]);
ReactDOM.render(null, container);
assertLog(['Unmount']);
});
});