@samitouri / QOS-React / commits / ec52a5698e

Fix: Don't call cWU if already unmounted

When a tree goes offscreen, we unmount all the effects just like we would in a normal deletion. (Conceptually it _is_ a deletion; we keep the fiber around so we can reuse its state if the tree mounts again.) If an offscreen component gets deleted "for real", we shouldn't unmount it again. The fix is to track on the stack whether we're inside a hidden tree. We already had a stack variable for this purpose, called `offscreenSubtreeWasHidden`, in another part of the commit phase, so I reused that variable instead of creating a new one. (The name is a bit confusing: "was" refers to the current tree before this commit. So, the "previous current".) Co-authored-by: dan <dan.abramov@me.com>

Andrew Clark committed Apr 6, 2022 at 23:47 UTC ec52a5698e2dfea7050a0b015f0b79abfb2d81b7
4 files changed +555 -102
packages/react-reconciler/src/ReactFiberCommitWork.new.js
+85 -50
@@ -1604,7 +1604,9 @@ function commitDeletionEffectsOnFiber(
1604 // that don't modify the stack.
1605 switch (deletedFiber.tag) {
1606 case HostComponent: {
1607 - safelyDetachRef(deletedFiber, nearestMountedAncestor);
1607 + if (!offscreenSubtreeWasHidden) {
1608 + safelyDetachRef(deletedFiber, nearestMountedAncestor);
1609 + }
1610 // Intentional fallthrough to next branch
1611 }
1612 // eslint-disable-next-line-no-fallthrough
@@ -1710,54 +1712,56 @@ function commitDeletionEffectsOnFiber(
1712 case ForwardRef:
1713 case MemoComponent:
1714 case SimpleMemoComponent: {
1713 - const updateQueue: FunctionComponentUpdateQueue | null = (deletedFiber.updateQueue: any);
1714 - if (updateQueue !== null) {
1715 - const lastEffect = updateQueue.lastEffect;
1716 - if (lastEffect !== null) {
1717 - const firstEffect = lastEffect.next;
1718 -
1719 - let effect = firstEffect;
1720 - do {
1721 - const {destroy, tag} = effect;
1722 - if (destroy !== undefined) {
1723 - if ((tag & HookInsertion) !== NoHookEffect) {
1724 - safelyCallDestroy(
1725 - deletedFiber,
1726 - nearestMountedAncestor,
1727 - destroy,
1728 - );
1729 - } else if ((tag & HookLayout) !== NoHookEffect) {
1730 - if (enableSchedulingProfiler) {
1731 - markComponentLayoutEffectUnmountStarted(deletedFiber);
1732 - }
1733 -
1734 - if (
1735 - enableProfilerTimer &&
1736 - enableProfilerCommitHooks &&
1737 - deletedFiber.mode & ProfileMode
1738 - ) {
1739 - startLayoutEffectTimer();
1740 - safelyCallDestroy(
1741 - deletedFiber,
1742 - nearestMountedAncestor,
1743 - destroy,
1744 - );
1745 - recordLayoutEffectDuration(deletedFiber);
1746 - } else {
1715 + if (!offscreenSubtreeWasHidden) {
1716 + const updateQueue: FunctionComponentUpdateQueue | null = (deletedFiber.updateQueue: any);
1717 + if (updateQueue !== null) {
1718 + const lastEffect = updateQueue.lastEffect;
1719 + if (lastEffect !== null) {
1720 + const firstEffect = lastEffect.next;
1721 +
1722 + let effect = firstEffect;
1723 + do {
1724 + const {destroy, tag} = effect;
1725 + if (destroy !== undefined) {
1726 + if ((tag & HookInsertion) !== NoHookEffect) {
1727 safelyCallDestroy(
1728 deletedFiber,
1729 nearestMountedAncestor,
1730 destroy,
1731 );
1752 - }
1732 + } else if ((tag & HookLayout) !== NoHookEffect) {
1733 + if (enableSchedulingProfiler) {
1734 + markComponentLayoutEffectUnmountStarted(deletedFiber);
1735 + }
1736
1754 - if (enableSchedulingProfiler) {
1755 - markComponentLayoutEffectUnmountStopped();
1737 + if (
1738 + enableProfilerTimer &&
1739 + enableProfilerCommitHooks &&
1740 + deletedFiber.mode & ProfileMode
1741 + ) {
1742 + startLayoutEffectTimer();
1743 + safelyCallDestroy(
1744 + deletedFiber,
1745 + nearestMountedAncestor,
1746 + destroy,
1747 + );
1748 + recordLayoutEffectDuration(deletedFiber);
1749 + } else {
1750 + safelyCallDestroy(
1751 + deletedFiber,
1752 + nearestMountedAncestor,
1753 + destroy,
1754 + );
1755 + }
1756 +
1757 + if (enableSchedulingProfiler) {
1758 + markComponentLayoutEffectUnmountStopped();
1759 + }
1760 }
1761 }
1758 - }
1759 - effect = effect.next;
1760 - } while (effect !== firstEffect);
1762 + effect = effect.next;
1763 + } while (effect !== firstEffect);
1764 + }
1765 }
1766 }
1767
@@ -1769,14 +1773,16 @@ function commitDeletionEffectsOnFiber(
1773 return;
1774 }
1775 case ClassComponent: {
1772 - safelyDetachRef(deletedFiber, nearestMountedAncestor);
1773 - const instance = deletedFiber.stateNode;
1774 - if (typeof instance.componentWillUnmount === 'function') {
1775 - safelyCallComponentWillUnmount(
1776 - deletedFiber,
1777 - nearestMountedAncestor,
1778 - instance,
1779 - );
1776 + if (!offscreenSubtreeWasHidden) {
1777 + safelyDetachRef(deletedFiber, nearestMountedAncestor);
1778 + const instance = deletedFiber.stateNode;
1779 + if (typeof instance.componentWillUnmount === 'function') {
1780 + safelyCallComponentWillUnmount(
1781 + deletedFiber,
1782 + nearestMountedAncestor,
1783 + instance,
1784 + );
1785 + }
1786 }
1787 recursivelyTraverseDeletionEffects(
1788 finishedRoot,
@@ -1796,6 +1802,27 @@ function commitDeletionEffectsOnFiber(
1802 );
1803 return;
1804 }
1805 + case OffscreenComponent: {
1806 + // If this offscreen component is hidden, we already unmounted it. Before
1807 + // deleting the children, track that it's already unmounted so that we
1808 + // don't attempt to unmount the effects again.
1809 + // TODO: If the tree is hidden, in most cases we should be able to skip
1810 + // over the nested children entirely. An exception is we haven't yet found
1811 + // the topmost host node to delete, which we already track on the stack.
1812 + // But the other case is portals, which need to be detached no matter how
1813 + // deeply they are nested. We should use a subtree flag to track whether a
1814 + // subtree includes a nested portal.
1815 + const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
1816 + offscreenSubtreeWasHidden =
1817 + prevOffscreenSubtreeWasHidden || deletedFiber.memoizedState !== null;
1818 + recursivelyTraverseDeletionEffects(
1819 + finishedRoot,
1820 + nearestMountedAncestor,
1821 + deletedFiber,
1822 + );
1823 + offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
1824 + break;
1825 + }
1826 default: {
1827 recursivelyTraverseDeletionEffects(
1828 finishedRoot,
@@ -2203,13 +2230,21 @@ function commitMutationEffectsOnFiber(
2230 return;
2231 }
2232 case OffscreenComponent: {
2233 + const wasHidden = current !== null && current.memoizedState !== null;
2234 +
2235 + // Before committing the children, track on the stack whether this
2236 + // offscreen subtree was already hidden, so that we don't unmount the
2237 + // effects again.
2238 + const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
2239 + offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden || wasHidden;
2240 recursivelyTraverseMutationEffects(root, finishedWork, lanes);
2241 + offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
2242 +
2243 commitReconciliationEffects(finishedWork);
2244
2245 if (flags & Visibility) {
2246 const newState: OffscreenState | null = finishedWork.memoizedState;
2247 const isHidden = newState !== null;
2212 - const wasHidden = current !== null && current.memoizedState !== null;
2248 const offscreenBoundary: Fiber = finishedWork;
2249
2250 if (supportsMutation) {
packages/react-reconciler/src/ReactFiberCommitWork.old.js
+85 -50
@@ -1604,7 +1604,9 @@ function commitDeletionEffectsOnFiber(
1604 // that don't modify the stack.
1605 switch (deletedFiber.tag) {
1606 case HostComponent: {
1607 - safelyDetachRef(deletedFiber, nearestMountedAncestor);
1607 + if (!offscreenSubtreeWasHidden) {
1608 + safelyDetachRef(deletedFiber, nearestMountedAncestor);
1609 + }
1610 // Intentional fallthrough to next branch
1611 }
1612 // eslint-disable-next-line-no-fallthrough
@@ -1710,54 +1712,56 @@ function commitDeletionEffectsOnFiber(
1712 case ForwardRef:
1713 case MemoComponent:
1714 case SimpleMemoComponent: {
1713 - const updateQueue: FunctionComponentUpdateQueue | null = (deletedFiber.updateQueue: any);
1714 - if (updateQueue !== null) {
1715 - const lastEffect = updateQueue.lastEffect;
1716 - if (lastEffect !== null) {
1717 - const firstEffect = lastEffect.next;
1718 -
1719 - let effect = firstEffect;
1720 - do {
1721 - const {destroy, tag} = effect;
1722 - if (destroy !== undefined) {
1723 - if ((tag & HookInsertion) !== NoHookEffect) {
1724 - safelyCallDestroy(
1725 - deletedFiber,
1726 - nearestMountedAncestor,
1727 - destroy,
1728 - );
1729 - } else if ((tag & HookLayout) !== NoHookEffect) {
1730 - if (enableSchedulingProfiler) {
1731 - markComponentLayoutEffectUnmountStarted(deletedFiber);
1732 - }
1733 -
1734 - if (
1735 - enableProfilerTimer &&
1736 - enableProfilerCommitHooks &&
1737 - deletedFiber.mode & ProfileMode
1738 - ) {
1739 - startLayoutEffectTimer();
1740 - safelyCallDestroy(
1741 - deletedFiber,
1742 - nearestMountedAncestor,
1743 - destroy,
1744 - );
1745 - recordLayoutEffectDuration(deletedFiber);
1746 - } else {
1715 + if (!offscreenSubtreeWasHidden) {
1716 + const updateQueue: FunctionComponentUpdateQueue | null = (deletedFiber.updateQueue: any);
1717 + if (updateQueue !== null) {
1718 + const lastEffect = updateQueue.lastEffect;
1719 + if (lastEffect !== null) {
1720 + const firstEffect = lastEffect.next;
1721 +
1722 + let effect = firstEffect;
1723 + do {
1724 + const {destroy, tag} = effect;
1725 + if (destroy !== undefined) {
1726 + if ((tag & HookInsertion) !== NoHookEffect) {
1727 safelyCallDestroy(
1728 deletedFiber,
1729 nearestMountedAncestor,
1730 destroy,
1731 );
1752 - }
1732 + } else if ((tag & HookLayout) !== NoHookEffect) {
1733 + if (enableSchedulingProfiler) {
1734 + markComponentLayoutEffectUnmountStarted(deletedFiber);
1735 + }
1736
1754 - if (enableSchedulingProfiler) {
1755 - markComponentLayoutEffectUnmountStopped();
1737 + if (
1738 + enableProfilerTimer &&
1739 + enableProfilerCommitHooks &&
1740 + deletedFiber.mode & ProfileMode
1741 + ) {
1742 + startLayoutEffectTimer();
1743 + safelyCallDestroy(
1744 + deletedFiber,
1745 + nearestMountedAncestor,
1746 + destroy,
1747 + );
1748 + recordLayoutEffectDuration(deletedFiber);
1749 + } else {
1750 + safelyCallDestroy(
1751 + deletedFiber,
1752 + nearestMountedAncestor,
1753 + destroy,
1754 + );
1755 + }
1756 +
1757 + if (enableSchedulingProfiler) {
1758 + markComponentLayoutEffectUnmountStopped();
1759 + }
1760 }
1761 }
1758 - }
1759 - effect = effect.next;
1760 - } while (effect !== firstEffect);
1762 + effect = effect.next;
1763 + } while (effect !== firstEffect);
1764 + }
1765 }
1766 }
1767
@@ -1769,14 +1773,16 @@ function commitDeletionEffectsOnFiber(
1773 return;
1774 }
1775 case ClassComponent: {
1772 - safelyDetachRef(deletedFiber, nearestMountedAncestor);
1773 - const instance = deletedFiber.stateNode;
1774 - if (typeof instance.componentWillUnmount === 'function') {
1775 - safelyCallComponentWillUnmount(
1776 - deletedFiber,
1777 - nearestMountedAncestor,
1778 - instance,
1779 - );
1776 + if (!offscreenSubtreeWasHidden) {
1777 + safelyDetachRef(deletedFiber, nearestMountedAncestor);
1778 + const instance = deletedFiber.stateNode;
1779 + if (typeof instance.componentWillUnmount === 'function') {
1780 + safelyCallComponentWillUnmount(
1781 + deletedFiber,
1782 + nearestMountedAncestor,
1783 + instance,
1784 + );
1785 + }
1786 }
1787 recursivelyTraverseDeletionEffects(
1788 finishedRoot,
@@ -1796,6 +1802,27 @@ function commitDeletionEffectsOnFiber(
1802 );
1803 return;
1804 }
1805 + case OffscreenComponent: {
1806 + // If this offscreen component is hidden, we already unmounted it. Before
1807 + // deleting the children, track that it's already unmounted so that we
1808 + // don't attempt to unmount the effects again.
1809 + // TODO: If the tree is hidden, in most cases we should be able to skip
1810 + // over the nested children entirely. An exception is we haven't yet found
1811 + // the topmost host node to delete, which we already track on the stack.
1812 + // But the other case is portals, which need to be detached no matter how
1813 + // deeply they are nested. We should use a subtree flag to track whether a
1814 + // subtree includes a nested portal.
1815 + const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
1816 + offscreenSubtreeWasHidden =
1817 + prevOffscreenSubtreeWasHidden || deletedFiber.memoizedState !== null;
1818 + recursivelyTraverseDeletionEffects(
1819 + finishedRoot,
1820 + nearestMountedAncestor,
1821 + deletedFiber,
1822 + );
1823 + offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
1824 + break;
1825 + }
1826 default: {
1827 recursivelyTraverseDeletionEffects(
1828 finishedRoot,
@@ -2203,13 +2230,21 @@ function commitMutationEffectsOnFiber(
2230 return;
2231 }
2232 case OffscreenComponent: {
2233 + const wasHidden = current !== null && current.memoizedState !== null;
2234 +
2235 + // Before committing the children, track on the stack whether this
2236 + // offscreen subtree was already hidden, so that we don't unmount the
2237 + // effects again.
2238 + const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
2239 + offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden || wasHidden;
2240 recursivelyTraverseMutationEffects(root, finishedWork, lanes);
2241 + offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
2242 +
2243 commitReconciliationEffects(finishedWork);
2244
2245 if (flags & Visibility) {
2246 const newState: OffscreenState | null = finishedWork.memoizedState;
2247 const isHidden = newState !== null;
2212 - const wasHidden = current !== null && current.memoizedState !== null;
2248 const offscreenBoundary: Fiber = finishedWork;
2249
2250 if (supportsMutation) {
packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js
-1
@@ -1980,7 +1980,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
1980
1981 // Destroy layout and passive effects in the errored tree.
1982 'App destroy layout',
1983 - 'ThrowsInWillUnmount componentWillUnmount',
1983 'Text:Fallback destroy layout',
1984 'Text:Outside destroy layout',
1985 'Text:Inside destroy passive',
packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemanticsDOM-test.js
+385 -1
@@ -10,18 +10,39 @@
10 'use strict';
11
12 let React;
13 +let ReactDOM;
14 let ReactDOMClient;
15 +let Scheduler;
16 let act;
17 +let container;
18
19 describe('ReactSuspenseEffectsSemanticsDOM', () => {
20 beforeEach(() => {
21 jest.resetModules();
22
23 React = require('react');
24 + ReactDOM = require('react-dom');
25 ReactDOMClient = require('react-dom/client');
26 + Scheduler = require('scheduler');
27 act = require('jest-react').act;
28 +
29 + container = document.createElement('div');
30 + document.body.appendChild(container);
31 + });
32 +
33 + afterEach(() => {
34 + document.body.removeChild(container);
35 });
36
37 + async function fakeImport(result) {
38 + return {default: result};
39 + }
40 +
41 + function Text(props) {
42 + Scheduler.unstable_yieldValue(props.text);
43 + return props.text;
44 + }
45 +
46 it('should not cause a cycle when combined with a render phase update', () => {
47 let scheduleSuspendingUpdate;
48
@@ -63,7 +84,7 @@ describe('ReactSuspenseEffectsSemanticsDOM', () => {
84 }
85
86 act(() => {
66 - const root = ReactDOMClient.createRoot(document.createElement('div'));
87 + const root = ReactDOMClient.createRoot(container);
88 root.render(<App />);
89 });
90
@@ -71,4 +92,367 @@ describe('ReactSuspenseEffectsSemanticsDOM', () => {
92 scheduleSuspendingUpdate();
93 });
94 });
95 +
96 + it('does not destroy layout effects twice when hidden child is removed', async () => {
97 + function ChildA({label}) {
98 + React.useLayoutEffect(() => {
99 + Scheduler.unstable_yieldValue('Did mount: ' + label);
100 + return () => {
101 + Scheduler.unstable_yieldValue('Will unmount: ' + label);
102 + };
103 + }, []);
104 + return <Text text={label} />;
105 + }
106 +
107 + function ChildB({label}) {
108 + React.useLayoutEffect(() => {
109 + Scheduler.unstable_yieldValue('Did mount: ' + label);
110 + return () => {
111 + Scheduler.unstable_yieldValue('Will unmount: ' + label);
112 + };
113 + }, []);
114 + return <Text text={label} />;
115 + }
116 +
117 + const LazyChildA = React.lazy(() => fakeImport(ChildA));
118 + const LazyChildB = React.lazy(() => fakeImport(ChildB));
119 +
120 + function Parent({swap}) {
121 + return (
122 + <React.Suspense fallback={<Text text="Loading..." />}>
123 + {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
124 + </React.Suspense>
125 + );
126 + }
127 +
128 + const root = ReactDOMClient.createRoot(container);
129 + act(() => {
130 + root.render(<Parent swap={false} />);
131 + });
132 + expect(Scheduler).toHaveYielded(['Loading...']);
133 +
134 + await LazyChildA;
135 + expect(Scheduler).toFlushAndYield(['A', 'Did mount: A']);
136 + expect(container.innerHTML).toBe('A');
137 +
138 + // Swap the position of A and B
139 + ReactDOM.flushSync(() => {
140 + root.render(<Parent swap={true} />);
141 + });
142 + expect(Scheduler).toHaveYielded(['Loading...', 'Will unmount: A']);
143 + expect(container.innerHTML).toBe('Loading...');
144 +
145 + await LazyChildB;
146 + expect(Scheduler).toFlushAndYield(['B', 'Did mount: B']);
147 + expect(container.innerHTML).toBe('B');
148 + });
149 +
150 + it('does not destroy ref cleanup twice when hidden child is removed', async () => {
151 + function ChildA({label}) {
152 + return (
153 + <span
154 + ref={node => {
155 + if (node) {
156 + Scheduler.unstable_yieldValue('Ref mount: ' + label);
157 + } else {
158 + Scheduler.unstable_yieldValue('Ref unmount: ' + label);
159 + }
160 + }}>
161 + <Text text={label} />
162 + </span>
163 + );
164 + }
165 +
166 + function ChildB({label}) {
167 + return (
168 + <span
169 + ref={node => {
170 + if (node) {
171 + Scheduler.unstable_yieldValue('Ref mount: ' + label);
172 + } else {
173 + Scheduler.unstable_yieldValue('Ref unmount: ' + label);
174 + }
175 + }}>
176 + <Text text={label} />
177 + </span>
178 + );
179 + }
180 +
181 + const LazyChildA = React.lazy(() => fakeImport(ChildA));
182 + const LazyChildB = React.lazy(() => fakeImport(ChildB));
183 +
184 + function Parent({swap}) {
185 + return (
186 + <React.Suspense fallback={<Text text="Loading..." />}>
187 + {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
188 + </React.Suspense>
189 + );
190 + }
191 +
192 + const root = ReactDOMClient.createRoot(container);
193 + act(() => {
194 + root.render(<Parent swap={false} />);
195 + });
196 + expect(Scheduler).toHaveYielded(['Loading...']);
197 +
198 + await LazyChildA;
199 + expect(Scheduler).toFlushAndYield(['A', 'Ref mount: A']);
200 + expect(container.innerHTML).toBe('<span>A</span>');
201 +
202 + // Swap the position of A and B
203 + ReactDOM.flushSync(() => {
204 + root.render(<Parent swap={true} />);
205 + });
206 + expect(Scheduler).toHaveYielded(['Loading...', 'Ref unmount: A']);
207 + expect(container.innerHTML).toBe(
208 + '<span style="display: none;">A</span>Loading...',
209 + );
210 +
211 + await LazyChildB;
212 + expect(Scheduler).toFlushAndYield(['B', 'Ref mount: B']);
213 + expect(container.innerHTML).toBe('<span>B</span>');
214 + });
215 +
216 + it('does not call componentWillUnmount twice when hidden child is removed', async () => {
217 + class ChildA extends React.Component {
218 + componentDidMount() {
219 + Scheduler.unstable_yieldValue('Did mount: ' + this.props.label);
220 + }
221 + componentWillUnmount() {
222 + Scheduler.unstable_yieldValue('Will unmount: ' + this.props.label);
223 + }
224 + render() {
225 + return <Text text={this.props.label} />;
226 + }
227 + }
228 +
229 + class ChildB extends React.Component {
230 + componentDidMount() {
231 + Scheduler.unstable_yieldValue('Did mount: ' + this.props.label);
232 + }
233 + componentWillUnmount() {
234 + Scheduler.unstable_yieldValue('Will unmount: ' + this.props.label);
235 + }
236 + render() {
237 + return <Text text={this.props.label} />;
238 + }
239 + }
240 +
241 + const LazyChildA = React.lazy(() => fakeImport(ChildA));
242 + const LazyChildB = React.lazy(() => fakeImport(ChildB));
243 +
244 + function Parent({swap}) {
245 + return (
246 + <React.Suspense fallback={<Text text="Loading..." />}>
247 + {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
248 + </React.Suspense>
249 + );
250 + }
251 +
252 + const root = ReactDOMClient.createRoot(container);
253 + act(() => {
254 + root.render(<Parent swap={false} />);
255 + });
256 + expect(Scheduler).toHaveYielded(['Loading...']);
257 +
258 + await LazyChildA;
259 + expect(Scheduler).toFlushAndYield(['A', 'Did mount: A']);
260 + expect(container.innerHTML).toBe('A');
261 +
262 + // Swap the position of A and B
263 + ReactDOM.flushSync(() => {
264 + root.render(<Parent swap={true} />);
265 + });
266 + expect(Scheduler).toHaveYielded(['Loading...', 'Will unmount: A']);
267 + expect(container.innerHTML).toBe('Loading...');
268 +
269 + await LazyChildB;
270 + expect(Scheduler).toFlushAndYield(['B', 'Did mount: B']);
271 + expect(container.innerHTML).toBe('B');
272 + });
273 +
274 + it('does not destroy layout effects twice when parent suspense is removed', async () => {
275 + function ChildA({label}) {
276 + React.useLayoutEffect(() => {
277 + Scheduler.unstable_yieldValue('Did mount: ' + label);
278 + return () => {
279 + Scheduler.unstable_yieldValue('Will unmount: ' + label);
280 + };
281 + }, []);
282 + return <Text text={label} />;
283 + }
284 + function ChildB({label}) {
285 + React.useLayoutEffect(() => {
286 + Scheduler.unstable_yieldValue('Did mount: ' + label);
287 + return () => {
288 + Scheduler.unstable_yieldValue('Will unmount: ' + label);
289 + };
290 + }, []);
291 + return <Text text={label} />;
292 + }
293 + const LazyChildA = React.lazy(() => fakeImport(ChildA));
294 + const LazyChildB = React.lazy(() => fakeImport(ChildB));
295 +
296 + function Parent({swap}) {
297 + return (
298 + <React.Suspense fallback={<Text text="Loading..." />}>
299 + {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
300 + </React.Suspense>
301 + );
302 + }
303 +
304 + const root = ReactDOMClient.createRoot(container);
305 + act(() => {
306 + root.render(<Parent swap={false} />);
307 + });
308 + expect(Scheduler).toHaveYielded(['Loading...']);
309 +
310 + await LazyChildA;
311 + expect(Scheduler).toFlushAndYield(['A', 'Did mount: A']);
312 + expect(container.innerHTML).toBe('A');
313 +
314 + // Swap the position of A and B
315 + ReactDOM.flushSync(() => {
316 + root.render(<Parent swap={true} />);
317 + });
318 + expect(Scheduler).toHaveYielded(['Loading...', 'Will unmount: A']);
319 + expect(container.innerHTML).toBe('Loading...');
320 +
321 + // Destroy the whole tree, including the hidden A
322 + ReactDOM.flushSync(() => {
323 + root.render(<h1>Hello</h1>);
324 + });
325 + expect(Scheduler).toFlushAndYield([]);
326 + expect(container.innerHTML).toBe('<h1>Hello</h1>');
327 + });
328 +
329 + it('does not destroy ref cleanup twice when parent suspense is removed', async () => {
330 + function ChildA({label}) {
331 + return (
332 + <span
333 + ref={node => {
334 + if (node) {
335 + Scheduler.unstable_yieldValue('Ref mount: ' + label);
336 + } else {
337 + Scheduler.unstable_yieldValue('Ref unmount: ' + label);
338 + }
339 + }}>
340 + <Text text={label} />
341 + </span>
342 + );
343 + }
344 +
345 + function ChildB({label}) {
346 + return (
347 + <span
348 + ref={node => {
349 + if (node) {
350 + Scheduler.unstable_yieldValue('Ref mount: ' + label);
351 + } else {
352 + Scheduler.unstable_yieldValue('Ref unmount: ' + label);
353 + }
354 + }}>
355 + <Text text={label} />
356 + </span>
357 + );
358 + }
359 +
360 + const LazyChildA = React.lazy(() => fakeImport(ChildA));
361 + const LazyChildB = React.lazy(() => fakeImport(ChildB));
362 +
363 + function Parent({swap}) {
364 + return (
365 + <React.Suspense fallback={<Text text="Loading..." />}>
366 + {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
367 + </React.Suspense>
368 + );
369 + }
370 +
371 + const root = ReactDOMClient.createRoot(container);
372 + act(() => {
373 + root.render(<Parent swap={false} />);
374 + });
375 + expect(Scheduler).toHaveYielded(['Loading...']);
376 +
377 + await LazyChildA;
378 + expect(Scheduler).toFlushAndYield(['A', 'Ref mount: A']);
379 + expect(container.innerHTML).toBe('<span>A</span>');
380 +
381 + // Swap the position of A and B
382 + ReactDOM.flushSync(() => {
383 + root.render(<Parent swap={true} />);
384 + });
385 + expect(Scheduler).toHaveYielded(['Loading...', 'Ref unmount: A']);
386 + expect(container.innerHTML).toBe(
387 + '<span style="display: none;">A</span>Loading...',
388 + );
389 +
390 + // Destroy the whole tree, including the hidden A
391 + ReactDOM.flushSync(() => {
392 + root.render(<h1>Hello</h1>);
393 + });
394 + expect(Scheduler).toFlushAndYield([]);
395 + expect(container.innerHTML).toBe('<h1>Hello</h1>');
396 + });
397 +
398 + it('does not call componentWillUnmount twice when parent suspense is removed', async () => {
399 + class ChildA extends React.Component {
400 + componentDidMount() {
401 + Scheduler.unstable_yieldValue('Did mount: ' + this.props.label);
402 + }
403 + componentWillUnmount() {
404 + Scheduler.unstable_yieldValue('Will unmount: ' + this.props.label);
405 + }
406 + render() {
407 + return <Text text={this.props.label} />;
408 + }
409 + }
410 +
411 + class ChildB extends React.Component {
412 + componentDidMount() {
413 + Scheduler.unstable_yieldValue('Did mount: ' + this.props.label);
414 + }
415 + componentWillUnmount() {
416 + Scheduler.unstable_yieldValue('Will unmount: ' + this.props.label);
417 + }
418 + render() {
419 + return <Text text={this.props.label} />;
420 + }
421 + }
422 +
423 + const LazyChildA = React.lazy(() => fakeImport(ChildA));
424 + const LazyChildB = React.lazy(() => fakeImport(ChildB));
425 +
426 + function Parent({swap}) {
427 + return (
428 + <React.Suspense fallback={<Text text="Loading..." />}>
429 + {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
430 + </React.Suspense>
431 + );
432 + }
433 +
434 + const root = ReactDOMClient.createRoot(container);
435 + act(() => {
436 + root.render(<Parent swap={false} />);
437 + });
438 + expect(Scheduler).toHaveYielded(['Loading...']);
439 +
440 + await LazyChildA;
441 + expect(Scheduler).toFlushAndYield(['A', 'Did mount: A']);
442 + expect(container.innerHTML).toBe('A');
443 +
444 + // Swap the position of A and B
445 + ReactDOM.flushSync(() => {
446 + root.render(<Parent swap={true} />);
447 + });
448 + expect(Scheduler).toHaveYielded(['Loading...', 'Will unmount: A']);
449 + expect(container.innerHTML).toBe('Loading...');
450 +
451 + // Destroy the whole tree, including the hidden A
452 + ReactDOM.flushSync(() => {
453 + root.render(<h1>Hello</h1>);
454 + });
455 + expect(Scheduler).toFlushAndYield([]);
456 + expect(container.innerHTML).toBe('<h1>Hello</h1>');
457 + });
458 });