main
js 1,352 lines 36 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 ReactDOMClient;
14 let Suspense;
15 let SuspenseList;
16 let ViewTransition;
17 let act;
18 let assertLog;
19 let Scheduler;
20 let textCache;
21 let startTransition;
22
23 describe('ReactDOMViewTransition', () => {
24 let container;
25
26 beforeEach(() => {
27 jest.resetModules();
28 React = require('react');
29 ReactDOMClient = require('react-dom/client');
30 Scheduler = require('scheduler');
31 act = require('internal-test-utils').act;
32 assertLog = require('internal-test-utils').assertLog;
33 Suspense = React.Suspense;
34 ViewTransition = React.ViewTransition;
35 startTransition = React.startTransition;
36 if (gate(flags => flags.enableSuspenseList)) {
37 SuspenseList = React.unstable_SuspenseList;
38 }
39 container = document.createElement('div');
40 document.body.appendChild(container);
41
42 textCache = new Map();
43 });
44
45 afterEach(() => {
46 document.body.removeChild(container);
47 });
48
49 function resolveText(text) {
50 const record = textCache.get(text);
51 if (record === undefined) {
52 const newRecord = {
53 status: 'resolved',
54 value: text,
55 };
56 textCache.set(text, newRecord);
57 } else if (record.status === 'pending') {
58 const thenable = record.value;
59 record.status = 'resolved';
60 record.value = text;
61 thenable.pings.forEach(t => t());
62 }
63 }
64
65 function readText(text) {
66 const record = textCache.get(text);
67 if (record !== undefined) {
68 switch (record.status) {
69 case 'pending':
70 Scheduler.log(`Suspend! [${text}]`);
71 throw record.value;
72 case 'rejected':
73 throw record.value;
74 case 'resolved':
75 return record.value;
76 }
77 } else {
78 Scheduler.log(`Suspend! [${text}]`);
79 const thenable = {
80 pings: [],
81 then(resolve) {
82 if (newRecord.status === 'pending') {
83 thenable.pings.push(resolve);
84 } else {
85 Promise.resolve().then(() => resolve(newRecord.value));
86 }
87 },
88 };
89
90 const newRecord = {
91 status: 'pending',
92 value: thenable,
93 };
94 textCache.set(text, newRecord);
95
96 throw thenable;
97 }
98 }
99
100 function Text({text}) {
101 Scheduler.log(text);
102 return text;
103 }
104
105 function AsyncText({text}) {
106 readText(text);
107 Scheduler.log(text);
108 return text;
109 }
110
111 // @gate enableSuspenseList
112 it('handles ViewTransition wrapping Suspense inside SuspenseList', async () => {
113 function Card({id}) {
114 return (
115 <div>
116 <AsyncText text={`Card ${id}`} />
117 </div>
118 );
119 }
120
121 function CardSkeleton({n}) {
122 return <Text text={`Skeleton ${n}`} />;
123 }
124
125 function App() {
126 return (
127 <div>
128 <SuspenseList revealOrder="together">
129 <ViewTransition>
130 <Suspense fallback={<CardSkeleton n={1} />}>
131 <Card id={1} />
132 </Suspense>
133 </ViewTransition>
134 <ViewTransition>
135 <Suspense fallback={<CardSkeleton n={2} />}>
136 <Card id={2} />
137 </Suspense>
138 </ViewTransition>
139 <ViewTransition>
140 <Suspense fallback={<CardSkeleton n={3} />}>
141 <Card id={3} />
142 </Suspense>
143 </ViewTransition>
144 </SuspenseList>
145 </div>
146 );
147 }
148
149 const root = ReactDOMClient.createRoot(container);
150
151 // Initial render - all cards should suspend
152 await act(() => {
153 root.render(<App />);
154 });
155
156 assertLog([
157 'Suspend! [Card 1]',
158 'Skeleton 1',
159 'Suspend! [Card 2]',
160 'Skeleton 2',
161 'Suspend! [Card 3]',
162 'Skeleton 3',
163 'Skeleton 1',
164 'Skeleton 2',
165 'Skeleton 3',
166 ]);
167
168 await act(() => {
169 resolveText('Card 1');
170 resolveText('Card 2');
171 resolveText('Card 3');
172 });
173
174 assertLog(['Card 1', 'Card 2', 'Card 3']);
175
176 // All cards should be visible
177 expect(container.textContent).toContain('Card 1');
178 expect(container.textContent).toContain('Card 2');
179 expect(container.textContent).toContain('Card 3');
180 });
181
182 describe('ViewTransition event callbacks', () => {
183 let originalGetBoundingClientRect;
184 let originalGetAnimations;
185 let originalAnimate;
186 let originalStartViewTransition;
187
188 beforeEach(() => {
189 // Save originals
190 originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
191 originalGetAnimations = Element.prototype.getAnimations;
192 originalAnimate = Element.prototype.animate;
193 originalStartViewTransition = document.startViewTransition;
194
195 // Mock CSS.escape if it doesn't exist
196 if (typeof CSS === 'undefined') {
197 global.CSS = {escape: str => str};
198 } else if (!CSS.escape) {
199 CSS.escape = str => str;
200 }
201
202 // Mock document.fonts
203 if (!document.fonts) {
204 Object.defineProperty(document, 'fonts', {
205 value: {status: 'loaded', ready: Promise.resolve()},
206 configurable: true,
207 });
208 }
209
210 // Mock getAnimations on Element.prototype (Web Animations API)
211 Element.prototype.getAnimations = function () {
212 return [];
213 };
214
215 // Mock animate on Element.prototype (Web Animations API)
216 Element.prototype.animate = function () {
217 return {cancel() {}, finished: Promise.resolve()};
218 };
219
220 // Mock getBoundingClientRect to return content-length-based sizes
221 // so that hasInstanceChanged can detect updates when text changes.
222 Element.prototype.getBoundingClientRect = function () {
223 const text = this.textContent || '';
224 const width = text.length * 10 + 10;
225 const height = 20;
226 return new DOMRect(0, 0, width, height);
227 };
228
229 // Mock document.startViewTransition
230 document.startViewTransition = function ({update}) {
231 update();
232 return {
233 ready: Promise.resolve(),
234 finished: Promise.resolve(),
235 skipTransition() {},
236 };
237 };
238 });
239
240 afterEach(() => {
241 Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
242 Element.prototype.getAnimations = originalGetAnimations;
243 Element.prototype.animate = originalAnimate;
244 if (originalStartViewTransition) {
245 document.startViewTransition = originalStartViewTransition;
246 } else {
247 delete document.startViewTransition;
248 }
249 });
250
251 // @gate enableViewTransition
252 it('fires onEnter when a ViewTransition mounts', async () => {
253 const onEnter = jest.fn();
254 const startViewTransitionSpy = jest.fn(document.startViewTransition);
255 document.startViewTransition = startViewTransitionSpy;
256
257 function App({show}) {
258 if (!show) {
259 return null;
260 }
261 return (
262 <ViewTransition onEnter={onEnter}>
263 <div>Hello</div>
264 </ViewTransition>
265 );
266 }
267
268 const root = ReactDOMClient.createRoot(container);
269
270 // Initial render without the ViewTransition
271 await act(() => {
272 root.render(<App show={false} />);
273 });
274 expect(onEnter).not.toHaveBeenCalled();
275 expect(startViewTransitionSpy).not.toHaveBeenCalled();
276
277 // Mount the ViewTransition inside startTransition
278 await act(() => {
279 startTransition(() => {
280 root.render(<App show={true} />);
281 });
282 });
283
284 expect(startViewTransitionSpy).toHaveBeenCalled();
285 expect(onEnter).toHaveBeenCalledTimes(1);
286 });
287
288 // @gate enableViewTransition
289 it('fires onExit when a ViewTransition unmounts', async () => {
290 const onExit = jest.fn();
291
292 function App({show}) {
293 if (!show) {
294 return null;
295 }
296 return (
297 <ViewTransition onExit={onExit}>
298 <div>Goodbye</div>
299 </ViewTransition>
300 );
301 }
302
303 const root = ReactDOMClient.createRoot(container);
304
305 // Initial render with the ViewTransition
306 await act(() => {
307 startTransition(() => {
308 root.render(<App show={true} />);
309 });
310 });
311 expect(onExit).not.toHaveBeenCalled();
312
313 // Unmount the ViewTransition inside startTransition
314 await act(() => {
315 startTransition(() => {
316 root.render(<App show={false} />);
317 });
318 });
319
320 expect(onExit).toHaveBeenCalledTimes(1);
321 });
322
323 // @gate enableViewTransition
324 it('fires onUpdate when content inside a ViewTransition changes', async () => {
325 const onUpdate = jest.fn();
326 const onEnter = jest.fn();
327
328 function App({text}) {
329 return (
330 <ViewTransition onUpdate={onUpdate} onEnter={onEnter}>
331 <div>{text}</div>
332 </ViewTransition>
333 );
334 }
335
336 const root = ReactDOMClient.createRoot(container);
337
338 // Initial render
339 await act(() => {
340 startTransition(() => {
341 root.render(<App text="Short" />);
342 });
343 });
344
345 onEnter.mockClear();
346 expect(onUpdate).not.toHaveBeenCalled();
347
348 // Update content inside startTransition (different text length
349 // produces different getBoundingClientRect values in our mock)
350 await act(() => {
351 startTransition(() => {
352 root.render(<App text="Much longer content here" />);
353 });
354 });
355
356 expect(onUpdate).toHaveBeenCalledTimes(1);
357 // onEnter should NOT fire on an update
358 expect(onEnter).not.toHaveBeenCalled();
359 });
360
361 // @gate enableViewTransition
362 it('fires onShare for paired named transitions instead of onEnter/onExit', async () => {
363 const onShareA = jest.fn();
364 const onExitA = jest.fn();
365 const onShareB = jest.fn();
366 const onEnterB = jest.fn();
367
368 function App({page}) {
369 if (page === 'a') {
370 return (
371 <ViewTransition
372 key="a"
373 name="hero"
374 onShare={onShareA}
375 onExit={onExitA}>
376 <div>Page A</div>
377 </ViewTransition>
378 );
379 }
380 return (
381 <ViewTransition
382 key="b"
383 name="hero"
384 onShare={onShareB}
385 onEnter={onEnterB}>
386 <div>Page B</div>
387 </ViewTransition>
388 );
389 }
390
391 const root = ReactDOMClient.createRoot(container);
392
393 // Render page A
394 await act(() => {
395 startTransition(() => {
396 root.render(<App page="a" />);
397 });
398 });
399
400 // Clear any enter callbacks from initial mount
401 onShareA.mockClear();
402 onExitA.mockClear();
403 onShareB.mockClear();
404 onEnterB.mockClear();
405
406 // Switch from page A to page B inside startTransition
407 await act(() => {
408 startTransition(() => {
409 root.render(<App page="b" />);
410 });
411 });
412
413 // onShare should fire on the exiting side (page A)
414 expect(onShareA).toHaveBeenCalledTimes(1);
415 // onExit should NOT fire when share takes precedence
416 expect(onExitA).not.toHaveBeenCalled();
417 // onEnter should NOT fire on the entering side when paired
418 expect(onEnterB).not.toHaveBeenCalled();
419 });
420
421 // @gate enableViewTransition
422 it('fires onEnter when Suspense content resolves', async () => {
423 const onEnter = jest.fn();
424
425 function App() {
426 return (
427 <ViewTransition onEnter={onEnter}>
428 <Suspense fallback={<div>Loading...</div>}>
429 <div>
430 <AsyncText text="Loaded" />
431 </div>
432 </Suspense>
433 </ViewTransition>
434 );
435 }
436
437 const root = ReactDOMClient.createRoot(container);
438
439 // Initial render - content suspends
440 await act(() => {
441 startTransition(() => {
442 root.render(<App />);
443 });
444 });
445
446 assertLog(['Suspend! [Loaded]', 'Suspend! [Loaded]']);
447 // onEnter fires for the fallback appearing
448 const enterCallsAfterFallback = onEnter.mock.calls.length;
449 onEnter.mockClear();
450
451 // Resolve the suspended content
452 await act(() => {
453 resolveText('Loaded');
454 });
455 assertLog(['Loaded']);
456
457 expect(container.textContent).toBe('Loaded');
458 // The reveal of the resolved content should trigger enter
459 // (or it may have triggered on the initial fallback mount)
460 expect(
461 onEnter.mock.calls.length + enterCallsAfterFallback,
462 ).toBeGreaterThanOrEqual(1);
463 });
464
465 // @gate enableViewTransition
466 it('does not fire onExit/onEnter on nested ViewTransition when the subtree is removed as one unit', async () => {
467 const onParentExit = jest.fn();
468 const onParentEnter = jest.fn();
469 const onNestedExit = jest.fn();
470 const onNestedEnter = jest.fn();
471
472 function App({show}) {
473 if (!show) {
474 return null;
475 }
476 return (
477 <ViewTransition
478 exit="page-exit"
479 enter="page-enter"
480 onExit={onParentExit}
481 onEnter={onParentEnter}>
482 <div>
483 <ViewTransition
484 exit="nested-exit"
485 enter="nested-enter"
486 onExit={onNestedExit}
487 onEnter={onNestedEnter}>
488 <div>Item</div>
489 </ViewTransition>
490 </div>
491 </ViewTransition>
492 );
493 }
494
495 const root = ReactDOMClient.createRoot(container);
496
497 await act(() => {
498 startTransition(() => {
499 root.render(<App show={false} />);
500 });
501 });
502
503 onParentEnter.mockClear();
504 onNestedEnter.mockClear();
505
506 await act(() => {
507 startTransition(() => {
508 root.render(<App show={true} />);
509 });
510 });
511
512 expect(onParentEnter).toHaveBeenCalledTimes(1);
513 expect(onNestedEnter).not.toHaveBeenCalled();
514
515 onParentExit.mockClear();
516 onNestedExit.mockClear();
517
518 await act(() => {
519 startTransition(() => {
520 root.render(<App show={false} />);
521 });
522 });
523
524 expect(onParentExit).toHaveBeenCalledTimes(1);
525 expect(onNestedExit).not.toHaveBeenCalled();
526 });
527
528 // @gate enableViewTransition && enableViewTransitionParentEnterExit
529 it('fires onParentExit when ancestor ViewTransition exits', async () => {
530 const onParentExit = jest.fn();
531 const onNestedExit = jest.fn();
532 const onParentExitNested = jest.fn();
533
534 function App({show}) {
535 if (!show) {
536 return null;
537 }
538 return (
539 <ViewTransition exit="page-exit" onExit={onParentExit}>
540 <div>
541 <ViewTransition
542 exit="nested-exit"
543 onExit={onNestedExit}
544 parentExit="nested-parent-exit"
545 onParentExit={onParentExitNested}>
546 <div>Item</div>
547 </ViewTransition>
548 </div>
549 </ViewTransition>
550 );
551 }
552
553 const root = ReactDOMClient.createRoot(container);
554
555 await act(() => {
556 startTransition(() => {
557 root.render(<App show={true} />);
558 });
559 });
560
561 onParentExit.mockClear();
562 onNestedExit.mockClear();
563 onParentExitNested.mockClear();
564
565 await act(() => {
566 startTransition(() => {
567 root.render(<App show={false} />);
568 });
569 });
570
571 expect(onParentExit).toHaveBeenCalledTimes(1);
572 expect(onNestedExit).not.toHaveBeenCalled();
573 expect(onParentExitNested).toHaveBeenCalledTimes(1);
574 });
575
576 // @gate enableViewTransition && enableViewTransitionParentEnterExit
577 it('fires onParentEnter when ancestor ViewTransition enters', async () => {
578 const onParentEnter = jest.fn();
579 const onNestedEnter = jest.fn();
580 const onParentEnterNested = jest.fn();
581
582 function App({show}) {
583 if (!show) {
584 return null;
585 }
586 return (
587 <ViewTransition enter="page-enter" onEnter={onParentEnter}>
588 <div>
589 <ViewTransition
590 enter="nested-enter"
591 onEnter={onNestedEnter}
592 parentEnter="nested-parent-enter"
593 onParentEnter={onParentEnterNested}>
594 <div>Item</div>
595 </ViewTransition>
596 </div>
597 </ViewTransition>
598 );
599 }
600
601 const root = ReactDOMClient.createRoot(container);
602
603 await act(() => {
604 startTransition(() => {
605 root.render(<App show={false} />);
606 });
607 });
608
609 onParentEnter.mockClear();
610 onNestedEnter.mockClear();
611 onParentEnterNested.mockClear();
612
613 await act(() => {
614 startTransition(() => {
615 root.render(<App show={true} />);
616 });
617 });
618
619 expect(onParentEnter).toHaveBeenCalledTimes(1);
620 expect(onNestedEnter).not.toHaveBeenCalled();
621 expect(onParentEnterNested).toHaveBeenCalledTimes(1);
622 });
623
624 // @gate enableViewTransition && enableViewTransitionParentEnterExit
625 it('breaks parentExit chain when intermediate ViewTransition lacks parentExit', async () => {
626 const onParentExit1 = jest.fn();
627 const onParentExit2 = jest.fn();
628
629 function App({show}) {
630 if (!show) {
631 return null;
632 }
633 return (
634 <ViewTransition exit="page-exit">
635 <div>
636 <ViewTransition parentExit="relay-exit">
637 <div>
638 <ViewTransition>
639 <div>
640 <ViewTransition
641 parentExit="nested-exit"
642 onParentExit={onParentExit1}>
643 <div>Deep</div>
644 </ViewTransition>
645 </div>
646 </ViewTransition>
647 </div>
648 </ViewTransition>
649 <ViewTransition
650 parentExit="nested-exit"
651 onParentExit={onParentExit2}>
652 <div>Shallow</div>
653 </ViewTransition>
654 </div>
655 </ViewTransition>
656 );
657 }
658
659 const root = ReactDOMClient.createRoot(container);
660
661 await act(() => {
662 startTransition(() => {
663 root.render(<App show={true} />);
664 });
665 });
666
667 onParentExit1.mockClear();
668 onParentExit2.mockClear();
669
670 await act(() => {
671 startTransition(() => {
672 root.render(<App show={false} />);
673 });
674 });
675
676 expect(onParentExit1).not.toHaveBeenCalled();
677 expect(onParentExit2).toHaveBeenCalledTimes(1);
678 });
679
680 // @gate enableViewTransition && enableViewTransitionParentEnterExit
681 it('stops the parentExit relay when an intermediate class is "none"', async () => {
682 const onParentExitDeep = jest.fn();
683 const onParentExitSibling = jest.fn();
684
685 function App({show}) {
686 if (!show) {
687 return null;
688 }
689 return (
690 <ViewTransition exit="page-exit">
691 <div>
692 <ViewTransition parentExit="none">
693 <div>
694 <ViewTransition
695 parentExit="nested-exit"
696 onParentExit={onParentExitDeep}>
697 <div>Deep</div>
698 </ViewTransition>
699 </div>
700 </ViewTransition>
701 <ViewTransition
702 parentExit="nested-exit"
703 onParentExit={onParentExitSibling}>
704 <div>Shallow</div>
705 </ViewTransition>
706 </div>
707 </ViewTransition>
708 );
709 }
710
711 const root = ReactDOMClient.createRoot(container);
712
713 await act(() => {
714 startTransition(() => {
715 root.render(<App show={true} />);
716 });
717 });
718
719 onParentExitDeep.mockClear();
720 onParentExitSibling.mockClear();
721
722 await act(() => {
723 startTransition(() => {
724 root.render(<App show={false} />);
725 });
726 });
727
728 // The "none" class stops the relay so nested activations below it never fire.
729 expect(onParentExitDeep).not.toHaveBeenCalled();
730 // A sibling that is not behind a "none" boundary still relays.
731 expect(onParentExitSibling).toHaveBeenCalledTimes(1);
732 });
733
734 // @gate enableViewTransition && enableViewTransitionParentEnterExit
735 it('stops the parentEnter relay when an intermediate class is "none"', async () => {
736 const onParentEnterDeep = jest.fn();
737 const onParentEnterSibling = jest.fn();
738
739 function App({show}) {
740 if (!show) {
741 return null;
742 }
743 return (
744 <ViewTransition enter="page-enter">
745 <div>
746 <ViewTransition parentEnter="none">
747 <div>
748 <ViewTransition
749 parentEnter="nested-enter"
750 onParentEnter={onParentEnterDeep}>
751 <div>Deep</div>
752 </ViewTransition>
753 </div>
754 </ViewTransition>
755 <ViewTransition
756 parentEnter="nested-enter"
757 onParentEnter={onParentEnterSibling}>
758 <div>Shallow</div>
759 </ViewTransition>
760 </div>
761 </ViewTransition>
762 );
763 }
764
765 const root = ReactDOMClient.createRoot(container);
766
767 await act(() => {
768 startTransition(() => {
769 root.render(<App show={false} />);
770 });
771 });
772
773 await act(() => {
774 startTransition(() => {
775 root.render(<App show={true} />);
776 });
777 });
778
779 // The "none" class stops the relay so nested activations below it never fire.
780 expect(onParentEnterDeep).not.toHaveBeenCalled();
781 // A sibling that is not behind a "none" boundary still relays.
782 expect(onParentEnterSibling).toHaveBeenCalledTimes(1);
783 });
784
785 it('does not fire onParentEnter when ancestor exits', async () => {
786 const onParentEnter = jest.fn();
787
788 function App({show}) {
789 if (!show) {
790 return null;
791 }
792 return (
793 <ViewTransition exit="page-exit">
794 <div>
795 <ViewTransition parentExit="relay-exit">
796 <ViewTransition
797 parentEnter="nested-enter"
798 onParentEnter={onParentEnter}>
799 <div>Item</div>
800 </ViewTransition>
801 </ViewTransition>
802 </div>
803 </ViewTransition>
804 );
805 }
806
807 const root = ReactDOMClient.createRoot(container);
808
809 await act(() => {
810 startTransition(() => {
811 root.render(<App show={true} />);
812 });
813 });
814
815 onParentEnter.mockClear();
816
817 await act(() => {
818 startTransition(() => {
819 root.render(<App show={false} />);
820 });
821 });
822
823 expect(onParentEnter).not.toHaveBeenCalled();
824 });
825
826 // @gate enableViewTransition
827 it('does not fire onParentExit when ancestor shares instead of exiting', async () => {
828 const onShare = jest.fn();
829 const onParentExit = jest.fn();
830
831 function App({page}) {
832 if (page === 'a') {
833 return (
834 <ViewTransition key="a" name="hero" onShare={onShare}>
835 <ViewTransition
836 parentExit="child-parent-exit"
837 onParentExit={onParentExit}>
838 <div>Page A</div>
839 </ViewTransition>
840 </ViewTransition>
841 );
842 }
843 return (
844 <ViewTransition key="b" name="hero">
845 <ViewTransition
846 parentExit="child-parent-exit"
847 onParentExit={onParentExit}>
848 <div>Page B</div>
849 </ViewTransition>
850 </ViewTransition>
851 );
852 }
853
854 const root = ReactDOMClient.createRoot(container);
855
856 await act(() => {
857 startTransition(() => {
858 root.render(<App page="a" />);
859 });
860 });
861
862 onShare.mockClear();
863 onParentExit.mockClear();
864
865 await act(() => {
866 startTransition(() => {
867 root.render(<App page="b" />);
868 });
869 });
870
871 expect(onShare).toHaveBeenCalledTimes(1);
872 expect(onParentExit).not.toHaveBeenCalled();
873 });
874
875 // @gate enableViewTransition
876 it('does not fire onParentEnter when ancestor shares instead of entering', async () => {
877 const onShare = jest.fn();
878 const onParentEnter = jest.fn();
879
880 function App({page}) {
881 if (page === 'a') {
882 return (
883 <ViewTransition key="a" name="hero" onShare={onShare}>
884 <ViewTransition
885 parentEnter="child-parent-enter"
886 onParentEnter={onParentEnter}>
887 <div>Page A</div>
888 </ViewTransition>
889 </ViewTransition>
890 );
891 }
892 return (
893 <ViewTransition key="b" name="hero">
894 <ViewTransition
895 parentEnter="child-parent-enter"
896 onParentEnter={onParentEnter}>
897 <div>Page B</div>
898 </ViewTransition>
899 </ViewTransition>
900 );
901 }
902
903 const root = ReactDOMClient.createRoot(container);
904
905 await act(() => {
906 startTransition(() => {
907 root.render(<App page="a" />);
908 });
909 });
910
911 onShare.mockClear();
912 onParentEnter.mockClear();
913
914 await act(() => {
915 startTransition(() => {
916 root.render(<App page="b" />);
917 });
918 });
919
920 expect(onShare).toHaveBeenCalledTimes(1);
921 expect(onParentEnter).not.toHaveBeenCalled();
922 });
923
924 it('does not fire onParentExit when ancestor exit is none', async () => {
925 const onParentExit = jest.fn();
926
927 function App({show}) {
928 if (!show) {
929 return null;
930 }
931 return (
932 <ViewTransition exit="none">
933 <ViewTransition
934 parentExit="nested-parent-exit"
935 onParentExit={onParentExit}>
936 <div>Item</div>
937 </ViewTransition>
938 </ViewTransition>
939 );
940 }
941
942 const root = ReactDOMClient.createRoot(container);
943
944 await act(() => {
945 startTransition(() => {
946 root.render(<App show={true} />);
947 });
948 });
949
950 onParentExit.mockClear();
951
952 await act(() => {
953 startTransition(() => {
954 root.render(<App show={false} />);
955 });
956 });
957
958 expect(onParentExit).not.toHaveBeenCalled();
959 });
960
961 it('does not fire onParentEnter when ancestor enter is none', async () => {
962 const onParentEnter = jest.fn();
963
964 function App({show}) {
965 if (!show) {
966 return null;
967 }
968 return (
969 <ViewTransition enter="none">
970 <ViewTransition
971 parentEnter="nested-parent-enter"
972 onParentEnter={onParentEnter}>
973 <div>Item</div>
974 </ViewTransition>
975 </ViewTransition>
976 );
977 }
978
979 const root = ReactDOMClient.createRoot(container);
980
981 await act(() => {
982 startTransition(() => {
983 root.render(<App show={false} />);
984 });
985 });
986
987 onParentEnter.mockClear();
988
989 await act(() => {
990 startTransition(() => {
991 root.render(<App show={true} />);
992 });
993 });
994
995 expect(onParentEnter).not.toHaveBeenCalled();
996 });
997
998 // @gate enableViewTransition && enableViewTransitionParentEnterExit
999 it('relays parentExit chain through unstyled parentExit', async () => {
1000 const onParentExit = jest.fn();
1001
1002 function App({show}) {
1003 if (!show) {
1004 return null;
1005 }
1006 return (
1007 <ViewTransition exit="page-exit">
1008 <div>
1009 <ViewTransition parentExit="auto">
1010 <ViewTransition
1011 parentExit="nested-exit"
1012 onParentExit={onParentExit}>
1013 <div>Item</div>
1014 </ViewTransition>
1015 </ViewTransition>
1016 </div>
1017 </ViewTransition>
1018 );
1019 }
1020
1021 const root = ReactDOMClient.createRoot(container);
1022
1023 await act(() => {
1024 startTransition(() => {
1025 root.render(<App show={true} />);
1026 });
1027 });
1028
1029 onParentExit.mockClear();
1030
1031 await act(() => {
1032 startTransition(() => {
1033 root.render(<App show={false} />);
1034 });
1035 });
1036
1037 expect(onParentExit).toHaveBeenCalledTimes(1);
1038 });
1039
1040 // @gate enableViewTransition && enableViewTransitionParentEnterExit
1041 it('fires onParentExit when ancestor ViewTransition exits with handler only', async () => {
1042 const onParentExit = jest.fn();
1043 const onRelayParentExit = jest.fn();
1044 const onParentExitDeep = jest.fn();
1045
1046 function App({show}) {
1047 if (!show) {
1048 return null;
1049 }
1050 return (
1051 <ViewTransition onExit={onParentExit}>
1052 <div>
1053 <ViewTransition onParentExit={onRelayParentExit}>
1054 <ViewTransition onParentExit={onParentExitDeep}>
1055 <div>Item</div>
1056 </ViewTransition>
1057 </ViewTransition>
1058 </div>
1059 </ViewTransition>
1060 );
1061 }
1062
1063 const root = ReactDOMClient.createRoot(container);
1064
1065 await act(() => {
1066 startTransition(() => {
1067 root.render(<App show={true} />);
1068 });
1069 });
1070
1071 onParentExit.mockClear();
1072 onRelayParentExit.mockClear();
1073 onParentExitDeep.mockClear();
1074
1075 await act(() => {
1076 startTransition(() => {
1077 root.render(<App show={false} />);
1078 });
1079 });
1080
1081 expect(onParentExit).toHaveBeenCalledTimes(1);
1082 expect(onRelayParentExit).toHaveBeenCalledTimes(1);
1083 expect(onParentExitDeep).toHaveBeenCalledTimes(1);
1084 });
1085
1086 // @gate enableViewTransition && enableViewTransitionParentEnterExit
1087 it('fires onParentEnter when ancestor ViewTransition enters with handler only', async () => {
1088 const onParentEnter = jest.fn();
1089 const onRelayParentEnter = jest.fn();
1090 const onParentEnterDeep = jest.fn();
1091
1092 function App({show}) {
1093 if (!show) {
1094 return null;
1095 }
1096 return (
1097 <ViewTransition onEnter={onParentEnter}>
1098 <div>
1099 <ViewTransition onParentEnter={onRelayParentEnter}>
1100 <ViewTransition onParentEnter={onParentEnterDeep}>
1101 <div>Item</div>
1102 </ViewTransition>
1103 </ViewTransition>
1104 </div>
1105 </ViewTransition>
1106 );
1107 }
1108
1109 const root = ReactDOMClient.createRoot(container);
1110
1111 await act(() => {
1112 startTransition(() => {
1113 root.render(<App show={false} />);
1114 });
1115 });
1116
1117 onParentEnter.mockClear();
1118 onRelayParentEnter.mockClear();
1119 onParentEnterDeep.mockClear();
1120
1121 await act(() => {
1122 startTransition(() => {
1123 root.render(<App show={true} />);
1124 });
1125 });
1126
1127 expect(onParentEnter).toHaveBeenCalledTimes(1);
1128 expect(onRelayParentEnter).toHaveBeenCalledTimes(1);
1129 expect(onParentEnterDeep).toHaveBeenCalledTimes(1);
1130 });
1131
1132 it('does not fire onParentEnter when ancestor enter is none with handler only', async () => {
1133 const onParentEnter = jest.fn();
1134
1135 function App({show}) {
1136 if (!show) {
1137 return null;
1138 }
1139 return (
1140 <ViewTransition enter="none">
1141 <ViewTransition onParentEnter={onParentEnter}>
1142 <div>Item</div>
1143 </ViewTransition>
1144 </ViewTransition>
1145 );
1146 }
1147
1148 const root = ReactDOMClient.createRoot(container);
1149
1150 await act(() => {
1151 startTransition(() => {
1152 root.render(<App show={false} />);
1153 });
1154 });
1155
1156 onParentEnter.mockClear();
1157
1158 await act(() => {
1159 startTransition(() => {
1160 root.render(<App show={true} />);
1161 });
1162 });
1163
1164 expect(onParentEnter).not.toHaveBeenCalled();
1165 });
1166
1167 // @gate enableViewTransition && enableViewTransitionParentEnterExit
1168 it('relays parentEnter chain to handler-only child through intermediate divs', async () => {
1169 const onParentEnter = jest.fn();
1170 const onParentEnterNested = jest.fn();
1171
1172 function App({show}) {
1173 if (!show) {
1174 return null;
1175 }
1176 return (
1177 <ViewTransition onEnter={onParentEnter}>
1178 <div>
1179 <div>
1180 <ViewTransition onParentEnter={onParentEnterNested}>
1181 <div>Item</div>
1182 </ViewTransition>
1183 </div>
1184 </div>
1185 </ViewTransition>
1186 );
1187 }
1188
1189 const root = ReactDOMClient.createRoot(container);
1190
1191 await act(() => {
1192 startTransition(() => {
1193 root.render(<App show={false} />);
1194 });
1195 });
1196
1197 onParentEnter.mockClear();
1198 onParentEnterNested.mockClear();
1199
1200 await act(() => {
1201 startTransition(() => {
1202 root.render(<App show={true} />);
1203 });
1204 });
1205
1206 expect(onParentEnter).toHaveBeenCalledTimes(1);
1207 expect(onParentEnterNested).toHaveBeenCalledTimes(1);
1208 });
1209
1210 // @gate enableViewTransition
1211 it('enters without props and does not fire handlers', async () => {
1212 const startViewTransitionSpy = jest.fn(document.startViewTransition);
1213 document.startViewTransition = startViewTransitionSpy;
1214
1215 function App({show}) {
1216 if (!show) {
1217 return null;
1218 }
1219 return (
1220 <ViewTransition>
1221 <div>Hello</div>
1222 </ViewTransition>
1223 );
1224 }
1225
1226 const root = ReactDOMClient.createRoot(container);
1227
1228 await act(() => {
1229 root.render(<App show={false} />);
1230 });
1231 expect(startViewTransitionSpy).not.toHaveBeenCalled();
1232
1233 await act(() => {
1234 startTransition(() => {
1235 root.render(<App show={true} />);
1236 });
1237 });
1238
1239 expect(startViewTransitionSpy).toHaveBeenCalled();
1240 });
1241
1242 // @gate enableViewTransition
1243 it('exits without props and does not fire handlers', async () => {
1244 const startViewTransitionSpy = jest.fn(document.startViewTransition);
1245 document.startViewTransition = startViewTransitionSpy;
1246
1247 function App({show}) {
1248 if (!show) {
1249 return null;
1250 }
1251 return (
1252 <ViewTransition>
1253 <div>Goodbye</div>
1254 </ViewTransition>
1255 );
1256 }
1257
1258 const root = ReactDOMClient.createRoot(container);
1259
1260 await act(() => {
1261 startTransition(() => {
1262 root.render(<App show={true} />);
1263 });
1264 });
1265 startViewTransitionSpy.mockClear();
1266
1267 await act(() => {
1268 startTransition(() => {
1269 root.render(<App show={false} />);
1270 });
1271 });
1272
1273 expect(startViewTransitionSpy).toHaveBeenCalled();
1274 });
1275
1276 // @gate enableViewTransition && enableViewTransitionParentEnterExit
1277 it('fires onParentEnter when ancestor ViewTransition has no props', async () => {
1278 const onParentEnterNested = jest.fn();
1279
1280 function App({show}) {
1281 if (!show) {
1282 return null;
1283 }
1284 return (
1285 <ViewTransition>
1286 <div>
1287 <ViewTransition onParentEnter={onParentEnterNested}>
1288 <div>Item</div>
1289 </ViewTransition>
1290 </div>
1291 </ViewTransition>
1292 );
1293 }
1294
1295 const root = ReactDOMClient.createRoot(container);
1296
1297 await act(() => {
1298 startTransition(() => {
1299 root.render(<App show={false} />);
1300 });
1301 });
1302
1303 onParentEnterNested.mockClear();
1304
1305 await act(() => {
1306 startTransition(() => {
1307 root.render(<App show={true} />);
1308 });
1309 });
1310
1311 expect(onParentEnterNested).toHaveBeenCalledTimes(1);
1312 });
1313
1314 // @gate enableViewTransition && enableViewTransitionParentEnterExit
1315 it('fires onParentExit when ancestor ViewTransition has no props', async () => {
1316 const onParentExitNested = jest.fn();
1317
1318 function App({show}) {
1319 if (!show) {
1320 return null;
1321 }
1322 return (
1323 <ViewTransition>
1324 <div>
1325 <ViewTransition onParentExit={onParentExitNested}>
1326 <div>Item</div>
1327 </ViewTransition>
1328 </div>
1329 </ViewTransition>
1330 );
1331 }
1332
1333 const root = ReactDOMClient.createRoot(container);
1334
1335 await act(() => {
1336 startTransition(() => {
1337 root.render(<App show={true} />);
1338 });
1339 });
1340
1341 onParentExitNested.mockClear();
1342
1343 await act(() => {
1344 startTransition(() => {
1345 root.render(<App show={false} />);
1346 });
1347 });
1348
1349 expect(onParentExitNested).toHaveBeenCalledTimes(1);
1350 });
1351 });
1352 });