main
js 558 lines 15.3 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 Activity;
18 let useState;
19 let useLayoutEffect;
20 let useEffect;
21 let LegacyHidden;
22 let assertLog;
23 let Suspense;
24
25 describe('ReactDOMActivity', () => {
26 let container;
27
28 beforeEach(() => {
29 jest.resetModules();
30 React = require('react');
31 Scheduler = require('scheduler/unstable_mock');
32 Activity = React.Activity;
33 useState = React.useState;
34 Suspense = React.Suspense;
35 useState = React.useState;
36 LegacyHidden = React.unstable_LegacyHidden;
37 useLayoutEffect = React.useLayoutEffect;
38 useEffect = React.useEffect;
39 ReactDOM = require('react-dom');
40 ReactDOMClient = require('react-dom/client');
41 const InternalTestUtils = require('internal-test-utils');
42 act = InternalTestUtils.act;
43 assertLog = InternalTestUtils.assertLog;
44 container = document.createElement('div');
45 document.body.appendChild(container);
46 });
47
48 afterEach(() => {
49 document.body.removeChild(container);
50 });
51
52 function Text(props) {
53 Scheduler.log(props.text);
54 return <span prop={props.text}>{props.children}</span>;
55 }
56
57 it(
58 'hiding an Activity boundary also hides the direct children of any ' +
59 'portals it contains, regardless of how deeply nested they are',
60 async () => {
61 const portalContainer = document.createElement('div');
62
63 let setShow;
64 function Accordion({children}) {
65 const [shouldShow, _setShow] = useState(true);
66 setShow = _setShow;
67 return (
68 <Activity mode={shouldShow ? 'visible' : 'hidden'}>
69 {children}
70 </Activity>
71 );
72 }
73
74 function App() {
75 return (
76 <Accordion>
77 <div>
78 {ReactDOM.createPortal(
79 <div>Portal contents</div>,
80 portalContainer,
81 )}
82 </div>
83 </Accordion>
84 );
85 }
86
87 const root = ReactDOMClient.createRoot(container);
88 await act(() => root.render(<App />));
89 expect(container.innerHTML).toBe('<div></div>');
90 expect(portalContainer.innerHTML).toBe('<div>Portal contents</div>');
91
92 // Hide the Activity boundary. Not only are the nearest DOM elements hidden,
93 // but also the children of the nested portal contained within it.
94 await act(() => setShow(false));
95 expect(container.innerHTML).toBe('<div style="display: none;"></div>');
96 expect(portalContainer.innerHTML).toBe(
97 '<div style="display: none;">Portal contents</div>',
98 );
99 },
100 );
101
102 it(
103 'revealing an Activity boundary inside a portal does not reveal the ' +
104 'portal contents if has a hidden Activity parent',
105 async () => {
106 const portalContainer = document.createElement('div');
107
108 let setShow;
109 function Accordion({children}) {
110 const [shouldShow, _setShow] = useState(false);
111 setShow = _setShow;
112 return (
113 <Activity mode={shouldShow ? 'visible' : 'hidden'}>
114 {children}
115 </Activity>
116 );
117 }
118
119 function App() {
120 return (
121 <Activity mode="hidden">
122 <div>
123 {ReactDOM.createPortal(
124 <Accordion>
125 <div>Portal contents</div>
126 </Accordion>,
127 portalContainer,
128 )}
129 </div>
130 </Activity>
131 );
132 }
133
134 // Start with both boundaries hidden.
135 const root = ReactDOMClient.createRoot(container);
136 await act(() => root.render(<App />));
137 expect(container.innerHTML).toBe('<div style="display: none;"></div>');
138 expect(portalContainer.innerHTML).toBe(
139 '<div style="display: none;">Portal contents</div>',
140 );
141
142 // Reveal the inner Activity boundary. It should not reveal its children,
143 // because there's a parent Activity boundary that is still hidden.
144 await act(() => setShow(true));
145 expect(container.innerHTML).toBe('<div style="display: none;"></div>');
146 expect(portalContainer.innerHTML).toBe(
147 '<div style="display: none;">Portal contents</div>',
148 );
149 },
150 );
151
152 it('hides new portals added to an already hidden tree', async () => {
153 function Child() {
154 return <Text text="Child" />;
155 }
156
157 const portalContainer = document.createElement('div');
158
159 function Portal({children}) {
160 return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
161 }
162
163 const root = ReactDOMClient.createRoot(container);
164 // Mount hidden tree.
165 await act(() => {
166 root.render(
167 <Activity mode="hidden">
168 <Text text="Parent" />
169 </Activity>,
170 );
171 });
172 assertLog(['Parent']);
173 expect(container.innerHTML).toBe(
174 '<span prop="Parent" style="display: none;"></span>',
175 );
176 expect(portalContainer.innerHTML).toBe('');
177
178 // Add a portal inside the hidden tree.
179 await act(() => {
180 root.render(
181 <Activity mode="hidden">
182 <Text text="Parent" />
183 <Portal>
184 <Child />
185 </Portal>
186 </Activity>,
187 );
188 });
189 assertLog(['Parent', 'Child']);
190 expect(container.innerHTML).toBe(
191 '<span prop="Parent" style="display: none;"></span><div style="display: none;"></div>',
192 );
193 expect(portalContainer.innerHTML).toBe(
194 '<span prop="Child" style="display: none;"></span>',
195 );
196
197 // Now reveal it.
198 await act(() => {
199 root.render(
200 <Activity mode="visible">
201 <Text text="Parent" />
202 <Portal>
203 <Child />
204 </Portal>
205 </Activity>,
206 );
207 });
208
209 assertLog(['Parent', 'Child']);
210 expect(container.innerHTML).toBe(
211 '<span prop="Parent" style=""></span><div style=""></div>',
212 );
213 expect(portalContainer.innerHTML).toBe(
214 '<span prop="Child" style=""></span>',
215 );
216 });
217
218 it('hides new insertions inside an already hidden portal', async () => {
219 function Child({text}) {
220 useLayoutEffect(() => {
221 Scheduler.log(`Mount layout ${text}`);
222 return () => {
223 Scheduler.log(`Unmount layout ${text}`);
224 };
225 }, [text]);
226 return <Text text={text} />;
227 }
228
229 const portalContainer = document.createElement('div');
230
231 function Portal({children}) {
232 return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
233 }
234
235 const root = ReactDOMClient.createRoot(container);
236 // Mount hidden tree.
237 await act(() => {
238 root.render(
239 <Activity mode="hidden">
240 <Portal>
241 <Child text="A" />
242 </Portal>
243 </Activity>,
244 );
245 });
246 assertLog(['A']);
247 expect(container.innerHTML).toBe('<div style="display: none;"></div>');
248 expect(portalContainer.innerHTML).toBe(
249 '<span prop="A" style="display: none;"></span>',
250 );
251
252 // Add a node inside the hidden portal.
253 await act(() => {
254 root.render(
255 <Activity mode="hidden">
256 <Portal>
257 <Child text="A" />
258 <Child text="B" />
259 </Portal>
260 </Activity>,
261 );
262 });
263 assertLog(['A', 'B']);
264 expect(container.innerHTML).toBe('<div style="display: none;"></div>');
265 expect(portalContainer.innerHTML).toBe(
266 '<span prop="A" style="display: none;"></span><span prop="B" style="display: none;"></span>',
267 );
268
269 // Now reveal it.
270 await act(() => {
271 root.render(
272 <Activity mode="visible">
273 <Portal>
274 <Child text="A" />
275 <Child text="B" />
276 </Portal>
277 </Activity>,
278 );
279 });
280
281 assertLog(['A', 'B', 'Mount layout A', 'Mount layout B']);
282 expect(container.innerHTML).toBe('<div style=""></div>');
283 expect(portalContainer.innerHTML).toBe(
284 '<span prop="A" style=""></span><span prop="B" style=""></span>',
285 );
286 });
287
288 it('reveal an inner Suspense boundary without revealing an outer Activity on the same host child', async () => {
289 const promise = new Promise(() => {});
290
291 function Child({showInner}) {
292 useLayoutEffect(() => {
293 Scheduler.log('Mount layout');
294 return () => {
295 Scheduler.log('Unmount layout');
296 };
297 }, []);
298 return (
299 <>
300 {showInner ? null : promise}
301 <Text text="Child" />
302 </>
303 );
304 }
305
306 const portalContainer = document.createElement('div');
307
308 function Portal({children}) {
309 return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
310 }
311
312 const root = ReactDOMClient.createRoot(container);
313
314 // Prerender the whole tree.
315 await act(() => {
316 root.render(
317 <Activity mode="hidden">
318 <Portal>
319 <Suspense name="Inner" fallback={<span>Loading</span>}>
320 <Child showInner={true} />
321 </Suspense>
322 </Portal>
323 </Activity>,
324 );
325 });
326
327 assertLog(['Child']);
328 expect(container.innerHTML).toBe('<div style="display: none;"></div>');
329 expect(portalContainer.innerHTML).toBe(
330 '<span prop="Child" style="display: none;"></span>',
331 );
332
333 // Re-suspend the inner.
334 await act(() => {
335 root.render(
336 <Activity mode="hidden">
337 <Portal>
338 <Suspense name="Inner" fallback={<span>Loading</span>}>
339 <Child showInner={false} />
340 </Suspense>
341 </Portal>
342 </Activity>,
343 );
344 });
345 assertLog([]);
346 expect(container.innerHTML).toBe('<div style="display: none;"></div>');
347 expect(portalContainer.innerHTML).toBe(
348 '<span prop="Child" style="display: none;"></span><span style="display: none;">Loading</span>',
349 );
350
351 // Toggle to visible while suspended.
352 await act(() => {
353 root.render(
354 <Activity mode="visible">
355 <Portal>
356 <Suspense name="Inner" fallback={<span>Loading</span>}>
357 <Child showInner={false} />
358 </Suspense>
359 </Portal>
360 </Activity>,
361 );
362 });
363 assertLog([]);
364 expect(container.innerHTML).toBe('<div style=""></div>');
365 expect(portalContainer.innerHTML).toBe(
366 '<span prop="Child" style="display: none;"></span><span style="">Loading</span>',
367 );
368
369 // Now reveal.
370 await act(() => {
371 root.render(
372 <Activity mode="visible">
373 <Portal>
374 <Suspense name="Inner" fallback={<span>Loading</span>}>
375 <Child showInner={true} />
376 </Suspense>
377 </Portal>
378 </Activity>,
379 );
380 });
381 assertLog(['Child', 'Mount layout']);
382 expect(container.innerHTML).toBe('<div style=""></div>');
383 expect(portalContainer.innerHTML).toBe(
384 '<span prop="Child" style=""></span>',
385 );
386 });
387
388 it('mounts/unmounts layout effects in portal when visibility changes (starting visible)', async () => {
389 function Child() {
390 useLayoutEffect(() => {
391 Scheduler.log('Mount layout');
392 return () => {
393 Scheduler.log('Unmount layout');
394 };
395 }, []);
396 return <Text text="Child" />;
397 }
398
399 const portalContainer = document.createElement('div');
400
401 function Portal({children}) {
402 return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
403 }
404
405 const root = ReactDOMClient.createRoot(container);
406 // Mount visible tree.
407 await act(() => {
408 root.render(
409 <Activity mode="visible">
410 <Portal>
411 <Child />
412 </Portal>
413 </Activity>,
414 );
415 });
416 assertLog(['Child', 'Mount layout']);
417 expect(container.innerHTML).toBe('<div></div>');
418 expect(portalContainer.innerHTML).toBe('<span prop="Child"></span>');
419
420 // Hide the tree. The layout effect is unmounted.
421 await act(() => {
422 root.render(
423 <Activity mode="hidden">
424 <Portal>
425 <Child />
426 </Portal>
427 </Activity>,
428 );
429 });
430 assertLog(['Unmount layout', 'Child']);
431 expect(container.innerHTML).toBe('<div style="display: none;"></div>');
432 expect(portalContainer.innerHTML).toBe(
433 '<span prop="Child" style="display: none;"></span>',
434 );
435 });
436
437 it('mounts/unmounts layout effects in portal when visibility changes (starting hidden)', async () => {
438 function Child() {
439 useLayoutEffect(() => {
440 Scheduler.log('Mount layout');
441 return () => {
442 Scheduler.log('Unmount layout');
443 };
444 }, []);
445 return <Text text="Child" />;
446 }
447
448 const portalContainer = document.createElement('div');
449
450 function Portal({children}) {
451 return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
452 }
453
454 const root = ReactDOMClient.createRoot(container);
455 // Mount hidden tree.
456 await act(() => {
457 root.render(
458 <Activity mode="hidden">
459 <Portal>
460 <Child />
461 </Portal>
462 </Activity>,
463 );
464 });
465 // No layout effect.
466 assertLog(['Child']);
467 expect(container.innerHTML).toBe('<div style="display: none;"></div>');
468 expect(portalContainer.innerHTML).toBe(
469 '<span prop="Child" style="display: none;"></span>',
470 );
471
472 // Unhide the tree. The layout effect is mounted.
473 await act(() => {
474 root.render(
475 <Activity mode="visible">
476 <Portal>
477 <Child />
478 </Portal>
479 </Activity>,
480 );
481 });
482 assertLog(['Child', 'Mount layout']);
483 expect(container.innerHTML).toBe('<div style=""></div>');
484 expect(portalContainer.innerHTML).toBe(
485 '<span prop="Child" style=""></span>',
486 );
487 });
488
489 // @gate enableLegacyHidden
490 it('does not toggle effects or hide nodes for LegacyHidden component inside portal', async () => {
491 function Child() {
492 useLayoutEffect(() => {
493 Scheduler.log('Mount layout');
494 return () => {
495 Scheduler.log('Unmount layout');
496 };
497 }, []);
498 useEffect(() => {
499 Scheduler.log('Mount passive');
500 return () => {
501 Scheduler.log('Unmount passive');
502 };
503 }, []);
504 return <Text text="Child" />;
505 }
506
507 const portalContainer = document.createElement('div');
508
509 function Portal({children}) {
510 return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
511 }
512
513 const root = ReactDOMClient.createRoot(container);
514 // Mount visible tree.
515 await act(() => {
516 root.render(
517 <LegacyHidden mode="visible">
518 <Portal>
519 <Child />
520 </Portal>
521 </LegacyHidden>,
522 );
523 });
524 assertLog(['Child', 'Mount layout', 'Mount passive']);
525 expect(container.innerHTML).toBe('<div></div>');
526 expect(portalContainer.innerHTML).toBe('<span prop="Child"></span>');
527
528 // Hide the tree.
529 await act(() => {
530 root.render(
531 <LegacyHidden mode="hidden">
532 <Portal>
533 <Child />
534 </Portal>
535 </LegacyHidden>,
536 );
537 });
538 // Effects not unmounted.
539 assertLog(['Child']);
540 expect(container.innerHTML).toBe('<div></div>');
541 expect(portalContainer.innerHTML).toBe('<span prop="Child"></span>');
542
543 // Unhide the tree.
544 await act(() => {
545 root.render(
546 <LegacyHidden mode="visible">
547 <Portal>
548 <Child />
549 </Portal>
550 </LegacyHidden>,
551 );
552 });
553 // Effects already mounted.
554 assertLog(['Child']);
555 expect(container.innerHTML).toBe('<div></div>');
556 expect(portalContainer.innerHTML).toBe('<span prop="Child"></span>');
557 });
558 });