- /**
- * 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']); 
- });
- });