diff --git a/fixtures/view-transition/server/index.js b/fixtures/view-transition/server/index.js
index e13d4706b9ef..344463d56775 100644
--- a/fixtures/view-transition/server/index.js
+++ b/fixtures/view-transition/server/index.js
@@ -20,12 +20,14 @@ if (process.env.NODE_ENV === 'development') {
for (var key in require.cache) {
delete require.cache[key];
}
- import('./render.js').then(({default: render}) => {
+ import('./render.js').then(mod => {
+ const render = mod.default.__esModule ? mod.default.default : mod.default;
render(req.url, res);
});
});
} else {
- import('./render.js').then(({default: render}) => {
+ import('./render.js').then(mod => {
+ const render = mod.default.__esModule ? mod.default.default : mod.default;
app.get('/', function (req, res) {
render(req.url, res);
});
diff --git a/fixtures/view-transition/src/components/NestedExit.css b/fixtures/view-transition/src/components/NestedExit.css
new file mode 100644
index 000000000000..a5c0157b56e4
--- /dev/null
+++ b/fixtures/view-transition/src/components/NestedExit.css
@@ -0,0 +1,238 @@
+.nested-exit-demo {
+ width: 300px;
+ min-height: 280px;
+ background: #f5f5f5;
+ border-radius: 10px;
+ padding: 20px;
+ margin-top: 20px;
+}
+
+.feed-item {
+ background: #fff;
+ border-radius: 8px;
+ padding: 12px 16px;
+ margin-bottom: 8px;
+ cursor: pointer;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.feed-item:hover {
+ background: #f0f0f0;
+}
+
+.feed-item h3 {
+ margin: 0 0 4px;
+ font-size: 16px;
+}
+
+.feed-item p {
+ margin: 0;
+ font-size: 13px;
+ color: #666;
+}
+
+.detail-view {
+ background: #fff;
+ border-radius: 8px;
+ padding: 16px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.detail-view h3 {
+ margin: 0 0 4px;
+ font-size: 16px;
+}
+
+.detail-view p {
+ margin: 0;
+ font-size: 13px;
+ color: #666;
+}
+
+.back-button {
+ background: none;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ padding: 6px 12px;
+ cursor: pointer;
+}
+
+/* Directional exit: posts above go up, posts below go down */
+@keyframes nested-exit-up {
+ from {
+ opacity: 1;
+ translate: 0 0;
+ }
+ to {
+ opacity: 0;
+ translate: 0 -60px;
+ }
+}
+
+@keyframes nested-exit-down {
+ from {
+ opacity: 1;
+ translate: 0 0;
+ }
+ to {
+ opacity: 0;
+ translate: 0 60px;
+ }
+}
+
+::view-transition-old(.nested-exit-up):only-child {
+ animation: nested-exit-up 600ms ease-out forwards;
+}
+
+::view-transition-old(.nested-exit-down):only-child {
+ animation: nested-exit-down 600ms ease-out forwards;
+}
+
+/* Forward shared: delayed until exits finish */
+::view-transition-group(.nested-shared-post-forward) {
+ animation-duration: 700ms;
+ animation-delay: 300ms;
+ animation-timing-function: ease-in-out;
+ animation-fill-mode: both;
+}
+
+::view-transition-old(.nested-shared-post-forward) {
+ animation-delay: 300ms;
+ animation-duration: 700ms;
+ animation-fill-mode: both;
+}
+
+::view-transition-new(.nested-shared-post-forward) {
+ animation-delay: 300ms;
+ animation-duration: 700ms;
+ animation-fill-mode: both;
+}
+
+/* Back shared: starts immediately, then items enter after */
+::view-transition-group(.nested-shared-post-back) {
+ animation-duration: 700ms;
+ animation-timing-function: ease-in-out;
+}
+
+/* Inner shared elements (title, body) start after card begins growing */
+::view-transition-group(.nested-shared-inner-forward) {
+ animation-duration: 600ms;
+ animation-delay: 450ms;
+ animation-timing-function: ease-in-out;
+ animation-fill-mode: both;
+}
+
+::view-transition-old(.nested-shared-inner-forward) {
+ animation-delay: 450ms;
+ animation-duration: 600ms;
+ animation-fill-mode: both;
+}
+
+::view-transition-new(.nested-shared-inner-forward) {
+ animation-delay: 450ms;
+ animation-duration: 600ms;
+ animation-fill-mode: both;
+}
+
+::view-transition-group(.nested-shared-inner-back) {
+ animation-duration: 600ms;
+ animation-delay: 100ms;
+ animation-timing-function: ease-in-out;
+ animation-fill-mode: both;
+}
+
+/* Back button slides in from left */
+@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 700ms both;
+}
+
+::view-transition-old(.nested-back-btn-exit):only-child {
+ animation: nested-back-btn-exit 200ms ease-in forwards;
+}
+
+/* Extra detail content fades in/out */
+@keyframes nested-extra-enter {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+::view-transition-new(.nested-extra-enter):only-child {
+ animation: nested-extra-enter 300ms ease-out 700ms both;
+}
+
+::view-transition-old(.nested-extra-exit):only-child {
+ animation: nested-extra-enter 200ms ease-in reverse forwards;
+}
+
+/* Directional enter: items fly back in after shared transition finishes */
+@keyframes nested-enter-from-up {
+ from {
+ opacity: 0;
+ translate: 0 -60px;
+ }
+ to {
+ opacity: 1;
+ translate: 0 0;
+ }
+}
+
+@keyframes nested-enter-from-down {
+ from {
+ opacity: 0;
+ translate: 0 60px;
+ }
+ to {
+ opacity: 1;
+ translate: 0 0;
+ }
+}
+
+::view-transition-new(.nested-enter-from-up):only-child {
+ animation: nested-enter-from-up 600ms ease-out 700ms both;
+}
+
+::view-transition-new(.nested-enter-from-down):only-child {
+ animation: nested-enter-from-down 600ms ease-out 700ms both;
+}
+
+/* Enter animation for detail view (when no shared match) */
+@keyframes nested-enter-detail {
+ from {
+ opacity: 0;
+ translate: 0 30px;
+ }
+ to {
+ opacity: 1;
+ translate: 0 0;
+ }
+}
+
+::view-transition-new(.nested-enter-detail):only-child {
+ animation: nested-enter-detail 600ms ease-out both;
+}
diff --git a/fixtures/view-transition/src/components/NestedExit.js b/fixtures/view-transition/src/components/NestedExit.js
new file mode 100644
index 000000000000..acf6ddbecaa6
--- /dev/null
+++ b/fixtures/view-transition/src/components/NestedExit.js
@@ -0,0 +1,136 @@
+import React, {
+ ViewTransition,
+ useState,
+ startTransition,
+ addTransitionType,
+} from 'react';
+
+import './NestedExit.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 FeedItem({item, index, onSelect}) {
+ // Build exit/enter maps: for each possible clicked item, determine direction
+ const exitMap = {};
+ const enterMap = {};
+ items.forEach((_, otherIndex) => {
+ if (otherIndex !== index) {
+ const key = 'select-' + otherIndex;
+ exitMap[key] = index < otherIndex ? 'nested-exit-up' : 'nested-exit-down';
+ enterMap[key] =
+ index < otherIndex ? 'nested-enter-from-up' : 'nested-enter-from-down';
+ }
+ });
+
+ const shareInner = {
+ 'nav-forward': 'nested-shared-inner-forward',
+ 'nav-back': 'nested-shared-inner-back',
+ };
+
+ return (
+
+ onSelect(item, index)}>
+
+ {item.title}
+
+
+ {item.body}
+
+
+
+ );
+}
+
+function Detail({item, onBack}) {
+ const shareInner = {
+ 'nav-forward': 'nested-shared-inner-forward',
+ 'nav-back': 'nested-shared-inner-back',
+ };
+
+ return (
+
+
+
+
+
+
+ {item.title}
+
+
+ {item.body}
+
+
+ This is the detail view with more content.
+
+
+
+ );
+}
+
+export default function NestedExit() {
+ const [selected, setSelected] = useState(null);
+
+ function selectItem(item, clickedIndex) {
+ startTransition(() => {
+ addTransitionType('permalink-navigation');
+ addTransitionType('nav-forward');
+ addTransitionType('select-' + clickedIndex);
+ setSelected(item);
+ });
+ }
+
+ function goBack() {
+ const backIndex = items.findIndex(i => i.id === selected.id);
+ startTransition(() => {
+ addTransitionType('permalink-navigation');
+ addTransitionType('nav-back');
+ addTransitionType('select-' + backIndex);
+ setSelected(null);
+ });
+ }
+
+ return (
+
+
Nested Exit/Enter
+
+ {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..9d5e9ffe90ba 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 NestedExit from './NestedExit.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/fixtures/view-transition/src/components/SwipeRecognizer.js b/fixtures/view-transition/src/components/SwipeRecognizer.js
index 6d6281102371..4c284b4b79ba 100644
--- a/fixtures/view-transition/src/components/SwipeRecognizer.js
+++ b/fixtures/view-transition/src/components/SwipeRecognizer.js
@@ -5,8 +5,16 @@ import React, {
unstable_startGestureTransition as startGestureTransition,
} from 'react';
-import ScrollTimelinePolyfill from 'animation-timelines/scroll-timeline';
-import TouchPanTimeline from 'animation-timelines/touch-pan-timeline';
+// These are ESM-only packages. We use a conditional require to avoid
+// require() of ESM errors during SSR with @babel/register.
+// On the client, webpack handles the bundling and supports require of ESM.
+let ScrollTimelinePolyfill;
+let TouchPanTimeline;
+if (typeof document !== 'undefined') {
+ ScrollTimelinePolyfill =
+ require('animation-timelines/scroll-timeline').default;
+ TouchPanTimeline = require('animation-timelines/touch-pan-timeline').default;
+}
const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent;
const isSafariMobile =
diff --git a/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js b/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js
index 1c5b43a18acd..f7ac6ca799c0 100644
--- a/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js
@@ -17,6 +17,8 @@ let ViewTransition;
let act;
let assertLog;
let Scheduler;
+let startTransition;
+let addTransitionType;
let textCache;
describe('ReactDOMViewTransition', () => {
@@ -31,6 +33,8 @@ describe('ReactDOMViewTransition', () => {
assertLog = require('internal-test-utils').assertLog;
Suspense = React.Suspense;
ViewTransition = React.ViewTransition;
+ startTransition = React.startTransition;
+ addTransitionType = React.addTransitionType;
if (gate(flags => flags.enableSuspenseList)) {
SuspenseList = React.unstable_SuspenseList;
}
@@ -176,4 +180,480 @@ describe('ReactDOMViewTransition', () => {
expect(container.textContent).toContain('Card 2');
expect(container.textContent).toContain('Card 3');
});
+
+ describe('ViewTransition event callbacks', () => {
+ let originalGetBoundingClientRect;
+ let originalGetAnimations;
+ let originalAnimate;
+ let originalStartViewTransition;
+
+ beforeEach(() => {
+ // Save originals
+ originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
+ originalGetAnimations = Element.prototype.getAnimations;
+ originalAnimate = Element.prototype.animate;
+ originalStartViewTransition = document.startViewTransition;
+
+ // Mock CSS.escape if it doesn't exist
+ if (typeof CSS === 'undefined') {
+ global.CSS = {escape: str => str};
+ } else if (!CSS.escape) {
+ CSS.escape = str => str;
+ }
+
+ // Mock document.fonts
+ if (!document.fonts) {
+ Object.defineProperty(document, 'fonts', {
+ value: {status: 'loaded', ready: Promise.resolve()},
+ configurable: true,
+ });
+ }
+
+ // Mock getAnimations on Element.prototype (Web Animations API)
+ Element.prototype.getAnimations = function () {
+ return [];
+ };
+
+ // Mock animate on Element.prototype (Web Animations API)
+ Element.prototype.animate = function () {
+ return {cancel() {}, finished: Promise.resolve()};
+ };
+
+ // Mock getBoundingClientRect to return content-length-based sizes
+ // so that hasInstanceChanged can detect updates when text changes.
+ Element.prototype.getBoundingClientRect = function () {
+ const text = this.textContent || '';
+ const width = text.length * 10 + 10;
+ const height = 20;
+ return new DOMRect(0, 0, width, height);
+ };
+
+ // Mock document.startViewTransition
+ document.startViewTransition = function ({update}) {
+ update();
+ return {
+ ready: Promise.resolve(),
+ finished: Promise.resolve(),
+ skipTransition() {},
+ };
+ };
+ });
+
+ afterEach(() => {
+ Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
+ Element.prototype.getAnimations = originalGetAnimations;
+ Element.prototype.animate = originalAnimate;
+ if (originalStartViewTransition) {
+ document.startViewTransition = originalStartViewTransition;
+ } else {
+ delete document.startViewTransition;
+ }
+ });
+
+ // @gate enableViewTransition
+ it('fires onEnter when a ViewTransition mounts', async () => {
+ const onEnter = jest.fn();
+ const startViewTransitionSpy = jest.fn(document.startViewTransition);
+ document.startViewTransition = startViewTransitionSpy;
+
+ function App({show}) {
+ if (!show) {
+ return null;
+ }
+ return (
+
+ Hello
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ // Initial render without the ViewTransition
+ await act(() => {
+ root.render();
+ });
+ expect(onEnter).not.toHaveBeenCalled();
+ expect(startViewTransitionSpy).not.toHaveBeenCalled();
+
+ // Mount the ViewTransition inside startTransition
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ expect(startViewTransitionSpy).toHaveBeenCalled();
+ expect(onEnter).toHaveBeenCalledTimes(1);
+ });
+
+ // @gate enableViewTransition
+ it('fires onExit when a ViewTransition unmounts', async () => {
+ const onExit = jest.fn();
+
+ function App({show}) {
+ if (!show) {
+ return null;
+ }
+ return (
+
+ Goodbye
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ // Initial render with the ViewTransition
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+ expect(onExit).not.toHaveBeenCalled();
+
+ // Unmount the ViewTransition inside startTransition
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ // @gate enableViewTransition
+ it('fires onUpdate when content inside a ViewTransition changes', async () => {
+ const onUpdate = jest.fn();
+ const onEnter = jest.fn();
+
+ function App({text}) {
+ return (
+
+ {text}
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ // Initial render
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ onEnter.mockClear();
+ expect(onUpdate).not.toHaveBeenCalled();
+
+ // Update content inside startTransition (different text length
+ // produces different getBoundingClientRect values in our mock)
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ // onEnter should NOT fire on an update
+ expect(onEnter).not.toHaveBeenCalled();
+ });
+
+ // @gate enableViewTransition
+ it('fires onShare for paired named transitions instead of onEnter/onExit', async () => {
+ const onShareA = jest.fn();
+ const onExitA = jest.fn();
+ const onShareB = jest.fn();
+ const onEnterB = jest.fn();
+
+ function App({page}) {
+ if (page === 'a') {
+ return (
+
+ Page A
+
+ );
+ }
+ return (
+
+ Page B
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ // Render page A
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ // Clear any enter callbacks from initial mount
+ onShareA.mockClear();
+ onExitA.mockClear();
+ onShareB.mockClear();
+ onEnterB.mockClear();
+
+ // Switch from page A to page B inside startTransition
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ // onShare should fire on the exiting side (page A)
+ expect(onShareA).toHaveBeenCalledTimes(1);
+ // onExit should NOT fire when share takes precedence
+ expect(onExitA).not.toHaveBeenCalled();
+ // onEnter should NOT fire on the entering side when paired
+ expect(onEnterB).not.toHaveBeenCalled();
+ });
+
+ // @gate enableViewTransition
+ it('fires onEnter when Suspense content resolves', async () => {
+ const onEnter = jest.fn();
+
+ function App() {
+ return (
+
+ Loading...}>
+
+
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ // Initial render - content suspends
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ assertLog(['Suspend! [Loaded]', 'Suspend! [Loaded]']);
+ // onEnter fires for the fallback appearing
+ const enterCallsAfterFallback = onEnter.mock.calls.length;
+ onEnter.mockClear();
+
+ // Resolve the suspended content
+ await act(() => {
+ resolveText('Loaded');
+ });
+ assertLog(['Loaded']);
+
+ expect(container.textContent).toBe('Loaded');
+ // The reveal of the resolved content should trigger enter
+ // (or it may have triggered on the initial fallback mount)
+ expect(
+ onEnter.mock.calls.length + enterCallsAfterFallback,
+ ).toBeGreaterThanOrEqual(1);
+ });
+
+ // @gate enableViewTransition
+ it('does not fire onExit/onEnter for nested ViewTransitions without type match', async () => {
+ const onOuterExit = jest.fn();
+ const onNestedExit = jest.fn();
+ const onOuterEnter = jest.fn();
+ const onNestedEnter = jest.fn();
+
+ function App({page}) {
+ if (page === 'feed') {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ // Render feed page
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ // Clear initial callbacks
+ onOuterExit.mockClear();
+ onNestedExit.mockClear();
+ onOuterEnter.mockClear();
+ onNestedEnter.mockClear();
+
+ // Switch to details page
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ // Outer VT exit fires
+ expect(onOuterExit).toHaveBeenCalledTimes(1);
+ // Nested VT exit does NOT fire
+ expect(onNestedExit).not.toHaveBeenCalled();
+ // Outer VT enter fires
+ expect(onOuterEnter).toHaveBeenCalledTimes(1);
+ // Nested VT enter does NOT fire
+ expect(onNestedEnter).not.toHaveBeenCalled();
+ });
+
+ // @gate enableViewTransition && enableViewTransitionNested
+ it('fires nested onExit/onEnter only on transition type match', async () => {
+ const onOuterExit = jest.fn();
+ const onNestedExit = jest.fn();
+ const onOuterEnter = jest.fn();
+ const onNestedEnter = jest.fn();
+ const onStringExit = jest.fn();
+ const onStringEnter = jest.fn();
+
+ function App({page}) {
+ if (page === 'feed') {
+ return (
+
+
+
+ Item 1
+
+
+ Item 2
+
+
+
+ );
+ }
+ return (
+
+
+
+ Detail Item
+
+
+ Detail Item 2
+
+
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ // Render feed page
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ // Clear initial callbacks
+ onOuterExit.mockClear();
+ onNestedExit.mockClear();
+ onOuterEnter.mockClear();
+ onNestedEnter.mockClear();
+ onStringExit.mockClear();
+ onStringEnter.mockClear();
+
+ // Switch to details page with 'nav' transition type
+ await act(() => {
+ startTransition(() => {
+ addTransitionType('nav');
+ root.render();
+ });
+ });
+
+ // Outer VT exit fires (top-level always fires)
+ expect(onOuterExit).toHaveBeenCalledTimes(1);
+ // Nested VT with type match fires
+ expect(onNestedExit).toHaveBeenCalledTimes(1);
+ // Nested VT with plain string exit does NOT fire (no type match)
+ expect(onStringExit).not.toHaveBeenCalled();
+ // Outer VT enter fires (top-level always fires)
+ expect(onOuterEnter).toHaveBeenCalledTimes(1);
+ // Nested VT with type match fires
+ expect(onNestedEnter).toHaveBeenCalledTimes(1);
+ // Nested VT with plain string enter does NOT fire (no type match)
+ expect(onStringEnter).not.toHaveBeenCalled();
+ });
+
+ // @gate enableViewTransition && enableViewTransitionNested
+ it('nested exit respects transition type filtering', async () => {
+ const onNestedExit1 = jest.fn();
+ const onNestedExit2 = jest.fn();
+
+ function App({page}) {
+ if (page === 'feed') {
+ return (
+
+
+
+ Item 1
+
+
+ Item 2
+
+
+
+ );
+ }
+ return (
+
+ Details
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+
+ await act(() => {
+ startTransition(() => {
+ root.render();
+ });
+ });
+
+ // Switch with 'nav' transition type
+ await act(() => {
+ startTransition(() => {
+ addTransitionType('nav');
+ root.render();
+ });
+ });
+
+ // First nested VT matches 'nav' type, so exit fires
+ expect(onNestedExit1).toHaveBeenCalledTimes(1);
+ // Second nested VT matches 'other' type (not active), default is 'none', so exit does NOT fire
+ expect(onNestedExit2).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js
index 760270010dbc..207d52ec2e15 100644
--- a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js
+++ b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js
@@ -7,7 +7,7 @@
* @flow
*/
-import type {ViewTransitionProps} from 'shared/ReactTypes';
+import type {ViewTransitionClass, ViewTransitionProps} from 'shared/ReactTypes';
import type {Instance, InstanceMeasurement, Props} from './ReactFiberConfig';
import type {Fiber} from './ReactInternalTypes';
import type {ViewTransitionState} from './ReactFiberViewTransitionComponent';
@@ -21,6 +21,7 @@ import {
NoFlags,
Update,
ViewTransitionStatic,
+ ViewTransitionStaticNested,
AffectedParentLayout,
ViewTransitionNamedStatic,
} from './ReactFiberFlags';
@@ -37,6 +38,7 @@ import {
import {
scheduleViewTransitionEvent,
scheduleGestureTransitionEvent,
+ getPendingTransitionTypes,
} from './ReactFiberWorkLoop';
import {
getViewTransitionName,
@@ -47,6 +49,7 @@ import {
enableComponentPerformanceTrack,
enableProfilerTimer,
enableViewTransitionForPersistenceMode,
+ enableViewTransitionNested,
} from 'shared/ReactFeatureFlags';
export let shouldStartViewTransition: boolean = false;
@@ -324,6 +327,46 @@ function commitAppearingPairViewTransitions(placement: Fiber): void {
}
}
+function commitNestedEnterViewTransitions(
+ 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 state: ViewTransitionState = child.stateNode;
+ const props: ViewTransitionProps = child.memoizedProps;
+ if (!state.paired && hasTransitionTypeMatch(props.enter)) {
+ const name = getViewTransitionName(props, state);
+ const className: ?string = getViewTransitionClassName(
+ props.default,
+ props.enter,
+ );
+ if (className !== 'none') {
+ applyViewTransitionToHostInstances(
+ child,
+ name,
+ className,
+ null,
+ false,
+ );
+ if (gesture) {
+ scheduleGestureTransitionEvent(child, props.onGestureEnter);
+ } else {
+ scheduleViewTransitionEvent(child, props.onEnter);
+ }
+ }
+ }
+ commitNestedEnterViewTransitions(child, gesture);
+ } else if ((child.subtreeFlags & ViewTransitionStaticNested) !== NoFlags) {
+ commitNestedEnterViewTransitions(child, gesture);
+ }
+ child = child.sibling;
+ }
+}
+
export function commitEnterViewTransitions(
placement: Fiber,
gesture: boolean,
@@ -364,6 +407,9 @@ export function commitEnterViewTransitions(
} else {
commitAppearingPairViewTransitions(placement);
}
+ if (enableViewTransitionNested) {
+ commitNestedEnterViewTransitions(placement, gesture);
+ }
} else if ((placement.subtreeFlags & ViewTransitionStatic) !== NoFlags) {
let child = placement.child;
while (child !== null) {
@@ -446,6 +492,57 @@ function commitDeletedPairViewTransitions(deletion: Fiber): void {
}
}
+// Check if a ViewTransitionClass is a per-type object and has at least one
+// active transition type that matches a key in the object. This is used to
+// determine whether nested ViewTransitions should fire exit/enter animations.
+function hasTransitionTypeMatch(classByType: ?ViewTransitionClass): boolean {
+ if (classByType == null || typeof classByType === 'string') {
+ return false;
+ }
+ const activeTypes = getPendingTransitionTypes();
+ if (activeTypes !== null) {
+ for (let i = 0; i < activeTypes.length; i++) {
+ if (classByType[activeTypes[i]] != null) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+function commitNestedExitViewTransitions(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 state: ViewTransitionState = child.stateNode;
+ const props: ViewTransitionProps = child.memoizedProps;
+ if (!state.paired && hasTransitionTypeMatch(props.exit)) {
+ const name = getViewTransitionName(props, state);
+ const className: ?string = getViewTransitionClassName(
+ props.default,
+ props.exit,
+ );
+ if (className !== 'none') {
+ applyViewTransitionToHostInstances(
+ child,
+ name,
+ className,
+ null,
+ false,
+ );
+ scheduleViewTransitionEvent(child, props.onExit);
+ }
+ }
+ commitNestedExitViewTransitions(child);
+ } else if ((child.subtreeFlags & ViewTransitionStaticNested) !== NoFlags) {
+ commitNestedExitViewTransitions(child);
+ }
+ child = child.sibling;
+ }
+}
+
export function commitExitViewTransitions(deletion: Fiber): void {
if (deletion.tag === ViewTransitionComponent) {
const props: ViewTransitionProps = deletion.memoizedProps;
@@ -493,6 +590,9 @@ export function commitExitViewTransitions(deletion: Fiber): void {
// Look for more pairs deeper in the tree.
commitDeletedPairViewTransitions(deletion);
}
+ if (enableViewTransitionNested) {
+ commitNestedExitViewTransitions(deletion);
+ }
} else if ((deletion.subtreeFlags & ViewTransitionStatic) !== NoFlags) {
let child = deletion.child;
while (child !== null) {
diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js
index 9b8c4a21bd8a..1b73d6073739 100644
--- a/packages/react-reconciler/src/ReactFiberCompleteWork.js
+++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js
@@ -40,6 +40,7 @@ import {
passChildrenWhenCloningPersistedNodes,
disableLegacyMode,
enableViewTransition,
+ enableViewTransitionNested,
enableSuspenseyImages,
} from 'shared/ReactFeatureFlags';
@@ -98,6 +99,7 @@ import {
ShouldSuspendCommit,
Cloned,
ViewTransitionStatic,
+ ViewTransitionStaticNested,
Hydrate,
PortalStatic,
} from './ReactFiberFlags';
@@ -2060,6 +2062,18 @@ 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 (enableViewTransitionNested) {
+ const props = workInProgress.pendingProps;
+ if (
+ (props.enter != null && typeof props.enter !== 'string') ||
+ (props.exit != null && typeof props.exit !== 'string')
+ ) {
+ workInProgress.flags |= ViewTransitionStaticNested;
+ } else {
+ // Clear if enter/exit type configs were removed in an update.
+ workInProgress.flags &= ~ViewTransitionStaticNested;
+ }
+ }
bubbleProperties(workInProgress);
}
return null;
diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js
index 9f85897fb05c..30b4e10e883c 100644
--- a/packages/react-reconciler/src/ReactFiberFlags.js
+++ b/packages/react-reconciler/src/ReactFiberFlags.js
@@ -83,6 +83,11 @@ 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;
+// ViewTransitionStaticNested tracks whether there are ViewTransition components
+// with type-based enter/exit configs. Unlike ViewTransitionStatic, this is NOT
+// cleared by HostComponents so it can be used to skip subtrees in nested walks.
+export const ViewTransitionStaticNested = /* */ 0b1000000000000000000000000000000;
+
// Tracks whether a HostPortal is present in the tree.
export const PortalStatic = /* */ 0b0000100000000000000000000000000;
@@ -140,6 +145,7 @@ export const StaticMask =
RefStatic |
MaySuspendCommit |
ViewTransitionStatic |
+ ViewTransitionStaticNested |
ViewTransitionNamedStatic |
PortalStatic |
Forked;
diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js
index ba4a7c52e3d3..df79eec724e8 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 enableViewTransitionNested: boolean = false;
+
export const enableViewTransitionForPersistenceMode: boolean = false;
export const enableGestureTransition = __EXPERIMENTAL__;
diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js
index 36774ad94d9f..68a605f7e2fa 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js
@@ -27,3 +27,4 @@ export const enableFragmentRefsInstanceHandles = __VARIANT__;
export const enableEffectEventMutationPhase = __VARIANT__;
export const enableFragmentRefsTextNodes = __VARIANT__;
export const enableViewTransitionForPersistenceMode = __VARIANT__;
+export const enableViewTransitionNested = __VARIANT__;
diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js
index 0e43e34d0009..3f7d6d89a982 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-fb.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js
@@ -29,6 +29,7 @@ export const {
enableFragmentRefsInstanceHandles,
enableFragmentRefsTextNodes,
enableViewTransitionForPersistenceMode,
+ enableViewTransitionNested,
} = dynamicFlags;
// The rest of the flags are static for better dead code elimination.
diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js
index 035bf2a75dd0..997765bde2cb 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-oss.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js
@@ -59,6 +59,7 @@ export const enableYieldingBeforePassive: boolean = false;
export const enableThrottledScheduling: boolean = false;
export const enableViewTransition: boolean = true;
+export const enableViewTransitionNested: 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 76597e0cbb01..6b3d4e5fe8f2 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js
@@ -60,6 +60,7 @@ export const enableYieldingBeforePassive: boolean = true;
export const enableThrottledScheduling: boolean = false;
export const enableViewTransition: boolean = true;
+export const enableViewTransitionNested: 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.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
index 8022dd8e2254..abb5951edb6a 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
@@ -55,6 +55,7 @@ export const transitionLaneExpirationMs = 5000;
export const enableYieldingBeforePassive = false;
export const enableThrottledScheduling = false;
export const enableViewTransition = true;
+export const enableViewTransitionNested = 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 271c464daa60..dd84f5627a4e 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
@@ -66,6 +66,7 @@ export const enableYieldingBeforePassive: boolean = false;
export const enableThrottledScheduling: boolean = false;
export const enableViewTransition: boolean = true;
+export const enableViewTransitionNested: boolean = false;
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 1646c834ef41..7fe7989a6efb 100644
--- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js
@@ -30,6 +30,7 @@ export const enableInfiniteRenderLoopDetection: boolean = __VARIANT__;
export const enableFastAddPropertiesInDiffing: boolean = __VARIANT__;
export const enableViewTransition: boolean = __VARIANT__;
+export const enableViewTransitionNested: 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 a07f34414217..33a45a14a882 100644
--- a/packages/shared/forks/ReactFeatureFlags.www.js
+++ b/packages/shared/forks/ReactFeatureFlags.www.js
@@ -28,6 +28,7 @@ export const {
syncLaneExpirationMs,
transitionLaneExpirationMs,
enableViewTransition,
+ enableViewTransitionNested,
enableScrollEndPolyfill,
enableFragmentRefs,
enableFragmentRefsScrollIntoView,