@samitouri / QOS-React-1 / commits / ec0fca31f4

Add parentEnter/parentExit props to ViewTransition (#36690)

An alternative to https://github.com/facebook/react/pull/36135 ___ Adds experimental support for `parentEnter` and `parentExit` on nested `<ViewTransition>` components, so descendants can animate when an ancestor boundary enters or exits. This is gated behind the `enableViewTransitionParentEnterExit` feature flag. Addresses the use case of animating individual items in a list (such as sliding inactive feed posts off-screen) when a parent `<ViewTransition>` is the one being added or removed during the update. Today, `exit`/ `enter` only fire on the `<ViewTransition>` that was directly mounted or unmounted. Nested boundaries in the same subtree are not visited for their own `exit`/`enter` when the whole tree goes away as one unit, which is usually what you want. But some cases need an explicit opt-in to nested animations. This PR adds `parentEnter`/`parentExit`, and their handlers `onParentEnter`/`onParentExit`. When an ancestor runs a real `enter` or `exit`, React walks descendants and activates boundaries that define the nested props. This only continues through `<ViewTransition>` nodes that define `parentEnter` / `parentExit` / `onParentEnter` / `onParentExit` / `onGestureParentEnter` / `onGestureParentExit`, otherwise the chaining stops. A boundary’s own `enter`/`exit` still do not run when only an ancestor animates. So it is possible to have different animations on the same `<ViewTransition>` for `enter` vs `parentEnter`. ```jsx function ExampleOnAncestorExit() { return ( // This boundary exits — starts the parentExit walk <ViewTransition exit="panel-exit"> <div> {/* ✓ Activates: parentExit set. Walk continues inside */} <ViewTransition parentExit="slide-out"> <div> {/* ✗ parentEnter is not consulted on an exit walk */} <ViewTransition parentEnter="slide-in"> ... </ViewTransition> {/* ✗ Chain broken: no parentExit or onParentExit */} <ViewTransition> <div> {/* ✗ Never reached on this path, blocked by parent above */} <ViewTransition parentExit="slide-out"> ... </ViewTransition> </div> </ViewTransition> {/* ✓ Handler-only chain: onParentExit continues the walk */} <ViewTransition onParentExit={() => animateOut()}> <ViewTransition onParentExit={() => animateDeepOut()}> ... </ViewTransition> </ViewTransition> {/* ✓ Activates: sibling under the chain */} <ViewTransition parentExit="slide-out"> ... </ViewTransition> </div> </ViewTransition> </div> </ViewTransition> ); } ```

Jack Pope committed Jul 1, 2026 at 09:16 UTC ec0fca31f419e821018fc67bc88f2ce62ffb2050
17 files changed +1501
fixtures/view-transition/src/components/NestedParentExit.css new
+212
@@ -0,0 +1,212 @@
1 +.nested-parent-exit {
2 + width: 280px;
3 + min-height: 280px;
4 + margin-top: 2rem;
5 + padding-top: 1rem;
6 + border-top: 1px solid #ccc;
7 +}
8 +
9 +.nested-parent-exit-swipe {
10 + min-height: 200px;
11 +}
12 +
13 +.nested-parent-exit-label {
14 + margin: 0 0 0.75rem;
15 + font-size: 13px;
16 + color: #666;
17 +}
18 +
19 +.nested-parent-exit-panel {
20 + min-height: 240px;
21 +}
22 +
23 +.nested-parent-exit .feed-item {
24 + margin-bottom: 0.75rem;
25 + cursor: pointer;
26 +}
27 +
28 +.nested-parent-exit .feed-item-title {
29 + margin: 0 0 0.15rem;
30 + font-size: 15px;
31 + font-weight: 600;
32 +}
33 +
34 +.nested-parent-exit .feed-item p,
35 +.nested-parent-exit .detail-view p {
36 + margin: 0;
37 + font-size: 13px;
38 + color: #666;
39 +}
40 +
41 +.nested-parent-exit .back-button {
42 + margin-bottom: 0.75rem;
43 + padding: 0;
44 + border: none;
45 + background: none;
46 + cursor: pointer;
47 + font: inherit;
48 + color: inherit;
49 + text-decoration: underline;
50 +}
51 +
52 +@keyframes nested-exit-left {
53 + from {
54 + opacity: 1;
55 + translate: 0 0;
56 + }
57 + to {
58 + opacity: 0;
59 + translate: -120% 0;
60 + }
61 +}
62 +
63 +::view-transition-old(.nested-exit-left) {
64 + animation: nested-exit-left 450ms ease-out forwards;
65 +}
66 +
67 +@keyframes nested-enter-from-left {
68 + from {
69 + opacity: 0;
70 + translate: -120% 0;
71 + }
72 + to {
73 + opacity: 1;
74 + translate: 0 0;
75 + }
76 +}
77 +
78 +::view-transition-new(.nested-enter-from-left) {
79 + animation: nested-enter-from-left 450ms ease-out 650ms both;
80 +}
81 +
82 +/* Title relays the parent activation but exits/enters vertically. */
83 +@keyframes nested-title-exit-up {
84 + from {
85 + opacity: 1;
86 + translate: 0 0;
87 + }
88 + to {
89 + opacity: 0;
90 + translate: 0 -80%;
91 + }
92 +}
93 +
94 +::view-transition-old(.nested-title-exit-up) {
95 + animation: nested-title-exit-up 450ms ease-out forwards;
96 +}
97 +
98 +@keyframes nested-title-enter-from-up {
99 + from {
100 + opacity: 0;
101 + translate: 0 -80%;
102 + }
103 + to {
104 + opacity: 1;
105 + translate: 0 0;
106 + }
107 +}
108 +
109 +::view-transition-new(.nested-title-enter-from-up) {
110 + animation: nested-title-enter-from-up 450ms ease-out 650ms both;
111 +}
112 +
113 +/* Body relays the same parent activation but exits/enters to the right. */
114 +@keyframes nested-body-exit-right {
115 + from {
116 + opacity: 1;
117 + translate: 0 0;
118 + }
119 + to {
120 + opacity: 0;
121 + translate: 120% 0;
122 + }
123 +}
124 +
125 +::view-transition-old(.nested-body-exit-right) {
126 + animation: nested-body-exit-right 450ms ease-in forwards;
127 +}
128 +
129 +@keyframes nested-body-enter-from-right {
130 + from {
131 + opacity: 0;
132 + translate: 120% 0;
133 + }
134 + to {
135 + opacity: 1;
136 + translate: 0 0;
137 + }
138 +}
139 +
140 +::view-transition-new(.nested-body-enter-from-right) {
141 + animation: nested-body-enter-from-right 450ms ease-out 650ms both;
142 +}
143 +
144 +::view-transition-group(.nested-shared-post-forward) {
145 + animation-duration: 600ms;
146 + animation-delay: 300ms;
147 + animation-timing-function: ease-in-out;
148 + animation-fill-mode: both;
149 +}
150 +
151 +::view-transition-old(.nested-shared-post-forward),
152 +::view-transition-new(.nested-shared-post-forward) {
153 + animation-delay: 300ms;
154 + animation-duration: 600ms;
155 + animation-fill-mode: both;
156 +}
157 +
158 +::view-transition-group(.nested-shared-post-back) {
159 + animation-duration: 600ms;
160 + animation-timing-function: ease-in-out;
161 +}
162 +
163 +::view-transition-group(.nested-shared-inner-forward) {
164 + animation-duration: 500ms;
165 + animation-delay: 400ms;
166 + animation-timing-function: ease-in-out;
167 + animation-fill-mode: both;
168 +}
169 +
170 +::view-transition-old(.nested-shared-inner-forward),
171 +::view-transition-new(.nested-shared-inner-forward) {
172 + animation-delay: 400ms;
173 + animation-duration: 500ms;
174 + animation-fill-mode: both;
175 +}
176 +
177 +::view-transition-group(.nested-shared-inner-back) {
178 + animation-duration: 500ms;
179 + animation-delay: 100ms;
180 + animation-timing-function: ease-in-out;
181 + animation-fill-mode: both;
182 +}
183 +
184 +@keyframes nested-back-btn-enter {
185 + from {
186 + opacity: 0;
187 + translate: -20px 0;
188 + }
189 + to {
190 + opacity: 1;
191 + translate: 0 0;
192 + }
193 +}
194 +
195 +@keyframes nested-back-btn-exit {
196 + from {
197 + opacity: 1;
198 + translate: 0 0;
199 + }
200 + to {
201 + opacity: 0;
202 + translate: -20px 0;
203 + }
204 +}
205 +
206 +::view-transition-new(.nested-back-btn-enter):only-child {
207 + animation: nested-back-btn-enter 300ms ease-out 650ms both;
208 +}
209 +
210 +::view-transition-old(.nested-back-btn-exit):only-child {
211 + animation: nested-back-btn-exit 200ms ease-in forwards;
212 +}
fixtures/view-transition/src/components/NestedParentExit.js new
+180
@@ -0,0 +1,180 @@
1 +import React, {
2 + ViewTransition,
3 + useState,
4 + useOptimistic,
5 + startTransition,
6 + addTransitionType,
7 +} from 'react';
8 +import SwipeRecognizer from './SwipeRecognizer.js';
9 +import './NestedParentExit.css';
10 +
11 +const items = [
12 + {id: 1, title: 'First Post', body: 'Hello from the first post.'},
13 + {id: 2, title: 'Second Post', body: 'Hello from the second post.'},
14 + {id: 3, title: 'Third Post', body: 'Hello from the third post.'},
15 +];
16 +
17 +function logGestureParent(kind, title, _timeline, _options, _instance, types) {
18 + // eslint-disable-next-line no-console
19 + console.log(`[NestedParentExit] onGestureParent${kind}`, title, types);
20 +}
21 +
22 +function FeedItem({item, index, activeIndex, onSelect}) {
23 + const isActive = activeIndex === index;
24 +
25 + return (
26 + <ViewTransition
27 + name={'nested-post-' + item.id}
28 + share={{
29 + 'nav-forward': 'nested-shared-post-forward',
30 + 'nav-back': 'nested-shared-post-back',
31 + }}
32 + parentExit={isActive ? undefined : 'nested-exit-left'}
33 + parentEnter={isActive ? undefined : 'nested-enter-from-left'}
34 + onGestureParentExit={(...args) =>
35 + logGestureParent('Exit', item.title, ...args)
36 + }
37 + onGestureParentEnter={(...args) =>
38 + logGestureParent('Enter', item.title, ...args)
39 + }>
40 + <div className="feed-item" onClick={() => onSelect(item, index)}>
41 + <ViewTransition
42 + name={'nested-title-' + item.id}
43 + share={{
44 + 'nav-forward': 'nested-shared-inner-forward',
45 + 'nav-back': 'nested-shared-inner-back',
46 + }}
47 + parentExit={isActive ? undefined : 'nested-title-exit-up'}
48 + parentEnter={isActive ? undefined : 'nested-title-enter-from-up'}>
49 + <div className="feed-item-title">{item.title}</div>
50 + </ViewTransition>
51 + <ViewTransition
52 + parentExit={isActive ? undefined : 'nested-body-exit-right'}
53 + parentEnter={isActive ? undefined : 'nested-body-enter-from-right'}>
54 + <p>{item.body}</p>
55 + </ViewTransition>
56 + </div>
57 + </ViewTransition>
58 + );
59 +}
60 +
61 +function Detail({item, onBack}) {
62 + return (
63 + <ViewTransition
64 + name={'nested-post-' + item.id}
65 + share={{
66 + 'nav-forward': 'nested-shared-post-forward',
67 + 'nav-back': 'nested-shared-post-back',
68 + }}>
69 + <div className="detail-view">
70 + <ViewTransition
71 + enter="nested-back-btn-enter"
72 + exit="nested-back-btn-exit">
73 + <button className="back-button" onClick={onBack}>
74 + ← Back
75 + </button>
76 + </ViewTransition>
77 + <ViewTransition
78 + name={'nested-title-' + item.id}
79 + share={{
80 + 'nav-forward': 'nested-shared-inner-forward',
81 + 'nav-back': 'nested-shared-inner-back',
82 + }}>
83 + <div className="feed-item-title">{item.title}</div>
84 + </ViewTransition>
85 + <p>{item.body}</p>
86 + </div>
87 + </ViewTransition>
88 + );
89 +}
90 +
91 +const initialNav = {selected: null, activeIndex: null};
92 +
93 +export default function NestedParentExit() {
94 + const [nav, setNav] = useState(initialNav);
95 + const [optimisticNav, navigateByGesture] = useOptimistic(
96 + nav,
97 + (state, direction) => {
98 + if (direction === 'left' && state.selected === null) {
99 + return {selected: items[0], activeIndex: 0};
100 + }
101 + if (direction === 'right' && state.selected !== null) {
102 + return {
103 + selected: null,
104 + activeIndex:
105 + state.activeIndex ??
106 + items.findIndex(i => i.id === state.selected.id),
107 + };
108 + }
109 + return state;
110 + }
111 + );
112 +
113 + const {selected, activeIndex} = optimisticNav;
114 +
115 + function goToDetail(item, index) {
116 + startTransition(() => {
117 + addTransitionType('nav-forward');
118 + setNav({selected: item, activeIndex: index});
119 + });
120 + }
121 +
122 + function goBack() {
123 + const current = selected;
124 + if (current == null) {
125 + return;
126 + }
127 + const backIndex = items.findIndex(i => i.id === current.id);
128 + startTransition(() => {
129 + addTransitionType('nav-back');
130 + setNav({selected: null, activeIndex: backIndex});
131 + });
132 + }
133 +
134 + function swipeAction() {
135 + if (nav.selected === null) {
136 + goToDetail(items[0], 0);
137 + } else {
138 + goBack();
139 + }
140 + }
141 +
142 + return (
143 + <div className="nested-parent-exit">
144 + <p className="nested-parent-exit-label">
145 + Parent Exit/Enter — click a post or swipe (scroll the strip below)
146 + </p>
147 + <div className="nested-parent-exit-swipe swipe-recognizer">
148 + <SwipeRecognizer
149 + action={swipeAction}
150 + gesture={direction => {
151 + addTransitionType(
152 + direction === 'left' ? 'nav-forward' : 'nav-back'
153 + );
154 + navigateByGesture(direction);
155 + }}
156 + direction={selected ? 'right' : 'left'}>
157 + <ViewTransition key={selected ? 'detail' : 'feed'} update="none">
158 + <div className="nested-parent-exit-panel">
159 + {selected ? (
160 + <Detail item={selected} onBack={goBack} />
161 + ) : (
162 + <>
163 + {items.map((item, index) => (
164 + <FeedItem
165 + key={item.id}
166 + item={item}
167 + index={index}
168 + activeIndex={activeIndex}
169 + onSelect={goToDetail}
170 + />
171 + ))}
172 + </>
173 + )}
174 + </div>
175 + </ViewTransition>
176 + </SwipeRecognizer>
177 + </div>
178 + </div>
179 + );
180 +}
fixtures/view-transition/src/components/Page.js
+2
@@ -20,6 +20,7 @@ import './Page.css';
20
21 import transitions from './Transitions.module.css';
22 import NestedReveal from './NestedReveal.js';
23 +import NestedParentExit from './NestedParentExit.js';
24
25 async function sleep(ms) {
26 return new Promise(resolve => setTimeout(resolve, ms));
@@ -322,6 +323,7 @@ export default function Page({url, navigate}) {
323 </ViewTransition>
324 </SwipeRecognizer>
325 <NestedReveal />
326 + <NestedParentExit />
327 </div>
328 );
329 }
packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js
+887
@@ -461,5 +461,892 @@ describe('ReactDOMViewTransition', () => {
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 });
packages/react-reconciler/src/ReactFiberApplyGesture.js
+5
@@ -75,6 +75,7 @@ import {
75 restoreUpdateViewTransitionForGesture,
76 appearingViewTransitions,
77 commitEnterViewTransitions,
78 + commitParentExitViewTransitions,
79 measureNestedViewTransitions,
80 measureUpdateViewTransition,
81 viewTransitionCancelableChildren,
@@ -93,6 +94,7 @@ import {
94 import {
95 enableProfilerTimer,
96 enableComponentPerformanceTrack,
97 + enableViewTransitionParentEnterExit,
98 } from 'shared/ReactFeatureFlags';
99 import {trackAnimatingTask} from './ReactProfilerTimer';
100 import {scheduleGestureTransitionEvent} from './ReactFiberWorkLoop';
@@ -327,6 +329,9 @@ function applyExitViewTransition(placement: Fiber): void {
329 scheduleGestureTransitionEvent(placement, props.onGestureShare);
330 } else {
331 scheduleGestureTransitionEvent(placement, props.onGestureExit);
332 + if (enableViewTransitionParentEnterExit) {
333 + commitParentExitViewTransitions(placement, true);
334 + }
335 }
336 }
337 }
packages/react-reconciler/src/ReactFiberCommitViewTransitions.js
+162
@@ -21,6 +21,7 @@ import {
21 NoFlags,
22 Update,
23 ViewTransitionStatic,
24 + ViewTransitionStaticParent,
25 AffectedParentLayout,
26 ViewTransitionNamedStatic,
27 } from './ReactFiberFlags';
@@ -47,6 +48,7 @@ import {
48 enableComponentPerformanceTrack,
49 enableProfilerTimer,
50 enableViewTransitionForPersistenceMode,
51 + enableViewTransitionParentEnterExit,
52 } from 'shared/ReactFeatureFlags';
53
54 export let shouldStartViewTransition: boolean = false;
@@ -326,6 +328,157 @@ function commitAppearingPairViewTransitions(placement: Fiber): void {
328 }
329 }
330
331 +export function commitParentEnterViewTransitions(
332 + parent: Fiber,
333 + gesture: boolean,
334 +): void {
335 + let child = parent.child;
336 + while (child !== null) {
337 + if (child.tag === OffscreenComponent && child.memoizedState !== null) {
338 + // Skip hidden subtrees.
339 + } else if (child.tag === ViewTransitionComponent) {
340 + const props: ViewTransitionProps = child.memoizedProps;
341 + const hasParentClass = props.parentEnter !== undefined;
342 + const hasParentHandler = gesture
343 + ? props.onGestureParentEnter != null
344 + : props.onParentEnter != null;
345 + if (hasParentClass || hasParentHandler) {
346 + let relay = true;
347 + if (hasParentClass) {
348 + const state: ViewTransitionState = child.stateNode;
349 + const name = getViewTransitionName(props, state);
350 + const className: ?string = getViewTransitionClassName(
351 + props.default,
352 + props.parentEnter,
353 + );
354 + if (className === 'none') {
355 + relay = false;
356 + } else {
357 + applyViewTransitionToHostInstances(
358 + child,
359 + name,
360 + className,
361 + null,
362 + false,
363 + );
364 + if (hasParentHandler) {
365 + if (gesture) {
366 + scheduleGestureTransitionEvent(
367 + child,
368 + props.onGestureParentEnter,
369 + );
370 + } else {
371 + scheduleViewTransitionEvent(child, props.onParentEnter);
372 + }
373 + }
374 + }
375 + } else {
376 + if (gesture) {
377 + scheduleGestureTransitionEvent(child, props.onGestureParentEnter);
378 + } else {
379 + scheduleViewTransitionEvent(child, props.onParentEnter);
380 + }
381 + }
382 + if (relay) {
383 + commitParentEnterViewTransitions(child, gesture);
384 + }
385 + }
386 + } else if ((child.subtreeFlags & ViewTransitionStaticParent) !== NoFlags) {
387 + commitParentEnterViewTransitions(child, gesture);
388 + }
389 + child = child.sibling;
390 + }
391 +}
392 +
393 +export function commitParentExitViewTransitions(
394 + parent: Fiber,
395 + gesture: boolean,
396 +): void {
397 + let child = parent.child;
398 + while (child !== null) {
399 + if (child.tag === OffscreenComponent && child.memoizedState !== null) {
400 + // Skip hidden subtrees.
401 + } else if (child.tag === ViewTransitionComponent) {
402 + const props: ViewTransitionProps = child.memoizedProps;
403 + const hasParentClass = props.parentExit !== undefined;
404 + const hasParentHandler = gesture
405 + ? props.onGestureParentExit != null
406 + : props.onParentExit != null;
407 + if (hasParentClass || hasParentHandler) {
408 + let relay = true;
409 + if (hasParentClass) {
410 + const state: ViewTransitionState = child.stateNode;
411 + const name = getViewTransitionName(props, state);
412 + const className: ?string = getViewTransitionClassName(
413 + props.default,
414 + props.parentExit,
415 + );
416 + if (className === 'none') {
417 + relay = false;
418 + } else {
419 + applyViewTransitionToHostInstances(
420 + child,
421 + name,
422 + className,
423 + null,
424 + false,
425 + );
426 + if (hasParentHandler) {
427 + if (gesture) {
428 + scheduleGestureTransitionEvent(
429 + child,
430 + props.onGestureParentExit,
431 + );
432 + } else {
433 + scheduleViewTransitionEvent(child, props.onParentExit);
434 + }
435 + }
436 + }
437 + } else {
438 + if (gesture) {
439 + scheduleGestureTransitionEvent(child, props.onGestureParentExit);
440 + } else {
441 + scheduleViewTransitionEvent(child, props.onParentExit);
442 + }
443 + }
444 + if (relay) {
445 + commitParentExitViewTransitions(child, gesture);
446 + }
447 + }
448 + } else if ((child.subtreeFlags & ViewTransitionStaticParent) !== NoFlags) {
449 + commitParentExitViewTransitions(child, gesture);
450 + }
451 + child = child.sibling;
452 + }
453 +}
454 +
455 +function restoreParentEnterOrExitViewTransitions(parent: Fiber): void {
456 + let child = parent.child;
457 + while (child !== null) {
458 + if (child.tag === OffscreenComponent && child.memoizedState !== null) {
459 + // Skip hidden subtrees.
460 + } else if (child.tag === ViewTransitionComponent) {
461 + const props: ViewTransitionProps = child.memoizedProps;
462 + const hasParentClass =
463 + props.parentEnter !== undefined || props.parentExit !== undefined;
464 + const hasParentHandler =
465 + props.onParentEnter != null ||
466 + props.onParentExit != null ||
467 + props.onGestureParentEnter != null ||
468 + props.onGestureParentExit != null;
469 + if (hasParentClass) {
470 + restoreViewTransitionOnHostInstances(child.child, false);
471 + }
472 + if (hasParentClass || hasParentHandler) {
473 + restoreParentEnterOrExitViewTransitions(child);
474 + }
475 + } else if ((child.subtreeFlags & ViewTransitionStaticParent) !== NoFlags) {
476 + restoreParentEnterOrExitViewTransitions(child);
477 + }
478 + child = child.sibling;
479 + }
480 +}
481 +
482 export function commitEnterViewTransitions(
483 placement: Fiber,
484 gesture: boolean,
@@ -361,6 +514,9 @@ export function commitEnterViewTransitions(
514 } else {
515 scheduleViewTransitionEvent(placement, props.onEnter);
516 }
517 + if (enableViewTransitionParentEnterExit) {
518 + commitParentEnterViewTransitions(placement, gesture);
519 + }
520 }
521 }
522 } else {
@@ -489,6 +645,9 @@ export function commitExitViewTransitions(deletion: Fiber): void {
645 scheduleViewTransitionEvent(deletion, props.onShare);
646 } else {
647 scheduleViewTransitionEvent(deletion, props.onExit);
648 + if (enableViewTransitionParentEnterExit) {
649 + commitParentExitViewTransitions(deletion, false);
650 + }
651 }
652 }
653 if (appearingViewTransitions !== null) {
@@ -619,6 +778,9 @@ export function restoreEnterOrExitViewTransitions(fiber: Fiber): void {
778 const instance: ViewTransitionState = fiber.stateNode;
779 instance.paired = null;
780 restoreViewTransitionOnHostInstances(fiber.child, false);
781 + if (enableViewTransitionParentEnterExit) {
782 + restoreParentEnterOrExitViewTransitions(fiber);
783 + }
784 restorePairedViewTransitions(fiber);
785 } else if ((fiber.subtreeFlags & ViewTransitionStatic) !== NoFlags) {
786 let child = fiber.child;
packages/react-reconciler/src/ReactFiberCompleteWork.js
+17
@@ -40,6 +40,7 @@ import {
40 passChildrenWhenCloningPersistedNodes,
41 disableLegacyMode,
42 enableViewTransition,
43 + enableViewTransitionParentEnterExit,
44 enableSuspenseyImages,
45 } from 'shared/ReactFeatureFlags';
46
@@ -98,6 +99,7 @@ import {
99 ShouldSuspendCommit,
100 Cloned,
101 ViewTransitionStatic,
102 + ViewTransitionStaticParent,
103 Hydrate,
104 PortalStatic,
105 } from './ReactFiberFlags';
@@ -2076,6 +2078,21 @@ function completeWork(
2078 // bubble up to the parent tree to indicate that there's a child that
2079 // might need an exit View Transition upon unmount.
2080 workInProgress.flags |= ViewTransitionStatic;
2081 + if (enableViewTransitionParentEnterExit) {
2082 + const props = workInProgress.pendingProps;
2083 + if (
2084 + props.parentEnter !== undefined ||
2085 + props.parentExit !== undefined ||
2086 + props.onParentEnter != null ||
2087 + props.onParentExit != null ||
2088 + props.onGestureParentEnter != null ||
2089 + props.onGestureParentExit != null
2090 + ) {
2091 + workInProgress.flags |= ViewTransitionStaticParent;
2092 + } else {
2093 + workInProgress.flags &= ~ViewTransitionStaticParent;
2094 + }
2095 + }
2096 bubbleProperties(workInProgress);
2097 }
2098 return null;
packages/react-reconciler/src/ReactFiberFlags.js
+5
@@ -83,6 +83,10 @@ export const ViewTransitionNamedStatic =
83 // ViewTransitionStatic tracks whether there are an ViewTransition components from
84 // the nearest HostComponent down. It resets at every HostComponent level.
85 export const ViewTransitionStatic = /* */ 0b0000010000000000000000000000000;
86 +// ViewTransitionStaticParent tracks whether there are ViewTransition components
87 +// with parentEnter/parentExit props. Unlike ViewTransitionStatic, this is NOT
88 +// cleared by HostComponents so it can be used to skip subtrees in parent walks.
89 +export const ViewTransitionStaticParent = /* */ 0b1000000000000000000000000000000;
90 // Tracks whether a HostPortal is present in the tree.
91 export const PortalStatic = /* */ 0b0000100000000000000000000000000;
92
@@ -140,6 +144,7 @@ export const StaticMask =
144 RefStatic |
145 MaySuspendCommit |
146 ViewTransitionStatic |
147 + ViewTransitionStaticParent |
148 ViewTransitionNamedStatic |
149 PortalStatic |
150 Forked;
packages/shared/ReactFeatureFlags.js
+2
@@ -80,6 +80,8 @@ export const enableTaint = __EXPERIMENTAL__;
80
81 export const enableViewTransition: boolean = true;
82
83 +export const enableViewTransitionParentEnterExit = __EXPERIMENTAL__;
84 +
85 export const enableViewTransitionForPersistenceMode: boolean = false;
86
87 export const enableGestureTransition = __EXPERIMENTAL__;
packages/shared/ReactTypes.js
+22
@@ -313,6 +313,8 @@ export type ViewTransitionProps = {
313 exit?: ViewTransitionClass,
314 share?: ViewTransitionClass,
315 update?: ViewTransitionClass,
316 + parentEnter?: ViewTransitionClass,
317 + parentExit?: ViewTransitionClass,
318 onEnter?: (
319 instance: ViewTransitionInstance,
320 types: Array<string>,
@@ -321,6 +323,14 @@ export type ViewTransitionProps = {
323 instance: ViewTransitionInstance,
324 types: Array<string>,
325 ) => void | (() => void),
326 + onParentEnter?: (
327 + instance: ViewTransitionInstance,
328 + types: Array<string>,
329 + ) => void | (() => void),
330 + onParentExit?: (
331 + instance: ViewTransitionInstance,
332 + types: Array<string>,
333 + ) => void | (() => void),
334 onShare?: (
335 instance: ViewTransitionInstance,
336 types: Array<string>,
@@ -341,6 +351,18 @@ export type ViewTransitionProps = {
351 instance: ViewTransitionInstance,
352 types: Array<string>,
353 ) => void | (() => void),
354 + onGestureParentEnter?: (
355 + timeline: GestureProvider,
356 + options: GestureOptionsRequired,
357 + instance: ViewTransitionInstance,
358 + types: Array<string>,
359 + ) => void | (() => void),
360 + onGestureParentExit?: (
361 + timeline: GestureProvider,
362 + options: GestureOptionsRequired,
363 + instance: ViewTransitionInstance,
364 + types: Array<string>,
365 + ) => void | (() => void),
366 onGestureShare?: (
367 timeline: GestureProvider,
368 options: GestureOptionsRequired,
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -70,6 +70,7 @@ export const transitionLaneExpirationMs = 5000;
70 export const enableYieldingBeforePassive: boolean = false;
71 export const enableThrottledScheduling: boolean = false;
72 export const enableViewTransition: boolean = true;
73 +export const enableViewTransitionParentEnterExit: boolean = true;
74 export const enableGestureTransition: boolean = false;
75 export const enableScrollEndPolyfill: boolean = true;
76 export const enableSuspenseyImages: boolean = false;
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -59,6 +59,7 @@ export const enableYieldingBeforePassive: boolean = false;
59
60 export const enableThrottledScheduling: boolean = false;
61 export const enableViewTransition: boolean = true;
62 +export const enableViewTransitionParentEnterExit: boolean = false;
63 export const enableViewTransitionForPersistenceMode: boolean = false;
64 export const enableGestureTransition: boolean = false;
65 export const enableScrollEndPolyfill: boolean = true;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -60,6 +60,7 @@ export const enableYieldingBeforePassive: boolean = true;
60
61 export const enableThrottledScheduling: boolean = false;
62 export const enableViewTransition: boolean = true;
63 +export const enableViewTransitionParentEnterExit = __EXPERIMENTAL__;
64 export const enableViewTransitionForPersistenceMode: boolean = false;
65 export const enableGestureTransition: boolean = false;
66 export const enableScrollEndPolyfill: boolean = true;
packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+1
@@ -55,6 +55,7 @@ export const transitionLaneExpirationMs = 5000;
55 export const enableYieldingBeforePassive = false;
56 export const enableThrottledScheduling = false;
57 export const enableViewTransition = true;
58 +export const enableViewTransitionParentEnterExit = false;
59 export const enableViewTransitionForPersistenceMode = false;
60 export const enableGestureTransition = false;
61 export const enableScrollEndPolyfill = true;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -66,6 +66,7 @@ export const enableYieldingBeforePassive: boolean = false;
66
67 export const enableThrottledScheduling: boolean = false;
68 export const enableViewTransition: boolean = true;
69 +export const enableViewTransitionParentEnterExit = __EXPERIMENTAL__;
70 export const enableViewTransitionForPersistenceMode: boolean = false;
71 export const enableGestureTransition: boolean = false;
72 export const enableScrollEndPolyfill: boolean = true;
packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+1
@@ -32,6 +32,7 @@ export const enableInfiniteRenderLoopDetectionForceThrow: boolean = __VARIANT__;
32 export const enableFastAddPropertiesInDiffing: boolean = __VARIANT__;
33 export const enableSuspenseyImages: boolean = __VARIANT__;
34 export const enableViewTransition: boolean = __VARIANT__;
35 +export const enableViewTransitionParentEnterExit: boolean = __VARIANT__;
36 export const enableScrollEndPolyfill: boolean = __VARIANT__;
37 export const enableFragmentRefs: boolean = __VARIANT__;
38 export const enableFragmentRefsScrollIntoView: boolean = __VARIANT__;
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -36,6 +36,7 @@ export const {
36 enableFragmentRefsTextNodes,
37 enableInternalInstanceMap,
38 enableParallelTransitions,
39 + enableViewTransitionParentEnterExit,
40 } = dynamicFeatureFlags;
41
42 // On WWW, __EXPERIMENTAL__ is used for a new modern build.