diff --git a/fixtures/view-transition/src/components/NestedParentExit.css b/fixtures/view-transition/src/components/NestedParentExit.css
new file mode 100644
index 000000000000..a5b71e6c36d5
--- /dev/null
+++ b/fixtures/view-transition/src/components/NestedParentExit.css
@@ -0,0 +1,150 @@
+.nested-parent-exit {
+ width: 280px;
+ min-height: 280px;
+ margin-top: 2rem;
+ padding-top: 1rem;
+ border-top: 1px solid #ccc;
+}
+
+.nested-parent-exit-swipe {
+ min-height: 200px;
+}
+
+.nested-parent-exit-label {
+ margin: 0 0 0.75rem;
+ font-size: 13px;
+ color: #666;
+}
+
+.nested-parent-exit-panel {
+ min-height: 240px;
+}
+
+.nested-parent-exit .feed-item {
+ margin-bottom: 0.75rem;
+ cursor: pointer;
+}
+
+.nested-parent-exit .feed-item-title {
+ margin: 0 0 0.15rem;
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.nested-parent-exit .feed-item p,
+.nested-parent-exit .detail-view p {
+ margin: 0;
+ font-size: 13px;
+ color: #666;
+}
+
+.nested-parent-exit .back-button {
+ margin-bottom: 0.75rem;
+ padding: 0;
+ border: none;
+ background: none;
+ cursor: pointer;
+ font: inherit;
+ color: inherit;
+ text-decoration: underline;
+}
+
+@keyframes nested-exit-left {
+ from {
+ opacity: 1;
+ translate: 0 0;
+ }
+ to {
+ opacity: 0;
+ translate: -120% 0;
+ }
+}
+
+::view-transition-old(.nested-exit-left) {
+ animation: nested-exit-left 450ms ease-out forwards;
+}
+
+@keyframes nested-enter-from-left {
+ from {
+ opacity: 0;
+ translate: -120% 0;
+ }
+ to {
+ opacity: 1;
+ translate: 0 0;
+ }
+}
+
+::view-transition-new(.nested-enter-from-left) {
+ animation: nested-enter-from-left 450ms ease-out 650ms both;
+}
+
+::view-transition-group(.nested-shared-post-forward) {
+ animation-duration: 600ms;
+ animation-delay: 300ms;
+ animation-timing-function: ease-in-out;
+ animation-fill-mode: both;
+}
+
+::view-transition-old(.nested-shared-post-forward),
+::view-transition-new(.nested-shared-post-forward) {
+ animation-delay: 300ms;
+ animation-duration: 600ms;
+ animation-fill-mode: both;
+}
+
+::view-transition-group(.nested-shared-post-back) {
+ animation-duration: 600ms;
+ animation-timing-function: ease-in-out;
+}
+
+::view-transition-group(.nested-shared-inner-forward) {
+ animation-duration: 500ms;
+ animation-delay: 400ms;
+ animation-timing-function: ease-in-out;
+ animation-fill-mode: both;
+}
+
+::view-transition-old(.nested-shared-inner-forward),
+::view-transition-new(.nested-shared-inner-forward) {
+ animation-delay: 400ms;
+ animation-duration: 500ms;
+ animation-fill-mode: both;
+}
+
+::view-transition-group(.nested-shared-inner-back) {
+ animation-duration: 500ms;
+ animation-delay: 100ms;
+ animation-timing-function: ease-in-out;
+ animation-fill-mode: both;
+}
+
+@keyframes nested-back-btn-enter {
+ from {
+ opacity: 0;
+ translate: -20px 0;
+ }
+ to {
+ opacity: 1;
+ translate: 0 0;
+ }
+}
+
+@keyframes nested-back-btn-exit {
+ from {
+ opacity: 1;
+ translate: 0 0;
+ }
+ to {
+ opacity: 0;
+ translate: -20px 0;
+ }
+}
+
+::view-transition-new(.nested-back-btn-enter):only-child {
+ animation: nested-back-btn-enter 300ms ease-out 650ms both;
+}
+
+::view-transition-old(.nested-back-btn-exit):only-child {
+ animation: nested-back-btn-exit 200ms ease-in forwards;
+}
diff --git a/fixtures/view-transition/src/components/NestedParentExit.js b/fixtures/view-transition/src/components/NestedParentExit.js
new file mode 100644
index 000000000000..7dbe2973c747
--- /dev/null
+++ b/fixtures/view-transition/src/components/NestedParentExit.js
@@ -0,0 +1,174 @@
+import React, {
+ ViewTransition,
+ useState,
+ useOptimistic,
+ startTransition,
+ addTransitionType,
+} from 'react';
+import SwipeRecognizer from './SwipeRecognizer.js';
+import './NestedParentExit.css';
+
+const items = [
+ {id: 1, title: 'First Post', body: 'Hello from the first post.'},
+ {id: 2, title: 'Second Post', body: 'Hello from the second post.'},
+ {id: 3, title: 'Third Post', body: 'Hello from the third post.'},
+];
+
+function logGestureParent(kind, title, _timeline, _options, _instance, types) {
+ // eslint-disable-next-line no-console
+ console.log(`[NestedParentExit] onGestureParent${kind}`, title, types);
+}
+
+function FeedItem({item, index, activeIndex, onSelect}) {
+ const isActive = activeIndex === index;
+
+ return (
+
+ logGestureParent('Exit', item.title, ...args)
+ }
+ onGestureParentEnter={(...args) =>
+ logGestureParent('Enter', item.title, ...args)
+ }>
+ onSelect(item, index)}>
+
+ {item.title}
+
+
{item.body}
+
+
+ );
+}
+
+function Detail({item, onBack}) {
+ return (
+
+
+
+
+
+
+ {item.title}
+
+
{item.body}
+
+
+ );
+}
+
+const initialNav = {selected: null, activeIndex: null};
+
+export default function NestedParentExit() {
+ const [nav, setNav] = useState(initialNav);
+ const [optimisticNav, navigateByGesture] = useOptimistic(
+ nav,
+ (state, direction) => {
+ if (direction === 'left' && state.selected === null) {
+ return {selected: items[0], activeIndex: 0};
+ }
+ if (direction === 'right' && state.selected !== null) {
+ return {
+ selected: null,
+ activeIndex:
+ state.activeIndex ??
+ items.findIndex(i => i.id === state.selected.id),
+ };
+ }
+ return state;
+ }
+ );
+
+ const {selected, activeIndex} = optimisticNav;
+
+ function goToDetail(item, index) {
+ setNav({selected: item, activeIndex: index});
+ startTransition(() => {
+ addTransitionType('nav-forward');
+ });
+ }
+
+ function goBack() {
+ const current = selected;
+ if (current == null) {
+ return;
+ }
+ const backIndex = items.findIndex(i => i.id === current.id);
+ setNav({selected: null, activeIndex: backIndex});
+ startTransition(() => {
+ addTransitionType('nav-back');
+ });
+ }
+
+ function swipeAction() {
+ if (nav.selected === null) {
+ goToDetail(items[0], 0);
+ } else {
+ goBack();
+ }
+ }
+
+ return (
+
+
+ Parent Exit/Enter — click a post or swipe (scroll the strip below)
+
+
+
{
+ addTransitionType(
+ direction === 'left' ? 'nav-forward' : 'nav-back'
+ );
+ navigateByGesture(direction);
+ }}
+ direction={selected ? 'right' : 'left'}>
+
+
+ {selected ? (
+
+ ) : (
+ <>
+ {items.map((item, index) => (
+
+ ))}
+ >
+ )}
+
+
+
+
+
+ );
+}
diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js
index 60faa09732d9..37f5624e9470 100644
--- a/fixtures/view-transition/src/components/Page.js
+++ b/fixtures/view-transition/src/components/Page.js
@@ -20,6 +20,7 @@ import './Page.css';
import transitions from './Transitions.module.css';
import NestedReveal from './NestedReveal.js';
+import NestedParentExit from './NestedParentExit.js';
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
@@ -322,6 +323,7 @@ export default function Page({url, navigate}) {
+
);
}
diff --git a/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js b/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js
index a44b3f5235ee..bd50ba2db3a3 100644
--- a/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js
@@ -461,5 +461,478 @@ describe('ReactDOMViewTransition', () => {
onEnter.mock.calls.length + enterCallsAfterFallback,
).toBeGreaterThanOrEqual(1);
});
+
+ // @gate enableViewTransition && enableViewTransitionParentEnterExit
+ it('does not fire onExit/onEnter on nested ViewTransition when the subtree is removed as one unit', async () => {
+ const onParentExit = jest.fn();
+ const onParentEnter = jest.fn();
+ const onNestedExit = jest.fn();
+ const onNestedEnter = jest.fn();
+
+ function App({show}) {
+ if (!show) {
+ return null;
+ }
+ return (
+
+
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ onParentEnter.mockClear();
+ onNestedEnter.mockClear();
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ expect(onParentEnter).toHaveBeenCalledTimes(1);
+ expect(onNestedEnter).not.toHaveBeenCalled();
+
+ onParentExit.mockClear();
+ onNestedExit.mockClear();
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ expect(onParentExit).toHaveBeenCalledTimes(1);
+ expect(onNestedExit).not.toHaveBeenCalled();
+ });
+
+ // @gate enableViewTransition && enableViewTransitionParentEnterExit
+ it('fires onParentExit when ancestor ViewTransition exits', async () => {
+ const onParentExit = jest.fn();
+ const onNestedExit = jest.fn();
+ const onParentExitNested = jest.fn();
+
+ function App({show}) {
+ if (!show) {
+ return null;
+ }
+ return (
+
+
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ onParentExit.mockClear();
+ onNestedExit.mockClear();
+ onParentExitNested.mockClear();
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ expect(onParentExit).toHaveBeenCalledTimes(1);
+ expect(onNestedExit).not.toHaveBeenCalled();
+ expect(onParentExitNested).toHaveBeenCalledTimes(1);
+ });
+
+ // @gate enableViewTransition && enableViewTransitionParentEnterExit
+ it('fires onParentEnter when ancestor ViewTransition enters', async () => {
+ const onParentEnter = jest.fn();
+ const onNestedEnter = jest.fn();
+ const onParentEnterNested = jest.fn();
+
+ function App({show}) {
+ if (!show) {
+ return null;
+ }
+ return (
+
+
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ onParentEnter.mockClear();
+ onNestedEnter.mockClear();
+ onParentEnterNested.mockClear();
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ expect(onParentEnter).toHaveBeenCalledTimes(1);
+ expect(onNestedEnter).not.toHaveBeenCalled();
+ expect(onParentEnterNested).toHaveBeenCalledTimes(1);
+ });
+
+ // @gate enableViewTransition && enableViewTransitionParentEnterExit
+ it('breaks parentExit chain when intermediate ViewTransition lacks parentExit', async () => {
+ const onParentExit1 = jest.fn();
+ const onParentExit2 = jest.fn();
+
+ function App({show}) {
+ if (!show) {
+ return null;
+ }
+ return (
+
+
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ onParentExit1.mockClear();
+ onParentExit2.mockClear();
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ expect(onParentExit1).not.toHaveBeenCalled();
+ expect(onParentExit2).toHaveBeenCalledTimes(1);
+ });
+
+ // @gate enableViewTransition && enableViewTransitionParentEnterExit
+ it('does not fire onParentEnter when ancestor exits', async () => {
+ const onParentEnter = jest.fn();
+
+ function App({show}) {
+ if (!show) {
+ return null;
+ }
+ return (
+
+
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ onParentEnter.mockClear();
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ expect(onParentEnter).not.toHaveBeenCalled();
+ });
+
+ // @gate enableViewTransition && enableViewTransitionParentEnterExit
+ it('does not fire onParentExit when ancestor shares instead of exiting', async () => {
+ const onShare = jest.fn();
+ const onParentExit = jest.fn();
+
+ function App({page}) {
+ if (page === 'a') {
+ return (
+
+
+ Page A
+
+
+ );
+ }
+ return (
+
+
+ Page B
+
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ onShare.mockClear();
+ onParentExit.mockClear();
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ expect(onShare).toHaveBeenCalledTimes(1);
+ expect(onParentExit).not.toHaveBeenCalled();
+ });
+
+ // @gate enableViewTransition && enableViewTransitionParentEnterExit
+ it('does not fire onParentEnter when ancestor shares instead of entering', async () => {
+ const onShare = jest.fn();
+ const onParentEnter = jest.fn();
+
+ function App({page}) {
+ if (page === 'a') {
+ return (
+
+
+ Page A
+
+
+ );
+ }
+ return (
+
+
+ Page B
+
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ onShare.mockClear();
+ onParentEnter.mockClear();
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ expect(onShare).toHaveBeenCalledTimes(1);
+ expect(onParentEnter).not.toHaveBeenCalled();
+ });
+
+ // @gate enableViewTransition && enableViewTransitionParentEnterExit
+ it('does not fire onParentExit when ancestor exit is none', async () => {
+ const onParentExit = jest.fn();
+
+ function App({show}) {
+ if (!show) {
+ return null;
+ }
+ return (
+
+
+ Item
+
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ onParentExit.mockClear();
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ expect(onParentExit).not.toHaveBeenCalled();
+ });
+
+ // @gate enableViewTransition && enableViewTransitionParentEnterExit
+ it('does not fire onParentEnter when ancestor enter is none', async () => {
+ const onParentEnter = jest.fn();
+
+ function App({show}) {
+ if (!show) {
+ return null;
+ }
+ return (
+
+
+ Item
+
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ onParentEnter.mockClear();
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ expect(onParentEnter).not.toHaveBeenCalled();
+ });
+
+ // @gate enableViewTransition && enableViewTransitionParentEnterExit
+ it('relays parentExit chain through unstyled parentExit', async () => {
+ const onParentExit = jest.fn();
+
+ function App({show}) {
+ if (!show) {
+ return null;
+ }
+ return (
+
+
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ onParentExit.mockClear();
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ expect(onParentExit).toHaveBeenCalledTimes(1);
+ });
});
});
diff --git a/packages/react-reconciler/src/ReactFiberApplyGesture.js b/packages/react-reconciler/src/ReactFiberApplyGesture.js
index 32e593958457..558ba4e3bad3 100644
--- a/packages/react-reconciler/src/ReactFiberApplyGesture.js
+++ b/packages/react-reconciler/src/ReactFiberApplyGesture.js
@@ -75,6 +75,7 @@ import {
restoreUpdateViewTransitionForGesture,
appearingViewTransitions,
commitEnterViewTransitions,
+ commitParentExitViewTransitions,
measureNestedViewTransitions,
measureUpdateViewTransition,
viewTransitionCancelableChildren,
@@ -93,6 +94,7 @@ import {
import {
enableProfilerTimer,
enableComponentPerformanceTrack,
+ enableViewTransitionParentEnterExit,
} from 'shared/ReactFeatureFlags';
import {trackAnimatingTask} from './ReactProfilerTimer';
import {scheduleGestureTransitionEvent} from './ReactFiberWorkLoop';
@@ -327,6 +329,9 @@ function applyExitViewTransition(placement: Fiber): void {
scheduleGestureTransitionEvent(placement, props.onGestureShare);
} else {
scheduleGestureTransitionEvent(placement, props.onGestureExit);
+ if (enableViewTransitionParentEnterExit) {
+ commitParentExitViewTransitions(placement, true);
+ }
}
}
}
diff --git a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js
index 760270010dbc..2e816ca527e0 100644
--- a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js
+++ b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js
@@ -21,6 +21,7 @@ import {
NoFlags,
Update,
ViewTransitionStatic,
+ ViewTransitionStaticParent,
AffectedParentLayout,
ViewTransitionNamedStatic,
} from './ReactFiberFlags';
@@ -47,6 +48,7 @@ import {
enableComponentPerformanceTrack,
enableProfilerTimer,
enableViewTransitionForPersistenceMode,
+ enableViewTransitionParentEnterExit,
} from 'shared/ReactFeatureFlags';
export let shouldStartViewTransition: boolean = false;
@@ -324,6 +326,104 @@ function commitAppearingPairViewTransitions(placement: Fiber): void {
}
}
+export function commitParentEnterViewTransitions(
+ parent: Fiber,
+ gesture: boolean,
+): void {
+ let child = parent.child;
+ while (child !== null) {
+ if (child.tag === OffscreenComponent && child.memoizedState !== null) {
+ // Skip hidden subtrees.
+ } else if (child.tag === ViewTransitionComponent) {
+ const props: ViewTransitionProps = child.memoizedProps;
+ if (props.parentEnter !== undefined) {
+ const state: ViewTransitionState = child.stateNode;
+ const name = getViewTransitionName(props, state);
+ const className: ?string = getViewTransitionClassName(
+ props.default,
+ props.parentEnter,
+ );
+ if (className !== 'none') {
+ applyViewTransitionToHostInstances(
+ child,
+ name,
+ className,
+ null,
+ false,
+ );
+ if (gesture) {
+ scheduleGestureTransitionEvent(child, props.onGestureParentEnter);
+ } else {
+ scheduleViewTransitionEvent(child, props.onParentEnter);
+ }
+ }
+ commitParentEnterViewTransitions(child, gesture);
+ }
+ } else if ((child.subtreeFlags & ViewTransitionStaticParent) !== NoFlags) {
+ commitParentEnterViewTransitions(child, gesture);
+ }
+ child = child.sibling;
+ }
+}
+
+export function commitParentExitViewTransitions(
+ parent: Fiber,
+ gesture: boolean,
+): void {
+ let child = parent.child;
+ while (child !== null) {
+ if (child.tag === OffscreenComponent && child.memoizedState !== null) {
+ // Skip hidden subtrees.
+ } else if (child.tag === ViewTransitionComponent) {
+ const props: ViewTransitionProps = child.memoizedProps;
+ if (props.parentExit !== undefined) {
+ const state: ViewTransitionState = child.stateNode;
+ const name = getViewTransitionName(props, state);
+ const className: ?string = getViewTransitionClassName(
+ props.default,
+ props.parentExit,
+ );
+ if (className !== 'none') {
+ applyViewTransitionToHostInstances(
+ child,
+ name,
+ className,
+ null,
+ false,
+ );
+ if (gesture) {
+ scheduleGestureTransitionEvent(child, props.onGestureParentExit);
+ } else {
+ scheduleViewTransitionEvent(child, props.onParentExit);
+ }
+ }
+ commitParentExitViewTransitions(child, gesture);
+ }
+ } else if ((child.subtreeFlags & ViewTransitionStaticParent) !== NoFlags) {
+ commitParentExitViewTransitions(child, gesture);
+ }
+ child = child.sibling;
+ }
+}
+
+function restoreParentEnterOrExitViewTransitions(parent: Fiber): void {
+ let child = parent.child;
+ while (child !== null) {
+ if (child.tag === OffscreenComponent && child.memoizedState !== null) {
+ // Skip hidden subtrees.
+ } else if (child.tag === ViewTransitionComponent) {
+ const props: ViewTransitionProps = child.memoizedProps;
+ if (props.parentEnter !== undefined || props.parentExit !== undefined) {
+ restoreViewTransitionOnHostInstances(child.child, false);
+ restoreParentEnterOrExitViewTransitions(child);
+ }
+ } else if ((child.subtreeFlags & ViewTransitionStaticParent) !== NoFlags) {
+ restoreParentEnterOrExitViewTransitions(child);
+ }
+ child = child.sibling;
+ }
+}
+
export function commitEnterViewTransitions(
placement: Fiber,
gesture: boolean,
@@ -359,6 +459,9 @@ export function commitEnterViewTransitions(
} else {
scheduleViewTransitionEvent(placement, props.onEnter);
}
+ if (enableViewTransitionParentEnterExit) {
+ commitParentEnterViewTransitions(placement, gesture);
+ }
}
}
} else {
@@ -487,6 +590,9 @@ export function commitExitViewTransitions(deletion: Fiber): void {
scheduleViewTransitionEvent(deletion, props.onShare);
} else {
scheduleViewTransitionEvent(deletion, props.onExit);
+ if (enableViewTransitionParentEnterExit) {
+ commitParentExitViewTransitions(deletion, false);
+ }
}
}
if (appearingViewTransitions !== null) {
@@ -617,6 +723,9 @@ export function restoreEnterOrExitViewTransitions(fiber: Fiber): void {
const instance: ViewTransitionState = fiber.stateNode;
instance.paired = null;
restoreViewTransitionOnHostInstances(fiber.child, false);
+ if (enableViewTransitionParentEnterExit) {
+ restoreParentEnterOrExitViewTransitions(fiber);
+ }
restorePairedViewTransitions(fiber);
} else if ((fiber.subtreeFlags & ViewTransitionStatic) !== NoFlags) {
let child = fiber.child;
diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js
index 6fc4297e1b33..acb1551b455d 100644
--- a/packages/react-reconciler/src/ReactFiberCompleteWork.js
+++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js
@@ -40,6 +40,7 @@ import {
passChildrenWhenCloningPersistedNodes,
disableLegacyMode,
enableViewTransition,
+ enableViewTransitionParentEnterExit,
enableSuspenseyImages,
} from 'shared/ReactFeatureFlags';
@@ -98,6 +99,7 @@ import {
ShouldSuspendCommit,
Cloned,
ViewTransitionStatic,
+ ViewTransitionStaticParent,
Hydrate,
PortalStatic,
} from './ReactFiberFlags';
@@ -2060,6 +2062,17 @@ function completeWork(
// bubble up to the parent tree to indicate that there's a child that
// might need an exit View Transition upon unmount.
workInProgress.flags |= ViewTransitionStatic;
+ if (enableViewTransitionParentEnterExit) {
+ const props = workInProgress.pendingProps;
+ if (
+ props.parentEnter !== undefined ||
+ props.parentExit !== undefined
+ ) {
+ workInProgress.flags |= ViewTransitionStaticParent;
+ } else {
+ workInProgress.flags &= ~ViewTransitionStaticParent;
+ }
+ }
bubbleProperties(workInProgress);
}
return null;
diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js
index 9f85897fb05c..859ea1fd594b 100644
--- a/packages/react-reconciler/src/ReactFiberFlags.js
+++ b/packages/react-reconciler/src/ReactFiberFlags.js
@@ -83,6 +83,10 @@ export const ViewTransitionNamedStatic =
// ViewTransitionStatic tracks whether there are an ViewTransition components from
// the nearest HostComponent down. It resets at every HostComponent level.
export const ViewTransitionStatic = /* */ 0b0000010000000000000000000000000;
+// ViewTransitionStaticParent tracks whether there are ViewTransition components
+// with parentEnter/parentExit props. Unlike ViewTransitionStatic, this is NOT
+// cleared by HostComponents so it can be used to skip subtrees in parent walks.
+export const ViewTransitionStaticParent = /* */ 0b1000000000000000000000000000000;
// Tracks whether a HostPortal is present in the tree.
export const PortalStatic = /* */ 0b0000100000000000000000000000000;
@@ -140,6 +144,7 @@ export const StaticMask =
RefStatic |
MaySuspendCommit |
ViewTransitionStatic |
+ ViewTransitionStaticParent |
ViewTransitionNamedStatic |
PortalStatic |
Forked;
diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js
index 5d2d6efaa7df..d6c3bf25a47c 100644
--- a/packages/shared/ReactFeatureFlags.js
+++ b/packages/shared/ReactFeatureFlags.js
@@ -80,6 +80,8 @@ export const enableTaint = __EXPERIMENTAL__;
export const enableViewTransition: boolean = true;
+export const enableViewTransitionParentEnterExit = __EXPERIMENTAL__;
+
export const enableViewTransitionForPersistenceMode: boolean = false;
export const enableGestureTransition = __EXPERIMENTAL__;
diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js
index 032d70c3ce36..22f6d42421f5 100644
--- a/packages/shared/ReactTypes.js
+++ b/packages/shared/ReactTypes.js
@@ -313,6 +313,8 @@ export type ViewTransitionProps = {
exit?: ViewTransitionClass,
share?: ViewTransitionClass,
update?: ViewTransitionClass,
+ parentEnter?: ViewTransitionClass,
+ parentExit?: ViewTransitionClass,
onEnter?: (
instance: ViewTransitionInstance,
types: Array,
@@ -321,6 +323,14 @@ export type ViewTransitionProps = {
instance: ViewTransitionInstance,
types: Array,
) => void | (() => void),
+ onParentEnter?: (
+ instance: ViewTransitionInstance,
+ types: Array,
+ ) => void | (() => void),
+ onParentExit?: (
+ instance: ViewTransitionInstance,
+ types: Array,
+ ) => void | (() => void),
onShare?: (
instance: ViewTransitionInstance,
types: Array,
@@ -341,6 +351,18 @@ export type ViewTransitionProps = {
instance: ViewTransitionInstance,
types: Array,
) => void | (() => void),
+ onGestureParentEnter?: (
+ timeline: GestureProvider,
+ options: GestureOptionsRequired,
+ instance: ViewTransitionInstance,
+ types: Array,
+ ) => void | (() => void),
+ onGestureParentExit?: (
+ timeline: GestureProvider,
+ options: GestureOptionsRequired,
+ instance: ViewTransitionInstance,
+ types: Array,
+ ) => void | (() => void),
onGestureShare?: (
timeline: GestureProvider,
options: GestureOptionsRequired,
diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js
index 96d13e9ec461..a8a357265d2a 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-fb.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js
@@ -71,6 +71,7 @@ export const transitionLaneExpirationMs = 5000;
export const enableYieldingBeforePassive: boolean = false;
export const enableThrottledScheduling: boolean = false;
export const enableViewTransition: boolean = true;
+export const enableViewTransitionParentEnterExit: boolean = true;
export const enableGestureTransition: boolean = false;
export const enableScrollEndPolyfill: boolean = true;
export const enableSuspenseyImages: boolean = false;
diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js
index 3c452162ab98..19cba62427ba 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-oss.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js
@@ -60,6 +60,7 @@ export const enableYieldingBeforePassive: boolean = false;
export const enableThrottledScheduling: boolean = false;
export const enableViewTransition: boolean = true;
+export const enableViewTransitionParentEnterExit: boolean = false;
export const enableViewTransitionForPersistenceMode: boolean = false;
export const enableGestureTransition: boolean = false;
export const enableScrollEndPolyfill: boolean = true;
diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js
index 309de96b4951..9dcfe483c7f7 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js
@@ -61,6 +61,7 @@ export const enableYieldingBeforePassive: boolean = true;
export const enableThrottledScheduling: boolean = false;
export const enableViewTransition: boolean = true;
+export const enableViewTransitionParentEnterExit = __EXPERIMENTAL__;
export const enableViewTransitionForPersistenceMode: boolean = false;
export const enableGestureTransition: boolean = false;
export const enableScrollEndPolyfill: boolean = true;
diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
index af8dd955d0ba..622255565463 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
@@ -56,6 +56,7 @@ export const transitionLaneExpirationMs = 5000;
export const enableYieldingBeforePassive = false;
export const enableThrottledScheduling = false;
export const enableViewTransition = true;
+export const enableViewTransitionParentEnterExit = false;
export const enableViewTransitionForPersistenceMode = false;
export const enableGestureTransition = false;
export const enableScrollEndPolyfill = true;
diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
index e57495ed4e53..2a737242d49d 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
@@ -67,6 +67,7 @@ export const enableYieldingBeforePassive: boolean = false;
export const enableThrottledScheduling: boolean = false;
export const enableViewTransition: boolean = true;
+export const enableViewTransitionParentEnterExit = __EXPERIMENTAL__;
export const enableViewTransitionForPersistenceMode: boolean = false;
export const enableGestureTransition: boolean = false;
export const enableScrollEndPolyfill: boolean = true;
diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js
index d3340f6eb940..7b3ce7f31cf8 100644
--- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js
@@ -32,6 +32,7 @@ export const enableInfiniteRenderLoopDetectionForceThrow: boolean = __VARIANT__;
export const enableFastAddPropertiesInDiffing: boolean = __VARIANT__;
export const enableSuspenseyImages: boolean = __VARIANT__;
export const enableViewTransition: boolean = __VARIANT__;
+export const enableViewTransitionParentEnterExit: boolean = __VARIANT__;
export const enableScrollEndPolyfill: boolean = __VARIANT__;
export const enableFragmentRefs: boolean = __VARIANT__;
export const enableFragmentRefsScrollIntoView: boolean = __VARIANT__;
diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js
index 062cbaa4268a..d080724d4672 100644
--- a/packages/shared/forks/ReactFeatureFlags.www.js
+++ b/packages/shared/forks/ReactFeatureFlags.www.js
@@ -100,6 +100,7 @@ export const disableLegacyMode: boolean = true;
export const enableEagerAlternateStateNodeCleanup: boolean = true;
+export const enableViewTransitionParentEnterExit: boolean = false;
export const enableViewTransitionForPersistenceMode: boolean = false;
export const enableGestureTransition: boolean = false;