main
js 1,631 lines 44 KB
Raw
1 let React;
2 let ReactNoop;
3 let Scheduler;
4 let act;
5 let LegacyHidden;
6 let Activity;
7 let useState;
8 let useLayoutEffect;
9 let useEffect;
10 let useInsertionEffect;
11 let useMemo;
12 let startTransition;
13 let waitForPaint;
14 let waitFor;
15 let assertLog;
16 let assertConsoleErrorDev;
17 let Suspense;
18
19 describe('Activity', () => {
20 beforeEach(() => {
21 jest.resetModules();
22
23 React = require('react');
24 ReactNoop = require('react-noop-renderer');
25 Scheduler = require('scheduler');
26 act = require('internal-test-utils').act;
27 LegacyHidden = React.unstable_LegacyHidden;
28 Activity = React.Activity;
29 Suspense = React.Suspense;
30 useState = React.useState;
31 useInsertionEffect = React.useInsertionEffect;
32 useLayoutEffect = React.useLayoutEffect;
33 useEffect = React.useEffect;
34 useMemo = React.useMemo;
35 startTransition = React.startTransition;
36
37 const InternalTestUtils = require('internal-test-utils');
38 waitForPaint = InternalTestUtils.waitForPaint;
39 waitFor = InternalTestUtils.waitFor;
40 assertLog = InternalTestUtils.assertLog;
41 assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
42 });
43
44 function Text(props) {
45 Scheduler.log(props.text);
46 return <span prop={props.text}>{props.children}</span>;
47 }
48
49 // @gate enableLegacyHidden
50 it('unstable-defer-without-hiding should never toggle the visibility of its children', async () => {
51 function App({mode}) {
52 return (
53 <>
54 <Text text="Normal" />
55 <LegacyHidden mode={mode}>
56 <Text text="Deferred" />
57 </LegacyHidden>
58 </>
59 );
60 }
61
62 // Test the initial mount
63 const root = ReactNoop.createRoot();
64 await act(async () => {
65 root.render(<App mode="unstable-defer-without-hiding" />);
66 await waitForPaint(['Normal']);
67 expect(root).toMatchRenderedOutput(<span prop="Normal" />);
68 });
69 assertLog(['Deferred']);
70 expect(root).toMatchRenderedOutput(
71 <>
72 <span prop="Normal" />
73 <span prop="Deferred" />
74 </>,
75 );
76
77 // Now try after an update
78 await act(() => {
79 root.render(<App mode="visible" />);
80 });
81 assertLog(['Normal', 'Deferred']);
82 expect(root).toMatchRenderedOutput(
83 <>
84 <span prop="Normal" />
85 <span prop="Deferred" />
86 </>,
87 );
88
89 await act(async () => {
90 root.render(<App mode="unstable-defer-without-hiding" />);
91 await waitForPaint(['Normal']);
92 expect(root).toMatchRenderedOutput(
93 <>
94 <span prop="Normal" />
95 <span prop="Deferred" />
96 </>,
97 );
98 });
99 assertLog(['Deferred']);
100 expect(root).toMatchRenderedOutput(
101 <>
102 <span prop="Normal" />
103 <span prop="Deferred" />
104 </>,
105 );
106 });
107
108 // @gate enableLegacyHidden && !disableLegacyMode
109 it('does not defer in legacy mode', async () => {
110 let setState;
111 function Foo() {
112 const [state, _setState] = useState('A');
113 setState = _setState;
114 return <Text text={state} />;
115 }
116
117 const root = ReactNoop.createLegacyRoot();
118 await act(() => {
119 root.render(
120 <>
121 <LegacyHidden mode="hidden">
122 <Foo />
123 </LegacyHidden>
124 <Text text="Outside" />
125 </>,
126 );
127
128 ReactNoop.flushSync();
129
130 // Should not defer the hidden tree
131 assertLog(['A', 'Outside']);
132 });
133 expect(root).toMatchRenderedOutput(
134 <>
135 <span prop="A" />
136 <span prop="Outside" />
137 </>,
138 );
139
140 // Test that the children can be updated
141 await act(() => {
142 setState('B');
143 });
144 assertLog(['B']);
145 expect(root).toMatchRenderedOutput(
146 <>
147 <span prop="B" />
148 <span prop="Outside" />
149 </>,
150 );
151 });
152
153 // @gate enableLegacyHidden
154 it('does defer in concurrent mode', async () => {
155 let setState;
156 function Foo() {
157 const [state, _setState] = useState('A');
158 setState = _setState;
159 return <Text text={state} />;
160 }
161
162 const root = ReactNoop.createRoot();
163 await act(async () => {
164 root.render(
165 <>
166 <LegacyHidden mode="hidden">
167 <Foo />
168 </LegacyHidden>
169 <Text text="Outside" />
170 </>,
171 );
172 // Should defer the hidden tree.
173 await waitForPaint(['Outside']);
174 });
175
176 // The hidden tree was rendered at lower priority.
177 assertLog(['A']);
178
179 expect(root).toMatchRenderedOutput(
180 <>
181 <span prop="A" />
182 <span prop="Outside" />
183 </>,
184 );
185
186 // Test that the children can be updated
187 await act(() => {
188 setState('B');
189 });
190 assertLog(['B']);
191 expect(root).toMatchRenderedOutput(
192 <>
193 <span prop="B" />
194 <span prop="Outside" />
195 </>,
196 );
197 });
198
199 it('mounts without layout effects when hidden', async () => {
200 function Child({text}) {
201 useLayoutEffect(() => {
202 Scheduler.log('Mount layout');
203 return () => {
204 Scheduler.log('Unmount layout');
205 };
206 }, []);
207 return <Text text="Child" />;
208 }
209
210 const root = ReactNoop.createRoot();
211
212 // Mount hidden tree.
213 await act(() => {
214 root.render(
215 <Activity mode="hidden">
216 <Child />
217 </Activity>,
218 );
219 });
220 // No layout effect.
221 assertLog(['Child']);
222 expect(root).toMatchRenderedOutput(<span hidden={true} prop="Child" />);
223
224 // Unhide the tree. The layout effect is mounted.
225 await act(() => {
226 root.render(
227 <Activity mode="visible">
228 <Child />
229 </Activity>,
230 );
231 });
232 assertLog(['Child', 'Mount layout']);
233 expect(root).toMatchRenderedOutput(<span prop="Child" />);
234 });
235
236 it('mounts/unmounts layout effects when visibility changes (starting visible)', async () => {
237 function Child({text}) {
238 useLayoutEffect(() => {
239 Scheduler.log('Mount layout');
240 return () => {
241 Scheduler.log('Unmount layout');
242 };
243 }, []);
244 return <Text text="Child" />;
245 }
246
247 const root = ReactNoop.createRoot();
248 await act(() => {
249 root.render(
250 <Activity mode="visible">
251 <Child />
252 </Activity>,
253 );
254 });
255 assertLog(['Child', 'Mount layout']);
256 expect(root).toMatchRenderedOutput(<span prop="Child" />);
257
258 // Hide the tree. The layout effect is unmounted.
259 await act(() => {
260 root.render(
261 <Activity mode="hidden">
262 <Child />
263 </Activity>,
264 );
265 });
266 assertLog(['Unmount layout', 'Child']);
267 expect(root).toMatchRenderedOutput(<span hidden={true} prop="Child" />);
268
269 // Unhide the tree. The layout effect is re-mounted.
270 await act(() => {
271 root.render(
272 <Activity mode="visible">
273 <Child />
274 </Activity>,
275 );
276 });
277 assertLog(['Child', 'Mount layout']);
278 expect(root).toMatchRenderedOutput(<span prop="Child" />);
279 });
280
281 it('nested offscreen does not call componentWillUnmount when hidden', async () => {
282 // This is a bug that appeared during production test of <Activity />.
283 // It is a very specific scenario with nested Offscreens. The inner offscreen
284 // goes from visible to hidden in synchronous update.
285 class ClassComponent extends React.Component {
286 render() {
287 return <Text text="child" />;
288 }
289
290 componentWillUnmount() {
291 Scheduler.log('componentWillUnmount');
292 }
293
294 componentDidMount() {
295 Scheduler.log('componentDidMount');
296 }
297 }
298
299 const root = ReactNoop.createRoot();
300 await act(() => {
301 // Outer and inner offscreen are hidden.
302 root.render(
303 <Activity mode={'hidden'}>
304 <Activity mode={'hidden'}>
305 <ClassComponent />
306 </Activity>
307 </Activity>,
308 );
309 });
310
311 assertLog(['child']);
312 expect(root).toMatchRenderedOutput(<span hidden={true} prop="child" />);
313
314 await act(() => {
315 // Inner offscreen is visible.
316 root.render(
317 <Activity mode={'hidden'}>
318 <Activity mode={'visible'}>
319 <ClassComponent />
320 </Activity>
321 </Activity>,
322 );
323 });
324
325 assertLog(['child']);
326 expect(root).toMatchRenderedOutput(<span hidden={true} prop="child" />);
327
328 await act(() => {
329 // Inner offscreen is hidden.
330 root.render(
331 <Activity mode={'hidden'}>
332 <Activity mode={'hidden'}>
333 <ClassComponent />
334 </Activity>
335 </Activity>,
336 );
337 });
338
339 assertLog(['child']);
340 expect(root).toMatchRenderedOutput(<span hidden={true} prop="child" />);
341
342 await act(() => {
343 // Inner offscreen is visible.
344 root.render(
345 <Activity mode={'hidden'}>
346 <Activity mode={'visible'}>
347 <ClassComponent />
348 </Activity>
349 </Activity>,
350 );
351 });
352
353 Scheduler.unstable_clearLog();
354
355 await act(() => {
356 // Outer offscreen is visible.
357 // Inner offscreen is hidden.
358 root.render(
359 <Activity mode={'visible'}>
360 <Activity mode={'hidden'}>
361 <ClassComponent />
362 </Activity>
363 </Activity>,
364 );
365 });
366
367 assertLog(['child']);
368
369 await act(() => {
370 // Outer offscreen is hidden.
371 // Inner offscreen is visible.
372 root.render(
373 <Activity mode={'hidden'}>
374 <Activity mode={'visible'}>
375 <ClassComponent />
376 </Activity>
377 </Activity>,
378 );
379 });
380
381 assertLog(['child']);
382 });
383
384 it('mounts/unmounts layout effects when visibility changes (starting hidden)', async () => {
385 function Child({text}) {
386 useLayoutEffect(() => {
387 Scheduler.log('Mount layout');
388 return () => {
389 Scheduler.log('Unmount layout');
390 };
391 }, []);
392 return <Text text="Child" />;
393 }
394
395 const root = ReactNoop.createRoot();
396 await act(() => {
397 // Start the tree hidden. The layout effect is not mounted.
398 root.render(
399 <Activity mode="hidden">
400 <Child />
401 </Activity>,
402 );
403 });
404 assertLog(['Child']);
405 expect(root).toMatchRenderedOutput(<span hidden={true} prop="Child" />);
406
407 // Show the tree. The layout effect is mounted.
408 await act(() => {
409 root.render(
410 <Activity mode="visible">
411 <Child />
412 </Activity>,
413 );
414 });
415 assertLog(['Child', 'Mount layout']);
416 expect(root).toMatchRenderedOutput(<span prop="Child" />);
417
418 // Hide the tree again. The layout effect is un-mounted.
419 await act(() => {
420 root.render(
421 <Activity mode="hidden">
422 <Child />
423 </Activity>,
424 );
425 });
426 assertLog(['Unmount layout', 'Child']);
427 expect(root).toMatchRenderedOutput(<span hidden={true} prop="Child" />);
428 });
429
430 it('hides children of offscreen after layout effects are destroyed', async () => {
431 const root = ReactNoop.createRoot();
432 function Child({text}) {
433 useLayoutEffect(() => {
434 Scheduler.log('Mount layout');
435 return () => {
436 // The child should not be hidden yet.
437 expect(root).toMatchRenderedOutput(<span prop="Child" />);
438 Scheduler.log('Unmount layout');
439 };
440 }, []);
441 return <Text text="Child" />;
442 }
443
444 await act(() => {
445 root.render(
446 <Activity mode="visible">
447 <Child />
448 </Activity>,
449 );
450 });
451 assertLog(['Child', 'Mount layout']);
452 expect(root).toMatchRenderedOutput(<span prop="Child" />);
453
454 // Hide the tree. The layout effect is unmounted.
455 await act(() => {
456 root.render(
457 <Activity mode="hidden">
458 <Child />
459 </Activity>,
460 );
461 });
462 assertLog(['Unmount layout', 'Child']);
463
464 // After the layout effect is unmounted, the child is hidden.
465 expect(root).toMatchRenderedOutput(<span hidden={true} prop="Child" />);
466 });
467
468 // @gate enableLegacyHidden
469 it('does not toggle effects for LegacyHidden component', async () => {
470 // LegacyHidden is meant to be the same as offscreen except it doesn't
471 // do anything to effects. Only used by www, as a temporary migration step.
472 function Child({text}) {
473 useLayoutEffect(() => {
474 Scheduler.log('Mount layout');
475 return () => {
476 Scheduler.log('Unmount layout');
477 };
478 }, []);
479 return <Text text="Child" />;
480 }
481
482 const root = ReactNoop.createRoot();
483 await act(() => {
484 root.render(
485 <LegacyHidden mode="visible">
486 <Child />
487 </LegacyHidden>,
488 );
489 });
490 assertLog(['Child', 'Mount layout']);
491
492 await act(() => {
493 root.render(
494 <LegacyHidden mode="hidden">
495 <Child />
496 </LegacyHidden>,
497 );
498 });
499 assertLog(['Child']);
500
501 await act(() => {
502 root.render(
503 <LegacyHidden mode="visible">
504 <Child />
505 </LegacyHidden>,
506 );
507 });
508 assertLog(['Child']);
509
510 await act(() => {
511 root.render(null);
512 });
513 assertLog(['Unmount layout']);
514 });
515
516 it('hides new insertions into an already hidden tree', async () => {
517 const root = ReactNoop.createRoot();
518 await act(() => {
519 root.render(
520 <Activity mode="hidden">
521 <span>Hi</span>
522 </Activity>,
523 );
524 });
525 expect(root).toMatchRenderedOutput(<span hidden={true}>Hi</span>);
526
527 // Insert a new node into the hidden tree
528 await act(() => {
529 root.render(
530 <Activity mode="hidden">
531 <span>Hi</span>
532 <span>Something new</span>
533 </Activity>,
534 );
535 });
536 expect(root).toMatchRenderedOutput(
537 <>
538 <span hidden={true}>Hi</span>
539 {/* This new node should also be hidden */}
540 <span hidden={true}>Something new</span>
541 </>,
542 );
543 });
544
545 it('hides updated nodes inside an already hidden tree', async () => {
546 const root = ReactNoop.createRoot();
547 await act(() => {
548 root.render(
549 <Activity mode="hidden">
550 <span>Hi</span>
551 </Activity>,
552 );
553 });
554 expect(root).toMatchRenderedOutput(<span hidden={true}>Hi</span>);
555
556 // Set the `hidden` prop to on an already hidden node
557 await act(() => {
558 root.render(
559 <Activity mode="hidden">
560 <span hidden={false}>Hi</span>
561 </Activity>,
562 );
563 });
564 // It should still be hidden, because the Activity container overrides it
565 expect(root).toMatchRenderedOutput(<span hidden={true}>Hi</span>);
566
567 // Unhide the boundary
568 await act(() => {
569 root.render(
570 <Activity mode="visible">
571 <span hidden={true}>Hi</span>
572 </Activity>,
573 );
574 });
575 // It should still be hidden, because of the prop
576 expect(root).toMatchRenderedOutput(<span hidden={true}>Hi</span>);
577
578 // Remove the `hidden` prop
579 await act(() => {
580 root.render(
581 <Activity mode="visible">
582 <span>Hi</span>
583 </Activity>,
584 );
585 });
586 // Now it's visible
587 expect(root).toMatchRenderedOutput(<span>Hi</span>);
588 });
589
590 it('revealing a hidden tree at high priority does not cause tearing', async () => {
591 // When revealing an offscreen tree, we need to include updates that were
592 // previously deferred because the tree was hidden, even if they are lower
593 // priority than the current render. However, we should *not* include low
594 // priority updates that are entangled with updates outside of the hidden
595 // tree, because that can cause tearing.
596 //
597 // This test covers a scenario where an update multiple updates inside a
598 // hidden tree share the same lane, but are processed at different times
599 // because of the timing of when they were scheduled.
600
601 // This functions checks whether the "outer" and "inner" states are
602 // consistent in the rendered output.
603 let currentOuter = null;
604 let currentInner = null;
605 function areOuterAndInnerConsistent() {
606 return (
607 currentOuter === null ||
608 currentInner === null ||
609 currentOuter === currentInner
610 );
611 }
612
613 let setInner;
614 function Child() {
615 const [inner, _setInner] = useState(0);
616 setInner = _setInner;
617
618 useEffect(() => {
619 currentInner = inner;
620 return () => {
621 currentInner = null;
622 };
623 }, [inner]);
624
625 return <Text text={'Inner: ' + inner} />;
626 }
627
628 let setOuter;
629 function App({show}) {
630 const [outer, _setOuter] = useState(0);
631 setOuter = _setOuter;
632
633 useEffect(() => {
634 currentOuter = outer;
635 return () => {
636 currentOuter = null;
637 };
638 }, [outer]);
639
640 return (
641 <>
642 <Text text={'Outer: ' + outer} />
643 <Activity mode={show ? 'visible' : 'hidden'}>
644 <Child />
645 </Activity>
646 </>
647 );
648 }
649
650 // Render a hidden tree
651 const root = ReactNoop.createRoot();
652 await act(() => {
653 root.render(<App show={false} />);
654 });
655 assertLog(['Outer: 0', 'Inner: 0']);
656 expect(root).toMatchRenderedOutput(
657 <>
658 <span prop="Outer: 0" />
659 <span hidden={true} prop="Inner: 0" />
660 </>,
661 );
662 expect(areOuterAndInnerConsistent()).toBe(true);
663
664 await act(async () => {
665 // Update a value both inside and outside the hidden tree. These values
666 // must always be consistent.
667 setOuter(1);
668 setInner(1);
669 // Only the outer updates finishes because the inner update is inside a
670 // hidden tree. The outer update is deferred to a later render.
671 await waitForPaint(['Outer: 1']);
672 expect(root).toMatchRenderedOutput(
673 <>
674 <span prop="Outer: 1" />
675 <span hidden={true} prop="Inner: 0" />
676 </>,
677 );
678
679 // Before the inner update can finish, we receive another pair of updates.
680 React.startTransition(() => {
681 setOuter(2);
682 setInner(2);
683 });
684
685 // Also, before either of these new updates are processed, the hidden
686 // tree is revealed at high priority.
687 ReactNoop.flushSync(() => {
688 root.render(<App show={true} />);
689 });
690
691 assertLog([
692 'Outer: 1',
693
694 // There are two pending updates on Inner, but only the first one
695 // is processed, even though they share the same lane. If the second
696 // update were erroneously processed, then Inner would be inconsistent
697 // with Outer.
698 'Inner: 1',
699 ]);
700 expect(root).toMatchRenderedOutput(
701 <>
702 <span prop="Outer: 1" />
703 <span prop="Inner: 1" />
704 </>,
705 );
706 expect(areOuterAndInnerConsistent()).toBe(true);
707 });
708 assertLog(['Outer: 2', 'Inner: 2']);
709 expect(root).toMatchRenderedOutput(
710 <>
711 <span prop="Outer: 2" />
712 <span prop="Inner: 2" />
713 </>,
714 );
715 expect(areOuterAndInnerConsistent()).toBe(true);
716 });
717
718 it('regression: Activity instance is sometimes null during setState', async () => {
719 let setState;
720 function Child() {
721 const [state, _setState] = useState('Initial');
722 setState = _setState;
723 return <Text text={state} />;
724 }
725
726 const root = ReactNoop.createRoot();
727 await act(() => {
728 root.render(<Activity />);
729 });
730 assertLog([]);
731 expect(root).toMatchRenderedOutput(null);
732
733 await act(async () => {
734 // Partially render a component
735 startTransition(() => {
736 root.render(
737 <Activity>
738 <Child />
739 <Text text="Sibling" />
740 </Activity>,
741 );
742 });
743 await waitFor(['Initial']);
744
745 // Before it finishes rendering, the whole tree gets deleted
746 ReactNoop.flushSync(() => {
747 root.render(null);
748 });
749
750 // Something attempts to update the never-mounted component. When this
751 // regression test was written, we would walk up the component's return
752 // path and reach an unmounted Activity component fiber. Its `stateNode`
753 // would be null because it was nulled out when it was deleted, but there
754 // was no null check before we accessed it. A weird edge case but we must
755 // account for it.
756 setState('Updated');
757 assertConsoleErrorDev([
758 "Can't perform a React state update on a component that hasn't mounted yet. " +
759 'This indicates that you have a side-effect in your render function that ' +
760 'asynchronously tries to update the component. ' +
761 'Move this work to useEffect instead.\n' +
762 ' in Child (at **)',
763 ]);
764 });
765 expect(root).toMatchRenderedOutput(null);
766 });
767
768 it('class component setState callbacks do not fire until tree is visible', async () => {
769 const root = ReactNoop.createRoot();
770
771 let child;
772 class Child extends React.Component {
773 state = {text: 'A'};
774 render() {
775 child = this;
776 return <Text text={this.state.text} />;
777 }
778 }
779
780 // Initial render
781 await act(() => {
782 root.render(
783 <Activity mode="hidden">
784 <Child />
785 </Activity>,
786 );
787 });
788 assertLog(['A']);
789 expect(root).toMatchRenderedOutput(<span hidden={true} prop="A" />);
790
791 // Schedule an update to a hidden class component. The update will finish
792 // rendering in the background, but the callback shouldn't fire yet, because
793 // the component isn't visible.
794 await act(() => {
795 child.setState({text: 'B'}, () => {
796 Scheduler.log('B update finished');
797 });
798 });
799 assertLog(['B']);
800 expect(root).toMatchRenderedOutput(<span hidden={true} prop="B" />);
801
802 // Now reveal the hidden component. Simultaneously, schedule another
803 // update with a callback to the same component. When the component is
804 // revealed, both the B callback and C callback should fire, in that order.
805 await act(() => {
806 root.render(
807 <Activity mode="visible">
808 <Child />
809 </Activity>,
810 );
811 child.setState({text: 'C'}, () => {
812 Scheduler.log('C update finished');
813 });
814 });
815 assertLog(['C', 'B update finished', 'C update finished']);
816 expect(root).toMatchRenderedOutput(<span prop="C" />);
817 });
818
819 it('does not call componentDidUpdate when reappearing a hidden class component', async () => {
820 class Child extends React.Component {
821 componentDidMount() {
822 Scheduler.log('componentDidMount');
823 }
824 componentDidUpdate() {
825 Scheduler.log('componentDidUpdate');
826 }
827 componentWillUnmount() {
828 Scheduler.log('componentWillUnmount');
829 }
830 render() {
831 return 'Child';
832 }
833 }
834
835 // Initial mount
836 const root = ReactNoop.createRoot();
837 await act(() => {
838 root.render(
839 <Activity mode="visible">
840 <Child />
841 </Activity>,
842 );
843 });
844 assertLog(['componentDidMount']);
845
846 // Hide the class component
847 await act(() => {
848 root.render(
849 <Activity mode="hidden">
850 <Child />
851 </Activity>,
852 );
853 });
854 assertLog(['componentWillUnmount']);
855
856 // Reappear the class component. componentDidMount should fire, not
857 // componentDidUpdate.
858 await act(() => {
859 root.render(
860 <Activity mode="visible">
861 <Child />
862 </Activity>,
863 );
864 });
865 assertLog(['componentDidMount']);
866 });
867
868 it(
869 'when reusing old components (hidden -> visible), layout effects fire ' +
870 'with same timing as if it were brand new',
871 async () => {
872 function Child({label}) {
873 useLayoutEffect(() => {
874 Scheduler.log('Mount ' + label);
875 return () => {
876 Scheduler.log('Unmount ' + label);
877 };
878 }, [label]);
879 return label;
880 }
881
882 // Initial mount
883 const root = ReactNoop.createRoot();
884 await act(() => {
885 root.render(
886 <Activity mode="visible">
887 <Child key="B" label="B" />
888 </Activity>,
889 );
890 });
891 assertLog(['Mount B']);
892
893 // Hide the component
894 await act(() => {
895 root.render(
896 <Activity mode="hidden">
897 <Child key="B" label="B" />
898 </Activity>,
899 );
900 });
901 assertLog(['Unmount B']);
902
903 // Reappear the component and also add some new siblings.
904 await act(() => {
905 root.render(
906 <Activity mode="visible">
907 <Child key="A" label="A" />
908 <Child key="B" label="B" />
909 <Child key="C" label="C" />
910 </Activity>,
911 );
912 });
913 // B's effect should fire in between A and C even though it's been reused
914 // from a previous render. In other words, it's the same order as if all
915 // three siblings were brand new.
916 assertLog(['Mount A', 'Mount B', 'Mount C']);
917 },
918 );
919
920 it(
921 'when reusing old components (hidden -> visible), layout effects fire ' +
922 'with same timing as if it were brand new (includes setState callback)',
923 async () => {
924 class Child extends React.Component {
925 componentDidMount() {
926 Scheduler.log('Mount ' + this.props.label);
927 }
928 componentWillUnmount() {
929 Scheduler.log('Unmount ' + this.props.label);
930 }
931 render() {
932 return this.props.label;
933 }
934 }
935
936 // Initial mount
937 const bRef = React.createRef();
938 const root = ReactNoop.createRoot();
939 await act(() => {
940 root.render(
941 <Activity mode="visible">
942 <Child key="B" ref={bRef} label="B" />
943 </Activity>,
944 );
945 });
946 assertLog(['Mount B']);
947
948 // We're going to schedule an update on a hidden component, so stash a
949 // reference to its setState before the ref gets detached
950 const setStateB = bRef.current.setState.bind(bRef.current);
951
952 // Hide the component
953 await act(() => {
954 root.render(
955 <Activity mode="hidden">
956 <Child key="B" ref={bRef} label="B" />
957 </Activity>,
958 );
959 });
960 assertLog(['Unmount B']);
961
962 // Reappear the component and also add some new siblings.
963 await act(() => {
964 setStateB(null, () => {
965 Scheduler.log('setState callback B');
966 });
967 root.render(
968 <Activity mode="visible">
969 <Child key="A" label="A" />
970 <Child key="B" ref={bRef} label="B" />
971 <Child key="C" label="C" />
972 </Activity>,
973 );
974 });
975 // B's effect should fire in between A and C even though it's been reused
976 // from a previous render. In other words, it's the same order as if all
977 // three siblings were brand new.
978 assertLog(['Mount A', 'Mount B', 'setState callback B', 'Mount C']);
979 },
980 );
981
982 it('defer passive effects when prerendering a new Activity tree', async () => {
983 function Child({label}) {
984 useEffect(() => {
985 Scheduler.log('Mount ' + label);
986 return () => {
987 Scheduler.log('Unmount ' + label);
988 };
989 }, [label]);
990 return <Text text={label} />;
991 }
992
993 function App({showMore}) {
994 return (
995 <>
996 <Child label="Shell" />
997 <Activity mode={showMore ? 'visible' : 'hidden'}>
998 <Child label="More" />
999 </Activity>
1000 </>
1001 );
1002 }
1003
1004 const root = ReactNoop.createRoot();
1005
1006 // Mount the app without showing the extra content
1007 await act(() => {
1008 root.render(<App showMore={false} />);
1009 });
1010 assertLog([
1011 // First mount the outer visible shell
1012 'Shell',
1013 'Mount Shell',
1014
1015 // Then prerender the hidden extra context. The passive effects in the
1016 // hidden tree should not fire
1017 'More',
1018 // Does not fire
1019 // 'Mount More',
1020 ]);
1021 // The hidden content has been prerendered
1022 expect(root).toMatchRenderedOutput(
1023 <>
1024 <span prop="Shell" />
1025 <span hidden={true} prop="More" />
1026 </>,
1027 );
1028
1029 // Reveal the prerendered tree
1030 await act(() => {
1031 root.render(<App showMore={true} />);
1032 });
1033 assertLog([
1034 'Shell',
1035 'More',
1036
1037 // Mount the passive effects in the newly revealed tree, the ones that
1038 // were skipped during pre-rendering.
1039 'Mount More',
1040 ]);
1041 });
1042
1043 // @gate enableLegacyHidden
1044 it('do not defer passive effects when prerendering a new LegacyHidden tree', async () => {
1045 function Child({label}) {
1046 useEffect(() => {
1047 Scheduler.log('Mount ' + label);
1048 return () => {
1049 Scheduler.log('Unmount ' + label);
1050 };
1051 }, [label]);
1052 return <Text text={label} />;
1053 }
1054
1055 function App({showMore}) {
1056 return (
1057 <>
1058 <Child label="Shell" />
1059 <LegacyHidden
1060 mode={showMore ? 'visible' : 'unstable-defer-without-hiding'}>
1061 <Child label="More" />
1062 </LegacyHidden>
1063 </>
1064 );
1065 }
1066
1067 const root = ReactNoop.createRoot();
1068
1069 // Mount the app without showing the extra content
1070 await act(() => {
1071 root.render(<App showMore={false} />);
1072 });
1073 assertLog([
1074 // First mount the outer visible shell
1075 'Shell',
1076 'Mount Shell',
1077
1078 // Then prerender the hidden extra context. Unlike Activity, the passive
1079 // effects in the hidden tree *should* fire
1080 'More',
1081 'Mount More',
1082 ]);
1083
1084 // The hidden content has been prerendered
1085 expect(root).toMatchRenderedOutput(
1086 <>
1087 <span prop="Shell" />
1088 <span prop="More" />
1089 </>,
1090 );
1091
1092 // Reveal the prerendered tree
1093 await act(() => {
1094 root.render(<App showMore={true} />);
1095 });
1096 assertLog(['Shell', 'More']);
1097 });
1098
1099 it('passive effects are connected and disconnected when the visibility changes', async () => {
1100 function Child({step}) {
1101 useEffect(() => {
1102 Scheduler.log(`Commit mount [${step}]`);
1103 return () => {
1104 Scheduler.log(`Commit unmount [${step}]`);
1105 };
1106 }, [step]);
1107 return <Text text={step} />;
1108 }
1109
1110 function App({show, step}) {
1111 return (
1112 <Activity mode={show ? 'visible' : 'hidden'}>
1113 {useMemo(
1114 () => (
1115 <Child step={step} />
1116 ),
1117 [step],
1118 )}
1119 </Activity>
1120 );
1121 }
1122
1123 const root = ReactNoop.createRoot();
1124 await act(() => {
1125 root.render(<App show={true} step={1} />);
1126 });
1127 assertLog([1, 'Commit mount [1]']);
1128 expect(root).toMatchRenderedOutput(<span prop={1} />);
1129
1130 // Hide the tree. This will unmount the effect.
1131 await act(() => {
1132 root.render(<App show={false} step={1} />);
1133 });
1134 assertLog(['Commit unmount [1]']);
1135 expect(root).toMatchRenderedOutput(<span hidden={true} prop={1} />);
1136
1137 // Update.
1138 await act(() => {
1139 root.render(<App show={false} step={2} />);
1140 });
1141 // The update is prerendered but no effects are fired
1142 assertLog([2]);
1143 expect(root).toMatchRenderedOutput(<span hidden={true} prop={2} />);
1144
1145 // Reveal the tree.
1146 await act(() => {
1147 root.render(<App show={true} step={2} />);
1148 });
1149 // The update doesn't render because it was already prerendered, but we do
1150 // fire the effect.
1151 assertLog(['Commit mount [2]']);
1152 expect(root).toMatchRenderedOutput(<span prop={2} />);
1153 });
1154
1155 it('passive effects are unmounted on hide in the same order as during a deletion: parent before child', async () => {
1156 function Child({label}) {
1157 useEffect(() => {
1158 Scheduler.log('Mount Child');
1159 return () => {
1160 Scheduler.log('Unmount Child');
1161 };
1162 }, []);
1163 return <div>Hi</div>;
1164 }
1165 function Parent() {
1166 useEffect(() => {
1167 Scheduler.log('Mount Parent');
1168 return () => {
1169 Scheduler.log('Unmount Parent');
1170 };
1171 }, []);
1172 return <Child />;
1173 }
1174
1175 function App({show}) {
1176 return (
1177 <Activity mode={show ? 'visible' : 'hidden'}>
1178 <Parent />
1179 </Activity>
1180 );
1181 }
1182
1183 const root = ReactNoop.createRoot();
1184 await act(() => {
1185 root.render(<App show={true} />);
1186 });
1187 assertLog(['Mount Child', 'Mount Parent']);
1188
1189 // First demonstrate what happens during a normal deletion
1190 await act(() => {
1191 root.render(null);
1192 });
1193 assertLog(['Unmount Parent', 'Unmount Child']);
1194
1195 // Now redo the same thing but hide instead of deleting
1196 await act(() => {
1197 root.render(<App show={true} />);
1198 });
1199 assertLog(['Mount Child', 'Mount Parent']);
1200 await act(() => {
1201 root.render(<App show={false} />);
1202 });
1203 // The order is the same as during a deletion: parent before child
1204 assertLog(['Unmount Parent', 'Unmount Child']);
1205 });
1206
1207 // TODO: As of now, there's no way to hide a tree without also unmounting its
1208 // effects. (Except for Suspense, which has its own tests associated with it.)
1209 // Re-enable this test once we add this ability. For example, we'll likely add
1210 // either an option or a heuristic to mount passive effects inside a hidden
1211 // tree after a delay.
1212 // eslint-disable-next-line jest/no-disabled-tests
1213 it.skip("don't defer passive effects when prerendering in a tree whose effects are already connected", async () => {
1214 function Child({label}) {
1215 useEffect(() => {
1216 Scheduler.log('Mount ' + label);
1217 return () => {
1218 Scheduler.log('Unmount ' + label);
1219 };
1220 }, [label]);
1221 return <Text text={label} />;
1222 }
1223
1224 function App({showMore, step}) {
1225 return (
1226 <>
1227 <Child label={'Shell ' + step} />
1228 <Activity mode={showMore ? 'visible' : 'hidden'}>
1229 <Child label={'More ' + step} />
1230 </Activity>
1231 </>
1232 );
1233 }
1234
1235 const root = ReactNoop.createRoot();
1236
1237 // Mount the app, including the extra content
1238 await act(() => {
1239 root.render(<App showMore={true} step={1} />);
1240 });
1241 assertLog(['Shell 1', 'More 1', 'Mount Shell 1', 'Mount More 1']);
1242 expect(root).toMatchRenderedOutput(
1243 <>
1244 <span prop="Shell 1" />
1245 <span prop="More 1" />
1246 </>,
1247 );
1248
1249 // Hide the extra content. while also updating one of its props
1250 await act(() => {
1251 root.render(<App showMore={false} step={2} />);
1252 });
1253 assertLog([
1254 // First update the outer visible shell
1255 'Shell 2',
1256 'Unmount Shell 1',
1257 'Mount Shell 2',
1258
1259 // Then prerender the update to the hidden content. Since the effects
1260 // are already connected inside the hidden tree, we don't defer updates
1261 // to them.
1262 'More 2',
1263 'Unmount More 1',
1264 'Mount More 2',
1265 ]);
1266 });
1267
1268 it('does not mount effects when prerendering a nested Activity boundary', async () => {
1269 function Child({label}) {
1270 useEffect(() => {
1271 Scheduler.log('Mount ' + label);
1272 return () => {
1273 Scheduler.log('Unmount ' + label);
1274 };
1275 }, [label]);
1276 return <Text text={label} />;
1277 }
1278
1279 function App({showOuter, showInner}) {
1280 return (
1281 <Activity mode={showOuter ? 'visible' : 'hidden'}>
1282 {useMemo(
1283 () => (
1284 <div>
1285 <Child label="Outer" />
1286 {showInner ? (
1287 <Activity mode="visible">
1288 <div>
1289 <Child label="Inner" />
1290 </div>
1291 </Activity>
1292 ) : null}
1293 </div>
1294 ),
1295 [showInner],
1296 )}
1297 </Activity>
1298 );
1299 }
1300
1301 const root = ReactNoop.createRoot();
1302
1303 // Prerender the outer contents. No effects should mount.
1304 await act(() => {
1305 root.render(<App showOuter={false} showInner={false} />);
1306 });
1307 assertLog(['Outer']);
1308 expect(root).toMatchRenderedOutput(
1309 <div hidden={true}>
1310 <span prop="Outer" />
1311 </div>,
1312 );
1313
1314 // Prerender the inner contents. No effects should mount.
1315 await act(() => {
1316 root.render(<App showOuter={false} showInner={true} />);
1317 });
1318 assertLog(['Outer', 'Inner']);
1319 expect(root).toMatchRenderedOutput(
1320 <div hidden={true}>
1321 <span prop="Outer" />
1322 <div>
1323 <span prop="Inner" />
1324 </div>
1325 </div>,
1326 );
1327
1328 // Reveal the prerendered tree
1329 await act(() => {
1330 root.render(<App showOuter={true} showInner={true} />);
1331 });
1332 // The effects fire, but the tree is not re-rendered because it already
1333 // prerendered.
1334 assertLog(['Mount Outer', 'Mount Inner']);
1335 expect(root).toMatchRenderedOutput(
1336 <div>
1337 <span prop="Outer" />
1338 <div>
1339 <span prop="Inner" />
1340 </div>
1341 </div>,
1342 );
1343 });
1344
1345 it('reveal an outer Activity boundary without revealing an inner one', async () => {
1346 function Child({label}) {
1347 useEffect(() => {
1348 Scheduler.log('Mount ' + label);
1349 return () => {
1350 Scheduler.log('Unmount ' + label);
1351 };
1352 }, [label]);
1353 return <Text text={label} />;
1354 }
1355
1356 function App({showOuter, showInner}) {
1357 return (
1358 <Activity mode={showOuter ? 'visible' : 'hidden'}>
1359 {useMemo(
1360 () => (
1361 <div>
1362 <Child label="Outer" />
1363 <Activity mode={showInner ? 'visible' : 'hidden'}>
1364 <div>
1365 <Child label="Inner" />
1366 </div>
1367 </Activity>
1368 </div>
1369 ),
1370 [showInner],
1371 )}
1372 </Activity>
1373 );
1374 }
1375
1376 const root = ReactNoop.createRoot();
1377
1378 // Prerender the whole tree.
1379 await act(() => {
1380 root.render(<App showOuter={false} showInner={false} />);
1381 });
1382 assertLog(['Outer', 'Inner']);
1383 // Both the inner and the outer tree should be hidden. Hiding the inner tree
1384 // is arguably redundant, but the advantage of hiding both is that later you
1385 // can reveal the outer tree without having to examine the inner one.
1386 expect(root).toMatchRenderedOutput(
1387 <div hidden={true}>
1388 <span prop="Outer" />
1389 <div hidden={true}>
1390 <span prop="Inner" />
1391 </div>
1392 </div>,
1393 );
1394
1395 // Reveal the outer contents. The inner tree remains hidden.
1396 await act(() => {
1397 root.render(<App showOuter={true} showInner={false} />);
1398 });
1399 assertLog(['Mount Outer']);
1400 expect(root).toMatchRenderedOutput(
1401 <div>
1402 <span prop="Outer" />
1403 <div hidden={true}>
1404 <span prop="Inner" />
1405 </div>
1406 </div>,
1407 );
1408 });
1409
1410 it('reveal an inner Activity boundary without revealing an outer one on the same host child', async () => {
1411 // This ensures that no update is scheduled, which would cover up the bug if the parent
1412 // then re-hides the child on the way up.
1413 const memoizedElement = <div />;
1414 function App({showOuter, showInner}) {
1415 return (
1416 <Activity mode={showOuter ? 'visible' : 'hidden'} name="Outer">
1417 <Activity mode={showInner ? 'visible' : 'hidden'} name="Inner">
1418 {memoizedElement}
1419 </Activity>
1420 </Activity>
1421 );
1422 }
1423
1424 const root = ReactNoop.createRoot();
1425
1426 // Prerender the whole tree.
1427 await act(() => {
1428 root.render(<App showOuter={false} showInner={false} />);
1429 });
1430 expect(root).toMatchRenderedOutput(<div hidden={true} />);
1431
1432 await act(() => {
1433 root.render(<App showOuter={false} showInner={true} />);
1434 });
1435 expect(root).toMatchRenderedOutput(<div hidden={true} />);
1436 });
1437
1438 it('reveal an inner Suspense boundary without revealing an outer Activity on the same host child', async () => {
1439 // This ensures that no update is scheduled, which would cover up the bug if the parent
1440 // then re-hides the child on the way up.
1441 const memoizedElement = <div />;
1442 const promise = new Promise(() => {});
1443 function App({showOuter, showInner}) {
1444 return (
1445 <Activity mode={showOuter ? 'visible' : 'hidden'} name="Outer">
1446 <Suspense name="Inner">
1447 {memoizedElement}
1448 {showInner ? null : promise}
1449 </Suspense>
1450 </Activity>
1451 );
1452 }
1453
1454 const root = ReactNoop.createRoot();
1455
1456 // Prerender the whole tree.
1457 await act(() => {
1458 root.render(<App showOuter={false} showInner={true} />);
1459 });
1460 expect(root).toMatchRenderedOutput(<div hidden={true} />);
1461
1462 // Resuspend the inner.
1463 await act(() => {
1464 root.render(<App showOuter={false} showInner={false} />);
1465 });
1466 expect(root).toMatchRenderedOutput(<div hidden={true} />);
1467
1468 await act(() => {
1469 root.render(<App showOuter={false} showInner={true} />);
1470 });
1471 expect(root).toMatchRenderedOutput(<div hidden={true} />);
1472 });
1473
1474 it('insertion effects are not disconnected when the visibility changes', async () => {
1475 function Child({step}) {
1476 useInsertionEffect(() => {
1477 Scheduler.log(`Commit mount [${step}]`);
1478 return () => {
1479 Scheduler.log(`Commit unmount [${step}]`);
1480 };
1481 }, [step]);
1482 return <Text text={step} />;
1483 }
1484
1485 function App({show, step}) {
1486 return (
1487 <Activity mode={show ? 'visible' : 'hidden'}>
1488 {useMemo(
1489 () => (
1490 <Child step={step} />
1491 ),
1492 [step],
1493 )}
1494 </Activity>
1495 );
1496 }
1497
1498 const root = ReactNoop.createRoot();
1499 await act(() => {
1500 root.render(<App show={true} step={1} />);
1501 });
1502 assertLog([1, 'Commit mount [1]']);
1503 expect(root).toMatchRenderedOutput(<span prop={1} />);
1504
1505 // Hide the tree. This will not unmount insertion effects.
1506 await act(() => {
1507 root.render(<App show={false} step={1} />);
1508 });
1509 assertLog([]);
1510 expect(root).toMatchRenderedOutput(<span hidden={true} prop={1} />);
1511
1512 // Update.
1513 await act(() => {
1514 root.render(<App show={false} step={2} />);
1515 });
1516 // The update is pre-rendered so insertion effects are fired
1517 assertLog([2, 'Commit unmount [1]', 'Commit mount [2]']);
1518 expect(root).toMatchRenderedOutput(<span hidden={true} prop={2} />);
1519
1520 // Reveal the tree.
1521 await act(() => {
1522 root.render(<App show={true} step={2} />);
1523 });
1524 // The update doesn't render because it was already pre-rendered, and the
1525 // insertion effect already fired.
1526 assertLog([]);
1527 expect(root).toMatchRenderedOutput(<span prop={2} />);
1528 });
1529
1530 it('getSnapshotBeforeUpdate does not run in hidden trees', async () => {
1531 let setState;
1532
1533 class Child extends React.Component {
1534 getSnapshotBeforeUpdate(prevProps) {
1535 const snapshot = `snapshot-${prevProps.value}-to-${this.props.value}`;
1536 Scheduler.log(`getSnapshotBeforeUpdate: ${snapshot}`);
1537 return snapshot;
1538 }
1539 componentDidUpdate(prevProps, prevState, snapshot) {
1540 Scheduler.log(`componentDidUpdate: ${snapshot}`);
1541 }
1542 componentDidMount() {
1543 Scheduler.log('componentDidMount');
1544 }
1545 componentWillUnmount() {
1546 Scheduler.log('componentWillUnmount');
1547 }
1548 render() {
1549 Scheduler.log(`render: ${this.props.value}`);
1550 return <span prop={this.props.value} />;
1551 }
1552 }
1553
1554 function Wrapper({show}) {
1555 const [value, _setState] = useState(1);
1556 setState = _setState;
1557 return (
1558 <Activity mode={show ? 'visible' : 'hidden'}>
1559 <Child value={value} />
1560 </Activity>
1561 );
1562 }
1563
1564 const root = ReactNoop.createRoot();
1565
1566 // Initial render
1567 await act(() => {
1568 root.render(<Wrapper show={true} />);
1569 });
1570 assertLog(['render: 1', 'componentDidMount']);
1571
1572 // Hide the Activity
1573 await act(() => {
1574 root.render(<Wrapper show={false} />);
1575 });
1576 assertLog([
1577 'componentWillUnmount',
1578 'render: 1',
1579 // Bugfix: snapshots for hidden trees should not need to be read.
1580 ...(gate('enableViewTransition')
1581 ? []
1582 : ['getSnapshotBeforeUpdate: snapshot-1-to-1']),
1583 ]);
1584
1585 // Trigger an update while hidden by calling setState
1586 await act(() => {
1587 setState(2);
1588 });
1589 assertLog([
1590 'render: 2',
1591 ...(gate('enableViewTransition')
1592 ? []
1593 : ['getSnapshotBeforeUpdate: snapshot-1-to-2']),
1594 ]);
1595
1596 // This is treated as a new mount so the snapshot also shouldn't be read.
1597 await act(() => {
1598 root.render(<Wrapper show={true} />);
1599 });
1600 assertLog([
1601 'render: 2',
1602 ...(gate('enableViewTransition')
1603 ? []
1604 : ['getSnapshotBeforeUpdate: snapshot-2-to-2']),
1605 'componentDidMount',
1606 ]);
1607 });
1608
1609 it('warns if you pass a hidden prop', async () => {
1610 function App() {
1611 return (
1612 // eslint-disable-next-line react/jsx-boolean-value
1613 <Activity hidden>
1614 <div />
1615 </Activity>
1616 );
1617 }
1618
1619 const root = ReactNoop.createRoot();
1620 await act(() => {
1621 root.render(<App show={true} step={1} />);
1622 });
1623 assertConsoleErrorDev([
1624 '<Activity> doesn\'t accept a hidden prop. Use mode="hidden" instead.\n' +
1625 '- <Activity hidden>\n' +
1626 '+ <Activity mode="hidden">\n' +
1627 ' in Activity (at **)\n' +
1628 ' in App (at **)',
1629 ]);
1630 });
1631 });