/** * 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 ( <> ); } 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
; } const neverResolves = {then() {}}; function ComponentThatSuspendsOnUpdate({shouldSuspend}) { if (shouldSuspend) { // Fake Suspend throw neverResolves; } return null; } await act(() => { const root = ReactDOMClient.createRoot(container); root.render(); }); await act(() => { scheduleSuspendingUpdate(); }); }); it('does not destroy ref cleanup twice when hidden child is removed', async () => { function ChildA({label}) { return ( { if (node) { Scheduler.log('Ref mount: ' + label); } else { Scheduler.log('Ref unmount: ' + label); } }}> ); } function ChildB({label}) { return ( { if (node) { Scheduler.log('Ref mount: ' + label); } else { Scheduler.log('Ref unmount: ' + label); } }}> ); } const LazyChildA = React.lazy(() => fakeImport(ChildA)); const LazyChildB = React.lazy(() => fakeImport(ChildB)); function Parent({swap}) { return ( }> {swap ? : } ); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render(); }); assertLog(['Loading...']); await act(() => resolveFakeImport(ChildA)); assertLog(['A', 'Ref mount: A']); expect(container.innerHTML).toBe('A'); // Swap the position of A and B ReactDOM.flushSync(() => { root.render(); }); assertLog(['Loading...', 'Ref unmount: A']); expect(container.innerHTML).toBe( 'ALoading...', ); await act(() => resolveFakeImport(ChildB)); assertLog(['B', 'Ref mount: B']); expect(container.innerHTML).toBe('B'); }); 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 ; } } class ChildB extends React.Component { componentDidMount() { Scheduler.log('Did mount: ' + this.props.label); } componentWillUnmount() { Scheduler.log('Will unmount: ' + this.props.label); } render() { return ; } } const LazyChildA = React.lazy(() => fakeImport(ChildA)); const LazyChildB = React.lazy(() => fakeImport(ChildB)); function Parent({swap}) { return ( }> {swap ? : } ); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render(); }); 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(); }); 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 ; } function ChildB({label}) { React.useLayoutEffect(() => { Scheduler.log('Did mount: ' + label); return () => { Scheduler.log('Will unmount: ' + label); }; }, []); return ; } const LazyChildA = React.lazy(() => fakeImport(ChildA)); const LazyChildB = React.lazy(() => fakeImport(ChildB)); function Parent({swap}) { return ( }> {swap ? : } ); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render(); }); 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(); }); assertLog(['Loading...', 'Will unmount: A']); expect(container.innerHTML).toBe('Loading...'); // Destroy the whole tree, including the hidden A ReactDOM.flushSync(() => { root.render(

Hello

); }); await waitForAll([]); expect(container.innerHTML).toBe('

Hello

'); }); it('does not destroy ref cleanup twice when parent suspense is removed', async () => { function ChildA({label}) { return ( { if (node) { Scheduler.log('Ref mount: ' + label); } else { Scheduler.log('Ref unmount: ' + label); } }}> ); } function ChildB({label}) { return ( { if (node) { Scheduler.log('Ref mount: ' + label); } else { Scheduler.log('Ref unmount: ' + label); } }}> ); } const LazyChildA = React.lazy(() => fakeImport(ChildA)); const LazyChildB = React.lazy(() => fakeImport(ChildB)); function Parent({swap}) { return ( }> {swap ? : } ); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render(); }); assertLog(['Loading...']); await act(() => resolveFakeImport(ChildA)); assertLog(['A', 'Ref mount: A']); expect(container.innerHTML).toBe('A'); // Swap the position of A and B ReactDOM.flushSync(() => { root.render(); }); assertLog(['Loading...', 'Ref unmount: A']); expect(container.innerHTML).toBe( 'ALoading...', ); // Destroy the whole tree, including the hidden A ReactDOM.flushSync(() => { root.render(

Hello

); }); await waitForAll([]); expect(container.innerHTML).toBe('

Hello

'); }); 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 ; } } class ChildB extends React.Component { componentDidMount() { Scheduler.log('Did mount: ' + this.props.label); } componentWillUnmount() { Scheduler.log('Will unmount: ' + this.props.label); } render() { return ; } } const LazyChildA = React.lazy(() => fakeImport(ChildA)); const LazyChildB = React.lazy(() => fakeImport(ChildB)); function Parent({swap}) { return ( }> {swap ? : } ); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render(); }); 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(); }); assertLog(['Loading...', 'Will unmount: A']); expect(container.innerHTML).toBe('Loading...'); // Destroy the whole tree, including the hidden A ReactDOM.flushSync(() => { root.render(

Hello

); }); await waitForAll([]); expect(container.innerHTML).toBe('

Hello

'); }); // @gate !disableLegacyMode 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 ; } function Sibling() { return ; } const LazySibling = React.lazy(() => fakeImport(Sibling)); function App({showMore}) { return ( }> {showMore ? : null} ); } // Initial render ReactDOM.render(, container); assertLog(['Child', 'Mount']); // Update that suspends, causing the existing tree to switches it to // a fallback. ReactDOM.render(, 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 ( {suspended && } ); } 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(); }); 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']); }); });