main
js 578 lines 15.1 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @emails react-core
8 */
9
10 'use strict';
11
12 let React;
13 let ReactDOM;
14 let ReactDOMClient;
15 let Scheduler;
16 let act;
17 let container;
18 let waitForAll;
19 let assertLog;
20 let fakeModuleCache;
21
22 describe('ReactSuspenseEffectsSemanticsDOM', () => {
23 beforeEach(() => {
24 jest.resetModules();
25
26 React = require('react');
27 ReactDOM = require('react-dom');
28 ReactDOMClient = require('react-dom/client');
29 Scheduler = require('scheduler');
30 act = require('internal-test-utils').act;
31
32 const InternalTestUtils = require('internal-test-utils');
33 waitForAll = InternalTestUtils.waitForAll;
34 assertLog = InternalTestUtils.assertLog;
35
36 container = document.createElement('div');
37 document.body.appendChild(container);
38
39 fakeModuleCache = new Map();
40 });
41
42 afterEach(() => {
43 document.body.removeChild(container);
44 });
45
46 async function fakeImport(Component) {
47 const record = fakeModuleCache.get(Component);
48 if (record === undefined) {
49 const newRecord = {
50 status: 'pending',
51 value: {default: Component},
52 pings: [],
53 then(ping) {
54 switch (newRecord.status) {
55 case 'pending': {
56 newRecord.pings.push(ping);
57 return;
58 }
59 case 'resolved': {
60 ping(newRecord.value);
61 return;
62 }
63 case 'rejected': {
64 throw newRecord.value;
65 }
66 }
67 },
68 };
69 fakeModuleCache.set(Component, newRecord);
70 return newRecord;
71 }
72 return record;
73 }
74
75 function resolveFakeImport(moduleName) {
76 const record = fakeModuleCache.get(moduleName);
77 if (record === undefined) {
78 throw new Error('Module not found');
79 }
80 if (record.status !== 'pending') {
81 throw new Error('Module already resolved');
82 }
83 record.status = 'resolved';
84 record.pings.forEach(ping => ping(record.value));
85 }
86
87 function Text(props) {
88 Scheduler.log(props.text);
89 return props.text;
90 }
91
92 it('should not cause a cycle when combined with a render phase update', async () => {
93 let scheduleSuspendingUpdate;
94
95 function App() {
96 const [value, setValue] = React.useState(true);
97
98 scheduleSuspendingUpdate = () => setValue(!value);
99
100 return (
101 <>
102 <React.Suspense fallback="Loading...">
103 <ComponentThatCausesBug value={value} />
104 <ComponentThatSuspendsOnUpdate shouldSuspend={!value} />
105 </React.Suspense>
106 </>
107 );
108 }
109
110 function ComponentThatCausesBug({value}) {
111 const [mirroredValue, setMirroredValue] = React.useState(value);
112 if (mirroredValue !== value) {
113 setMirroredValue(value);
114 }
115
116 // eslint-disable-next-line no-unused-vars
117 const [_, setRef] = React.useState(null);
118
119 return <div ref={setRef} />;
120 }
121
122 const neverResolves = {then() {}};
123
124 function ComponentThatSuspendsOnUpdate({shouldSuspend}) {
125 if (shouldSuspend) {
126 // Fake Suspend
127 throw neverResolves;
128 }
129 return null;
130 }
131
132 await act(() => {
133 const root = ReactDOMClient.createRoot(container);
134 root.render(<App />);
135 });
136
137 await act(() => {
138 scheduleSuspendingUpdate();
139 });
140 });
141
142 it('does not destroy ref cleanup twice when hidden child is removed', async () => {
143 function ChildA({label}) {
144 return (
145 <span
146 ref={node => {
147 if (node) {
148 Scheduler.log('Ref mount: ' + label);
149 } else {
150 Scheduler.log('Ref unmount: ' + label);
151 }
152 }}>
153 <Text text={label} />
154 </span>
155 );
156 }
157
158 function ChildB({label}) {
159 return (
160 <span
161 ref={node => {
162 if (node) {
163 Scheduler.log('Ref mount: ' + label);
164 } else {
165 Scheduler.log('Ref unmount: ' + label);
166 }
167 }}>
168 <Text text={label} />
169 </span>
170 );
171 }
172
173 const LazyChildA = React.lazy(() => fakeImport(ChildA));
174 const LazyChildB = React.lazy(() => fakeImport(ChildB));
175
176 function Parent({swap}) {
177 return (
178 <React.Suspense fallback={<Text text="Loading..." />}>
179 {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
180 </React.Suspense>
181 );
182 }
183
184 const root = ReactDOMClient.createRoot(container);
185 await act(() => {
186 root.render(<Parent swap={false} />);
187 });
188 assertLog(['Loading...']);
189
190 await act(() => resolveFakeImport(ChildA));
191 assertLog(['A', 'Ref mount: A']);
192 expect(container.innerHTML).toBe('<span>A</span>');
193
194 // Swap the position of A and B
195 ReactDOM.flushSync(() => {
196 root.render(<Parent swap={true} />);
197 });
198 assertLog(['Loading...', 'Ref unmount: A']);
199 expect(container.innerHTML).toBe(
200 '<span style="display: none;">A</span>Loading...',
201 );
202
203 await act(() => resolveFakeImport(ChildB));
204 assertLog(['B', 'Ref mount: B']);
205 expect(container.innerHTML).toBe('<span>B</span>');
206 });
207
208 it('does not call componentWillUnmount twice when hidden child is removed', async () => {
209 class ChildA extends React.Component {
210 componentDidMount() {
211 Scheduler.log('Did mount: ' + this.props.label);
212 }
213 componentWillUnmount() {
214 Scheduler.log('Will unmount: ' + this.props.label);
215 }
216 render() {
217 return <Text text={this.props.label} />;
218 }
219 }
220
221 class ChildB extends React.Component {
222 componentDidMount() {
223 Scheduler.log('Did mount: ' + this.props.label);
224 }
225 componentWillUnmount() {
226 Scheduler.log('Will unmount: ' + this.props.label);
227 }
228 render() {
229 return <Text text={this.props.label} />;
230 }
231 }
232
233 const LazyChildA = React.lazy(() => fakeImport(ChildA));
234 const LazyChildB = React.lazy(() => fakeImport(ChildB));
235
236 function Parent({swap}) {
237 return (
238 <React.Suspense fallback={<Text text="Loading..." />}>
239 {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
240 </React.Suspense>
241 );
242 }
243
244 const root = ReactDOMClient.createRoot(container);
245 await act(() => {
246 root.render(<Parent swap={false} />);
247 });
248 assertLog(['Loading...']);
249
250 await act(() => resolveFakeImport(ChildA));
251 assertLog(['A', 'Did mount: A']);
252 expect(container.innerHTML).toBe('A');
253
254 // Swap the position of A and B
255 ReactDOM.flushSync(() => {
256 root.render(<Parent swap={true} />);
257 });
258 assertLog(['Loading...', 'Will unmount: A']);
259 expect(container.innerHTML).toBe('Loading...');
260
261 await act(() => resolveFakeImport(ChildB));
262 assertLog(['B', 'Did mount: B']);
263 expect(container.innerHTML).toBe('B');
264 });
265
266 it('does not destroy layout effects twice when parent suspense is removed', async () => {
267 function ChildA({label}) {
268 React.useLayoutEffect(() => {
269 Scheduler.log('Did mount: ' + label);
270 return () => {
271 Scheduler.log('Will unmount: ' + label);
272 };
273 }, []);
274 return <Text text={label} />;
275 }
276 function ChildB({label}) {
277 React.useLayoutEffect(() => {
278 Scheduler.log('Did mount: ' + label);
279 return () => {
280 Scheduler.log('Will unmount: ' + label);
281 };
282 }, []);
283 return <Text text={label} />;
284 }
285 const LazyChildA = React.lazy(() => fakeImport(ChildA));
286 const LazyChildB = React.lazy(() => fakeImport(ChildB));
287
288 function Parent({swap}) {
289 return (
290 <React.Suspense fallback={<Text text="Loading..." />}>
291 {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
292 </React.Suspense>
293 );
294 }
295
296 const root = ReactDOMClient.createRoot(container);
297 await act(() => {
298 root.render(<Parent swap={false} />);
299 });
300 assertLog(['Loading...']);
301
302 await act(() => resolveFakeImport(ChildA));
303 assertLog(['A', 'Did mount: A']);
304 expect(container.innerHTML).toBe('A');
305
306 // Swap the position of A and B
307 ReactDOM.flushSync(() => {
308 root.render(<Parent swap={true} />);
309 });
310 assertLog(['Loading...', 'Will unmount: A']);
311 expect(container.innerHTML).toBe('Loading...');
312
313 // Destroy the whole tree, including the hidden A
314 ReactDOM.flushSync(() => {
315 root.render(<h1>Hello</h1>);
316 });
317 await waitForAll([]);
318 expect(container.innerHTML).toBe('<h1>Hello</h1>');
319 });
320
321 it('does not destroy ref cleanup twice when parent suspense is removed', async () => {
322 function ChildA({label}) {
323 return (
324 <span
325 ref={node => {
326 if (node) {
327 Scheduler.log('Ref mount: ' + label);
328 } else {
329 Scheduler.log('Ref unmount: ' + label);
330 }
331 }}>
332 <Text text={label} />
333 </span>
334 );
335 }
336
337 function ChildB({label}) {
338 return (
339 <span
340 ref={node => {
341 if (node) {
342 Scheduler.log('Ref mount: ' + label);
343 } else {
344 Scheduler.log('Ref unmount: ' + label);
345 }
346 }}>
347 <Text text={label} />
348 </span>
349 );
350 }
351
352 const LazyChildA = React.lazy(() => fakeImport(ChildA));
353 const LazyChildB = React.lazy(() => fakeImport(ChildB));
354
355 function Parent({swap}) {
356 return (
357 <React.Suspense fallback={<Text text="Loading..." />}>
358 {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
359 </React.Suspense>
360 );
361 }
362
363 const root = ReactDOMClient.createRoot(container);
364 await act(() => {
365 root.render(<Parent swap={false} />);
366 });
367 assertLog(['Loading...']);
368
369 await act(() => resolveFakeImport(ChildA));
370 assertLog(['A', 'Ref mount: A']);
371 expect(container.innerHTML).toBe('<span>A</span>');
372
373 // Swap the position of A and B
374 ReactDOM.flushSync(() => {
375 root.render(<Parent swap={true} />);
376 });
377 assertLog(['Loading...', 'Ref unmount: A']);
378 expect(container.innerHTML).toBe(
379 '<span style="display: none;">A</span>Loading...',
380 );
381
382 // Destroy the whole tree, including the hidden A
383 ReactDOM.flushSync(() => {
384 root.render(<h1>Hello</h1>);
385 });
386 await waitForAll([]);
387 expect(container.innerHTML).toBe('<h1>Hello</h1>');
388 });
389
390 it('does not call componentWillUnmount twice when parent suspense is removed', async () => {
391 class ChildA extends React.Component {
392 componentDidMount() {
393 Scheduler.log('Did mount: ' + this.props.label);
394 }
395 componentWillUnmount() {
396 Scheduler.log('Will unmount: ' + this.props.label);
397 }
398 render() {
399 return <Text text={this.props.label} />;
400 }
401 }
402
403 class ChildB extends React.Component {
404 componentDidMount() {
405 Scheduler.log('Did mount: ' + this.props.label);
406 }
407 componentWillUnmount() {
408 Scheduler.log('Will unmount: ' + this.props.label);
409 }
410 render() {
411 return <Text text={this.props.label} />;
412 }
413 }
414
415 const LazyChildA = React.lazy(() => fakeImport(ChildA));
416 const LazyChildB = React.lazy(() => fakeImport(ChildB));
417
418 function Parent({swap}) {
419 return (
420 <React.Suspense fallback={<Text text="Loading..." />}>
421 {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
422 </React.Suspense>
423 );
424 }
425
426 const root = ReactDOMClient.createRoot(container);
427 await act(() => {
428 root.render(<Parent swap={false} />);
429 });
430 assertLog(['Loading...']);
431
432 await act(() => resolveFakeImport(ChildA));
433 assertLog(['A', 'Did mount: A']);
434 expect(container.innerHTML).toBe('A');
435
436 // Swap the position of A and B
437 ReactDOM.flushSync(() => {
438 root.render(<Parent swap={true} />);
439 });
440 assertLog(['Loading...', 'Will unmount: A']);
441 expect(container.innerHTML).toBe('Loading...');
442
443 // Destroy the whole tree, including the hidden A
444 ReactDOM.flushSync(() => {
445 root.render(<h1>Hello</h1>);
446 });
447 await waitForAll([]);
448 expect(container.innerHTML).toBe('<h1>Hello</h1>');
449 });
450
451 // @gate !disableLegacyMode
452 it('regression: unmount hidden tree, in legacy mode', async () => {
453 // In legacy mode, when a tree suspends and switches to a fallback, the
454 // effects are not unmounted. So we have to unmount them during a deletion.
455
456 function Child() {
457 React.useLayoutEffect(() => {
458 Scheduler.log('Mount');
459 return () => {
460 Scheduler.log('Unmount');
461 };
462 }, []);
463 return <Text text="Child" />;
464 }
465
466 function Sibling() {
467 return <Text text="Sibling" />;
468 }
469 const LazySibling = React.lazy(() => fakeImport(Sibling));
470
471 function App({showMore}) {
472 return (
473 <React.Suspense fallback={<Text text="Loading..." />}>
474 <Child />
475 {showMore ? <LazySibling /> : null}
476 </React.Suspense>
477 );
478 }
479
480 // Initial render
481 ReactDOM.render(<App showMore={false} />, container);
482 assertLog(['Child', 'Mount']);
483
484 // Update that suspends, causing the existing tree to switches it to
485 // a fallback.
486 ReactDOM.render(<App showMore={true} />, container);
487 assertLog([
488 'Child',
489 'Loading...',
490
491 // In a concurrent root, the effect would unmount here. But this is legacy
492 // mode, so it doesn't.
493 // Unmount
494 ]);
495
496 // Delete the tree and unmount the effect
497 ReactDOM.render(null, container);
498 assertLog(['Unmount']);
499 });
500
501 it('does not call cleanup effects twice after a bailout', async () => {
502 const never = new Promise(resolve => {});
503 function Never() {
504 throw never;
505 }
506
507 let setSuspended;
508 let setLetter;
509
510 function App() {
511 const [suspended, _setSuspended] = React.useState(false);
512 setSuspended = _setSuspended;
513 const [letter, _setLetter] = React.useState('A');
514 setLetter = _setLetter;
515
516 return (
517 <React.Suspense fallback="Loading...">
518 <Child letter={letter} />
519 {suspended && <Never />}
520 </React.Suspense>
521 );
522 }
523
524 let nextId = 0;
525 const freed = new Set();
526 let setStep;
527
528 function Child({letter}) {
529 const [, _setStep] = React.useState(0);
530 setStep = _setStep;
531
532 React.useLayoutEffect(() => {
533 const localId = nextId++;
534 Scheduler.log('Did mount: ' + letter + localId);
535 return () => {
536 if (freed.has(localId)) {
537 throw Error('Double free: ' + letter + localId);
538 }
539 freed.add(localId);
540 Scheduler.log('Will unmount: ' + letter + localId);
541 };
542 }, [letter]);
543 }
544
545 const root = ReactDOMClient.createRoot(container);
546 await act(() => {
547 root.render(<App />);
548 });
549 assertLog(['Did mount: A0']);
550
551 await act(() => {
552 setStep(1);
553 setSuspended(false);
554 });
555 assertLog([]);
556
557 await act(() => {
558 setStep(1);
559 });
560 assertLog([]);
561
562 await act(() => {
563 setSuspended(true);
564 });
565 assertLog(['Will unmount: A0']);
566
567 await act(() => {
568 setSuspended(false);
569 setLetter('B');
570 });
571 assertLog(['Did mount: B1']);
572
573 await act(() => {
574 root.unmount();
575 });
576 assertLog(['Will unmount: B1']);
577 });
578 });