@samitouri / QOS-React / commits / 83840902c8

[Fizz] Support nested enter/exit ViewTransition animations (#36917)

Adds SSR support for nested parentEnter/parentExit View Transitions. Fizz now emits vt-parent-enter/vt-parent-exit annotations during streaming, and the client picks them up on hydration, so nested enter/exit animations work for Suspense reveals.

Jack Pope committed Jul 19, 2026 at 09:59 UTC 83840902c890f0eb85decda239ef6b1b14945779
14 files changed +807 -115
fixtures/view-transition/server/render.js
+7
@@ -2,6 +2,9 @@ import React from 'react';
2 import {renderToPipeableStream} from 'react-dom/server';
3
4 import App from '../src/components/App.js';
5 +import {resetFeedReveal} from '../src/components/NestedParentExit.js';
6 +import {resetPageReveal} from '../src/components/Page.js';
7 +import {resetNestedReveal} from '../src/components/NestedReveal.js';
8
9 let assets;
10 if (process.env.NODE_ENV === 'development') {
@@ -15,6 +18,10 @@ if (process.env.NODE_ENV === 'development') {
18 }
19
20 export default function render(url, res) {
21 + // Force resuspend on every load
22 + resetFeedReveal();
23 + resetPageReveal();
24 + resetNestedReveal();
25 res.socket.on('error', error => {
26 // Log fatal errors
27 console.error('Fatal', error);
fixtures/view-transition/src/components/NestedParentExit.css
+49
@@ -210,3 +210,52 @@
210 ::view-transition-old(.nested-back-btn-exit):only-child {
211 animation: nested-back-btn-exit 200ms ease-in forwards;
212 }
213 +
214 +/* Suspense reveal of the feed (drives the SSR parent enter/exit demo). */
215 +.feed-item-skeleton {
216 + cursor: default;
217 +}
218 +
219 +.skeleton-line {
220 + background: #ddd;
221 + border-radius: 4px;
222 +}
223 +
224 +.skeleton-title {
225 + height: 15px;
226 + width: 60%;
227 + margin: 0 0 0.35rem;
228 +}
229 +
230 +.skeleton-body {
231 + height: 13px;
232 + width: 90%;
233 +}
234 +
235 +@keyframes nested-feed-enter {
236 + from {
237 + opacity: 0;
238 + translate: 0 20px;
239 + }
240 + to {
241 + opacity: 1;
242 + translate: 0 0;
243 + }
244 +}
245 +
246 +::view-transition-new(.nested-feed-enter) {
247 + animation: nested-feed-enter 450ms ease-out both;
248 +}
249 +
250 +@keyframes nested-feed-exit {
251 + from {
252 + opacity: 1;
253 + }
254 + to {
255 + opacity: 0;
256 + }
257 +}
258 +
259 +::view-transition-old(.nested-feed-exit) {
260 + animation: nested-feed-exit 300ms ease-in forwards;
261 +}
fixtures/view-transition/src/components/NestedParentExit.js
+61 -11
@@ -1,5 +1,7 @@
1 import React, {
2 ViewTransition,
3 + Suspense,
4 + use,
5 useState,
6 useOptimistic,
7 startTransition,
@@ -14,6 +16,45 @@ const items = [
16 {id: 3, title: 'Third Post', body: 'Hello from the third post.'},
17 ];
18
19 +let feedRevealPromise = null;
20 +
21 +export function resetFeedReveal() {
22 + feedRevealPromise = null;
23 +}
24 +
25 +function FeedReveal() {
26 + if (feedRevealPromise === null) {
27 + feedRevealPromise = new Promise(resolve => setTimeout(resolve, 1000));
28 + }
29 + use(feedRevealPromise);
30 + return null;
31 +}
32 +
33 +function FeedSkeleton() {
34 + return (
35 + <ViewTransition exit="nested-feed-exit">
36 + <div className="nested-feed">
37 + {items.map(item => (
38 + // Mirrors FeedItem: each skeleton row relays the exit (level 1), and
39 + // its title/body placeholders relay again (level 2) so they animate
40 + // out separately from the row — up and to the right. This exercises
41 + // the nested parentExit relay through the SSR streaming reveal.
42 + <ViewTransition key={item.id} parentExit="nested-exit-left">
43 + <div className="feed-item feed-item-skeleton">
44 + <ViewTransition parentExit="nested-title-exit-up">
45 + <div className="feed-item-title skeleton-line skeleton-title" />
46 + </ViewTransition>
47 + <ViewTransition parentExit="nested-body-exit-right">
48 + <div className="skeleton-line skeleton-body" />
49 + </ViewTransition>
50 + </div>
51 + </ViewTransition>
52 + ))}
53 + </div>
54 + </ViewTransition>
55 + );
56 +}
57 +
58 function logGestureParent(kind, title, _timeline, _options, _instance, types) {
59 // eslint-disable-next-line no-console
60 console.log(`[NestedParentExit] onGestureParent${kind}`, title, types);
@@ -159,17 +200,26 @@ export default function NestedParentExit() {
200 {selected ? (
201 <Detail item={selected} onBack={goBack} />
202 ) : (
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 - </>
203 + <Suspense fallback={<FeedSkeleton />}>
204 + {/* The entering ViewTransition is the direct child of the
205 + Suspense content, so it activates an enter scope and the
206 + feed items below relay parentEnter (emitting
207 + vt-parent-enter annotations in Fizz). */}
208 + <ViewTransition enter="nested-feed-enter">
209 + <div className="nested-feed">
210 + {items.map((item, index) => (
211 + <FeedItem
212 + key={item.id}
213 + item={item}
214 + index={index}
215 + activeIndex={activeIndex}
216 + onSelect={goToDetail}
217 + />
218 + ))}
219 + <FeedReveal />
220 + </div>
221 + </ViewTransition>
222 + </Suspense>
223 )}
224 </div>
225 </ViewTransition>
fixtures/view-transition/src/components/NestedReveal.js
+5
@@ -12,6 +12,11 @@ function Use({useable}) {
12 let delay1;
13 let delay2;
14
15 +export function resetNestedReveal() {
16 + delay1 = undefined;
17 + delay2 = undefined;
18 +}
19 +
20 export default function NestedReveal({}) {
21 if (!delay1) {
22 delay1 = sleep(100);
fixtures/view-transition/src/components/Page.css
+42 -1
@@ -18,4 +18,45 @@
18 top: 10px;
19 left: 360px;
20 border: 1px solid #ccc;
21 -}
\ No newline at end of file
21 +}
22 +
23 +.examples {
24 + display: grid;
25 + /* Wide enough that the demos (the ~340px swipe-recognizer boxes) fit without
26 + overflowing once card + content padding is subtracted. */
27 + grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
28 + gap: 20px;
29 + align-items: start;
30 + width: 100%;
31 + box-sizing: border-box;
32 + padding: 10px;
33 +}
34 +
35 +.example-card {
36 + display: flex;
37 + flex-direction: column;
38 + min-height: 320px;
39 + background: #eee;
40 + border: 1px solid #ddd;
41 + border-radius: 10px;
42 + padding: 20px;
43 + box-sizing: border-box;
44 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
45 +}
46 +
47 +.example-card-title {
48 + margin: 0 0 16px;
49 + font-size: 15px;
50 + font-weight: 600;
51 + color: #333;
52 +}
53 +
54 +.example-card-content {
55 + flex: 1;
56 + width: 100%;
57 + background: #fff;
58 + border-radius: 10px;
59 + padding: 16px;
60 + box-sizing: border-box;
61 + overflow: auto;
62 +}
fixtures/view-transition/src/components/Page.js
+117 -99
@@ -42,6 +42,15 @@ const b = (
42 </div>
43 );
44
45 +function ExampleCard({title, children}) {
46 + return (
47 + <div className="example-card">
48 + {title ? <h2 className="example-card-title">{title}</h2> : null}
49 + <div className="example-card-content">{children}</div>
50 + </div>
51 + );
52 +}
53 +
54 function Component() {
55 // Test inserting fonts with style tags using useInsertionEffect. This is not recommended but
56 // used to test that gestures etc works with useInsertionEffect so that stylesheet based
@@ -86,6 +95,9 @@ function Id() {
95 }
96
97 let wait;
98 +export function resetPageReveal() {
99 + wait = undefined;
100 +}
101 function Suspend() {
102 if (!wait) wait = sleep(500);
103 return React.use(wait);
@@ -214,116 +226,122 @@ export default function Page({url, navigate}) {
226 </ViewTransition>
227 );
228 return (
217 - <div className="swipe-recognizer">
218 - <SwipeRecognizer
219 - action={swipeAction}
220 - gesture={direction => {
221 - addTransitionType(
222 - direction === 'left' ? 'navigation-forward' : 'navigation-back'
223 - );
224 - optimisticNavigate(direction);
225 - }}
226 - direction={show ? 'left' : 'right'}>
227 - <button
228 - className="button"
229 - onClick={() => {
230 - navigate(url === '/?b' ? '/?a' : '/?b');
231 - }}>
232 - {url === '/?b' ? 'Goto A' : 'Goto B'}
233 - </button>
234 - <ViewTransition default="none">
235 - <div>
236 - <ViewTransition>
237 - <div>
238 - <ViewTransition default={transitions['slide-on-nav']}>
239 - <h1>{!show ? 'A' : 'B' + counter}</h1>
240 - </ViewTransition>
241 - </div>
242 - </ViewTransition>
243 - <ViewTransition
244 - default={{
245 - 'navigation-back': transitions['slide-right'],
246 - 'navigation-forward': transitions['slide-left'],
247 - }}>
248 - <h1>{!show ? 'A' + counter : 'B'}</h1>
249 - </ViewTransition>
250 - {
251 - // Using url instead of renderedUrl here lets us only update this on commit.
252 - url === '/?b' ? (
253 - <div>
254 - {a}
255 - {b}
256 - </div>
257 - ) : (
229 + <div className="examples">
230 + <ExampleCard title="Navigation & Gestures">
231 + <SwipeRecognizer
232 + action={swipeAction}
233 + gesture={direction => {
234 + addTransitionType(
235 + direction === 'left' ? 'navigation-forward' : 'navigation-back'
236 + );
237 + optimisticNavigate(direction);
238 + }}
239 + direction={show ? 'left' : 'right'}>
240 + <button
241 + className="button"
242 + onClick={() => {
243 + navigate(url === '/?b' ? '/?a' : '/?b');
244 + }}>
245 + {url === '/?b' ? 'Goto A' : 'Goto B'}
246 + </button>
247 + <ViewTransition default="none">
248 + <div>
249 + <ViewTransition>
250 <div>
259 - {b}
260 - {a}
251 + <ViewTransition default={transitions['slide-on-nav']}>
252 + <h1>{!show ? 'A' : 'B' + counter}</h1>
253 + </ViewTransition>
254 </div>
262 - )
263 - }
264 - <ViewTransition>
265 - {show ? (
266 - <div>hello{exclamation}</div>
267 - ) : (
268 - <section>Loading</section>
269 - )}
270 - </ViewTransition>
271 - <p>
272 - <Id />
273 - </p>
274 - {show ? null : (
275 - <ViewTransition>
276 - <div>world{exclamation}</div>
255 </ViewTransition>
278 - )}
279 - <Activity mode={show ? 'visible' : 'hidden'}>
256 + <ViewTransition
257 + default={{
258 + 'navigation-back': transitions['slide-right'],
259 + 'navigation-forward': transitions['slide-left'],
260 + }}>
261 + <h1>{!show ? 'A' + counter : 'B'}</h1>
262 + </ViewTransition>
263 + {
264 + // Using url instead of renderedUrl here lets us only update this on commit.
265 + url === '/?b' ? (
266 + <div>
267 + {a}
268 + {b}
269 + </div>
270 + ) : (
271 + <div>
272 + {b}
273 + {a}
274 + </div>
275 + )
276 + }
277 <ViewTransition>
281 - <div>!!</div>
278 + {show ? (
279 + <div>hello{exclamation}</div>
280 + ) : (
281 + <section>Loading</section>
282 + )}
283 </ViewTransition>
283 - </Activity>
284 - <Suspense
285 - fallback={
284 + <p>
285 + <Id />
286 + </p>
287 + {show ? null : (
288 + <ViewTransition>
289 + <div>world{exclamation}</div>
290 + </ViewTransition>
291 + )}
292 + <Activity mode={show ? 'visible' : 'hidden'}>
293 + <ViewTransition>
294 + <div>!!</div>
295 + </ViewTransition>
296 + </Activity>
297 + <Suspense
298 + fallback={
299 + <ViewTransition>
300 + <div>
301 + <ViewTransition name="shared-reveal">
302 + <h2>█████</h2>
303 + </ViewTransition>
304 + <p>████</p>
305 + <p>███████</p>
306 + <p>████</p>
307 + <p>██</p>
308 + <p>██████</p>
309 + <p>███</p>
310 + <p>████</p>
311 + </div>
312 + </ViewTransition>
313 + }>
314 <ViewTransition>
315 <div>
316 + <p>these</p>
317 + <p>rows</p>
318 <ViewTransition name="shared-reveal">
289 - <h2>█████</h2>
319 + <h2>exist</h2>
320 </ViewTransition>
291 - <p>████</p>
292 - <p>███████</p>
293 - <p>████</p>
294 - <p>██</p>
295 - <p>██████</p>
296 - <p>███</p>
297 - <p>████</p>
321 + <p>to</p>
322 + <p>test</p>
323 + <p>scrolling</p>
324 + <p>content</p>
325 + <p>out</p>
326 + <p>of</p>
327 + {portal}
328 + <p>the</p>
329 + <p>viewport</p>
330 + <Suspend />
331 </div>
332 </ViewTransition>
300 - }>
301 - <ViewTransition>
302 - <div>
303 - <p>these</p>
304 - <p>rows</p>
305 - <ViewTransition name="shared-reveal">
306 - <h2>exist</h2>
307 - </ViewTransition>
308 - <p>to</p>
309 - <p>test</p>
310 - <p>scrolling</p>
311 - <p>content</p>
312 - <p>out</p>
313 - <p>of</p>
314 - {portal}
315 - <p>the</p>
316 - <p>viewport</p>
317 - <Suspend />
318 - </div>
319 - </ViewTransition>
320 - {show ? <Component /> : null}
321 - </Suspense>
322 - </div>
323 - </ViewTransition>
324 - </SwipeRecognizer>
325 - <NestedReveal />
326 - <NestedParentExit />
333 + {show ? <Component /> : null}
334 + </Suspense>
335 + </div>
336 + </ViewTransition>
337 + </SwipeRecognizer>
338 + </ExampleCard>
339 + <ExampleCard title="Nested Suspense Reveal">
340 + <NestedReveal />
341 + </ExampleCard>
342 + <ExampleCard title="Parent Enter / Exit (SSR)">
343 + <NestedParentExit />
344 + </ExampleCard>
345 </div>
346 );
347 }
packages/react-dom-bindings/src/client/ReactDOMComponent.js
+7 -1
@@ -73,6 +73,7 @@ import {
73 enableSrcObject,
74 enableTrustedTypesIntegration,
75 enableViewTransition,
76 + enableViewTransitionParentEnterExit,
77 } from 'shared/ReactFeatureFlags';
78 import {
79 mediaEventTypes,
@@ -240,7 +241,10 @@ function hasViewTransition(htmlElement: HTMLElement): boolean {
241 htmlElement.getAttribute('vt-share') ||
242 htmlElement.getAttribute('vt-exit') ||
243 htmlElement.getAttribute('vt-enter') ||
243 - htmlElement.getAttribute('vt-update')
244 + htmlElement.getAttribute('vt-update') ||
245 + (enableViewTransitionParentEnterExit &&
246 + (htmlElement.getAttribute('vt-parent-enter') ||
247 + htmlElement.getAttribute('vt-parent-exit')))
248 );
249 }
250
@@ -3307,6 +3311,8 @@ export function diffHydratedProperties(
3311 case 'vt-enter':
3312 case 'vt-exit':
3313 case 'vt-share':
3314 + case 'vt-parent-enter':
3315 + case 'vt-parent-exit':
3316 if (enableViewTransition) {
3317 // View Transition annotations are expected from the Server Runtime.
3318 // However, if they're also specified on the client and don't match
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+68
@@ -36,6 +36,7 @@ import {
36 enableSrcObject,
37 enableFizzBlockingRender,
38 enableViewTransition,
39 + enableViewTransitionParentEnterExit,
40 } from 'shared/ReactFeatureFlags';
41
42 import type {
@@ -790,6 +791,8 @@ const EXIT_SCOPE = /* */ 0b0001000; // A direct Instance below a Suspense
791 const ENTER_SCOPE = /* */ 0b0010000; // A direct Instance below Suspense content is the only thing that can "enter"
792 const UPDATE_SCOPE = /* */ 0b0100000; // Inside a scope that applies "update" ViewTransitions if anything mutates here.
793 const APPEARING_SCOPE = /* */ 0b1000000; // Below Suspense content subtree which might appear in an "enter" animation or "shared" animation.
794 +const PARENT_EXIT_SCOPE = /* */ 0b10000000; // Below a ViewTransition that is exiting.
795 +const PARENT_ENTER_SCOPE = /* */ 0b100000000; // Below a ViewTransition that is entering.
796
797 // Everything not listed here are tracked for the whole subtree as opposed to just
798 // until the next Instance.
@@ -800,6 +803,8 @@ type ViewTransitionContext = {
803 enter: 'none' | 'auto' | string,
804 exit: 'none' | 'auto' | string,
805 share: 'none' | 'auto' | string,
806 + parentEnter: 'none' | 'auto' | string,
807 + parentExit: 'none' | 'auto' | string,
808 name: 'auto' | string,
809 autoName: string, // a name that can be used if an explicit one is not defined.
810 nameIdx: number, // keeps track of how many duplicates of this name we've emitted.
@@ -957,6 +962,8 @@ function getSuspenseViewTransition(
962 enter: 'none',
963 exit: 'none',
964 share: parentViewTransition.update, // For exit or enter of reveals.
965 + parentEnter: 'none',
966 + parentExit: 'none',
967 name: parentViewTransition.autoName,
968 autoName: parentViewTransition.autoName,
969 // TOOD: If we have more than just this Suspense boundary as a child of the ViewTransition
@@ -1012,6 +1019,10 @@ export function getViewTransitionFormatContext(
1019 enter: ?string,
1020 exit: ?string,
1021 share: ?string,
1022 + parentEnter: ?string,
1023 + parentExit: ?string,
1024 + hasParentEnterHandler: boolean,
1025 + hasParentExitHandler: boolean,
1026 name: ?string,
1027 autoName: string, // name or an autogenerated unique name
1028 ): FormatContext {
@@ -1058,11 +1069,29 @@ export function getViewTransitionFormatContext(
1069 } else {
1070 resumableState.instructions |= NeedUpgradeToViewTransitions;
1071 }
1072 + let resolvedParentEnter = 'none';
1073 + let resolvedParentExit = 'none';
1074 + if (enableViewTransitionParentEnterExit) {
1075 + if (
1076 + parentEnter != null &&
1077 + (parentContext.tagScope & PARENT_ENTER_SCOPE) !== 0
1078 + ) {
1079 + resolvedParentEnter = parentEnter;
1080 + }
1081 + if (
1082 + parentExit != null &&
1083 + (parentContext.tagScope & PARENT_EXIT_SCOPE) !== 0
1084 + ) {
1085 + resolvedParentExit = parentExit;
1086 + }
1087 + }
1088 const viewTransition: ViewTransitionContext = {
1089 update,
1090 enter,
1091 exit,
1092 share,
1093 + parentEnter: resolvedParentEnter,
1094 + parentExit: resolvedParentExit,
1095 name,
1096 autoName,
1097 nameIdx: 0,
@@ -1076,6 +1105,33 @@ export function getViewTransitionFormatContext(
1105 if (enter !== 'none') {
1106 subtreeScope |= APPEARING_SCOPE;
1107 }
1108 + if (enableViewTransitionParentEnterExit) {
1109 + // Parent enter relay: a ViewTransition that is itself entering starts a relay
1110 + // for its subtree. A nested ViewTransition continues that relay as long as it
1111 + // opts in with a parentEnter prop that doesn't resolve to "none", or with an
1112 + // onParentEnter handler. A missing parentEnter (with no handler) or an
1113 + // explicit "none" stops the relay so descendants below it don't participate.
1114 + // This mirrors commitParentEnterViewTransitions.
1115 + if (enter !== 'none') {
1116 + subtreeScope |= PARENT_ENTER_SCOPE;
1117 + } else if (
1118 + (parentContext.tagScope & PARENT_ENTER_SCOPE) !== 0 &&
1119 + (parentEnter === 'none' ||
1120 + (parentEnter === undefined && !hasParentEnterHandler))
1121 + ) {
1122 + subtreeScope &= ~PARENT_ENTER_SCOPE;
1123 + }
1124 + // Parent exit relay: mirror of the enter relay above.
1125 + if (exit !== 'none') {
1126 + subtreeScope |= PARENT_EXIT_SCOPE;
1127 + } else if (
1128 + (parentContext.tagScope & PARENT_EXIT_SCOPE) !== 0 &&
1129 + (parentExit === 'none' ||
1130 + (parentExit === undefined && !hasParentExitHandler))
1131 + ) {
1132 + subtreeScope &= ~PARENT_EXIT_SCOPE;
1133 + }
1134 + }
1135 return createFormatContext(
1136 parentContext.insertionMode,
1137 parentContext.selectedValue,
@@ -1178,6 +1234,18 @@ function pushViewTransitionAttributes(
1234 if (viewTransition.share !== 'none') {
1235 pushStringAttribute(target, 'vt-share', viewTransition.share);
1236 }
1237 + if (
1238 + enableViewTransitionParentEnterExit &&
1239 + viewTransition.parentEnter !== 'none'
1240 + ) {
1241 + pushStringAttribute(target, 'vt-parent-enter', viewTransition.parentEnter);
1242 + }
1243 + if (
1244 + enableViewTransitionParentEnterExit &&
1245 + viewTransition.parentExit !== 'none'
1246 + ) {
1247 + pushStringAttribute(target, 'vt-parent-exit', viewTransition.parentExit);
1248 + }
1249 }
1250
1251 const styleNameCache: Map<string, PrecomputedChunk> = new Map();
packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+4
@@ -194,6 +194,10 @@ export function getViewTransitionFormatContext(
194 enter: void | null | 'none' | 'auto' | string,
195 exit: void | null | 'none' | 'auto' | string,
196 share: void | null | 'none' | 'auto' | string,
197 + parentEnter: void | null | 'none' | 'auto' | string,
198 + parentExit: void | null | 'none' | 'auto' | string,
199 + hasParentEnterHandler: boolean,
200 + hasParentExitHandler: boolean,
201 name: void | null | 'auto' | string,
202 autoName: string, // name or an autogenerated unique name
203 ): FormatContext {
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js
+1 -1
@@ -8,7 +8,7 @@ export const clientRenderBoundary =
8 export const completeBoundary =
9 '$RB=[];$RV=function(a){$RT=performance.now();for(var b=0;b<a.length;b+=2){var c=a[b],e=a[b+1];null!==e.parentNode&&e.parentNode.removeChild(e);var f=c.parentNode;if(f){var g=c.previousSibling,h=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d||"/&"===d)if(0===h)break;else h--;else"$"!==d&&"$?"!==d&&"$~"!==d&&"$!"!==d&&"&"!==d||h++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;e.firstChild;)f.insertBefore(e.firstChild,c);g.data="$";g._reactRetry&&requestAnimationFrame(g._reactRetry)}}a.length=0};\n$RC=function(a,b){if(b=document.getElementById(b))(a=document.getElementById(a))?(a.previousSibling.data="$~",$RB.push(a,b),2===$RB.length&&("number"!==typeof $RT?requestAnimationFrame($RV.bind(null,$RB)):(a=performance.now(),setTimeout($RV.bind(null,$RB),2300>a&&2E3<a?2300-a:$RT+300-a)))):b.parentNode.removeChild(b)};';
10 export const completeBoundaryUpgradeToViewTransitions =
11 - '$RV=function(A,g){function k(a,b){var e=a.getAttribute(b);e&&(b=a.style,l.push(a,b.viewTransitionName,b.viewTransitionClass),"auto"!==e&&(b.viewTransitionClass=e),(a=a.getAttribute("vt-name"))||(a="_T_"+K++ +"_"),a=CSS.escape(a)!==a?"r-"+btoa(a).replace(/=/g,""):a,b.viewTransitionName=a,B=!0)}var B=!1,K=0,l=[];try{var f=document.__reactViewTransition;if(f){f.finished.finally($RV.bind(null,g));return}var m=new Map;for(f=1;f<g.length;f+=2)for(var h=g[f].querySelectorAll("[vt-share]"),d=0;d<h.length;d++){var c=h[d];m.set(c.getAttribute("vt-name"),c)}var u=[];for(h=0;h<g.length;h+=2){var C=g[h],x=C.parentNode;if(x){var v=x.getBoundingClientRect();if(v.left||v.top||v.width||v.height){c=C;for(f=0;c;){if(8===c.nodeType){var r=c.data;if("/$"===r)if(0===f)break;else f--;else"$"!==r&&"$?"!==r&&"$~"!==r&&"$!"!==r||f++}else if(1===c.nodeType){d=c;var D=d.getAttribute("vt-name"),y=m.get(D);k(d,y?"vt-share":"vt-exit");y&&(k(y,"vt-share"),m.set(D,null));var E=d.querySelectorAll("[vt-share]");\nfor(d=0;d<E.length;d++){var F=E[d],G=F.getAttribute("vt-name"),H=m.get(G);H&&(k(F,"vt-share"),k(H,"vt-share"),m.set(G,null))}}c=c.nextSibling}for(var I=g[h+1],t=I.firstElementChild;t;)null!==m.get(t.getAttribute("vt-name"))&&k(t,"vt-enter"),t=t.nextElementSibling;c=x;do for(var n=c.firstElementChild;n;){var J=n.getAttribute("vt-update");J&&"none"!==J&&!l.includes(n)&&k(n,"vt-update");n=n.nextElementSibling}while((c=c.parentNode)&&1===c.nodeType&&"none"!==c.getAttribute("vt-update"));u.push.apply(u,\nI.querySelectorAll(\'img[src]:not([loading="lazy"])\'))}}}if(B){var z=document.__reactViewTransition=document.startViewTransition({update:function(){A(g);for(var a=[document.documentElement.clientHeight,document.fonts.ready],b={},e=0;e<u.length;b={g:b.g},e++)if(b.g=u[e],!b.g.complete){var p=b.g.getBoundingClientRect();0<p.bottom&&0<p.right&&p.top<window.innerHeight&&p.left<window.innerWidth&&(p=new Promise(function(w){return function(q){w.g.addEventListener("load",q);w.g.addEventListener("error",q)}}(b)),\na.push(p))}return Promise.race([Promise.all(a),new Promise(function(w){var q=performance.now();setTimeout(w,2300>q&&2E3<q?2300-q:500)})])},types:[]});z.ready.finally(function(){for(var a=l.length-3;0<=a;a-=3){var b=l[a],e=b.style;e.viewTransitionName=l[a+1];e.viewTransitionClass=l[a+1];""===b.getAttribute("style")&&b.removeAttribute("style")}});z.finished.finally(function(){document.__reactViewTransition===z&&(document.__reactViewTransition=null)});$RB=[];return}}catch(a){}A(g)}.bind(null,\n$RV);';
11 + '$RV=function(B,g){function h(a,c){var e=a.getAttribute(c);e&&(c=a.style,l.push(a,c.viewTransitionName,c.viewTransitionClass),"auto"!==e&&(c.viewTransitionClass=e),(a=a.getAttribute("vt-name"))||(a="_T_"+N++ +"_"),a=CSS.escape(a)!==a?"r-"+btoa(a).replace(/=/g,""):a,c.viewTransitionName=a,C=!0)}var C=!1,N=0,l=[];try{var f=document.__reactViewTransition;if(f){f.finished.finally($RV.bind(null,g));return}var m=new Map;for(f=1;f<g.length;f+=2)for(var k=g[f].querySelectorAll("[vt-share]"),d=0;d<k.length;d++){var b=k[d];m.set(b.getAttribute("vt-name"),b)}var u=[];for(k=0;k<g.length;k+=2){var D=g[k],x=D.parentNode;if(x){var v=x.getBoundingClientRect();if(v.left||v.top||v.width||v.height){b=D;for(f=0;b;){if(8===b.nodeType){var t=b.data;if("/$"===t)if(0===f)break;else f--;else"$"!==t&&"$?"!==t&&"$~"!==t&&"$!"!==t||f++}else if(1===b.nodeType){d=b;var E=d.getAttribute("vt-name"),y=m.get(E);h(d,y?"vt-share":"vt-exit");y&&(h(y,"vt-share"),m.set(E,null));for(var F=d.querySelectorAll("[vt-share]"),\nz=0;z<F.length;z++){var G=F[z],H=G.getAttribute("vt-name"),I=m.get(H);I&&(h(G,"vt-share"),h(I,"vt-share"),m.set(H,null))}var J=d.querySelectorAll("[vt-parent-exit]");for(d=0;d<J.length;d++)h(J[d],"vt-parent-exit")}b=b.nextSibling}for(var K=g[k+1],n=K.firstElementChild;n;){null!==m.get(n.getAttribute("vt-name"))&&h(n,"vt-enter");var L=n.querySelectorAll("[vt-parent-enter]");for(b=0;b<L.length;b++)h(L[b],"vt-parent-enter");n=n.nextElementSibling}b=x;do for(var p=b.firstElementChild;p;){var M=p.getAttribute("vt-update");\nM&&"none"!==M&&!l.includes(p)&&h(p,"vt-update");p=p.nextElementSibling}while((b=b.parentNode)&&1===b.nodeType&&"none"!==b.getAttribute("vt-update"));u.push.apply(u,K.querySelectorAll(\'img[src]:not([loading="lazy"])\'))}}}if(C){var A=document.__reactViewTransition=document.startViewTransition({update:function(){B(g);for(var a=[document.documentElement.clientHeight,document.fonts.ready],c={},e=0;e<u.length;c={g:c.g},e++)if(c.g=u[e],!c.g.complete){var q=c.g.getBoundingClientRect();0<q.bottom&&0<q.right&&\nq.top<window.innerHeight&&q.left<window.innerWidth&&(q=new Promise(function(w){return function(r){w.g.addEventListener("load",r);w.g.addEventListener("error",r)}}(c)),a.push(q))}return Promise.race([Promise.all(a),new Promise(function(w){var r=performance.now();setTimeout(w,2300>r&&2E3<r?2300-r:500)})])},types:[]});A.ready.finally(function(){for(var a=l.length-3;0<=a;a-=3){var c=l[a],e=c.style;e.viewTransitionName=l[a+1];e.viewTransitionClass=l[a+1];""===c.getAttribute("style")&&c.removeAttribute("style")}});\nA.finished.finally(function(){document.__reactViewTransition===A&&(document.__reactViewTransition=null)});$RB=[];return}}catch(a){}B(g)}.bind(null,$RV);';
12 export const completeBoundaryWithStyles =
13 '$RM=new Map;$RR=function(n,w,p){function u(q){this._p=null;q()}for(var r=new Map,t=document,h,b,e=t.querySelectorAll("link[data-precedence],style[data-precedence]"),v=[],k=0;b=e[k++];)"not all"===b.getAttribute("media")?v.push(b):("LINK"===b.tagName&&$RM.set(b.getAttribute("href"),b),r.set(b.dataset.precedence,h=b));e=0;b=[];var l,a;for(k=!0;;){if(k){var f=p[e++];if(!f){k=!1;e=0;continue}var c=!1,m=0;var d=f[m++];if(a=$RM.get(d)){var g=a._p;c=!0}else{a=t.createElement("link");a.href=d;a.rel=\n"stylesheet";for(a.dataset.precedence=l=f[m++];g=f[m++];)a.setAttribute(g,f[m++]);g=a._p=new Promise(function(q,x){a.onload=u.bind(a,q);a.onerror=u.bind(a,x)});$RM.set(d,a)}d=a.getAttribute("media");!g||d&&!matchMedia(d).matches||b.push(g);if(c)continue}else{a=v[e++];if(!a)break;l=a.getAttribute("data-precedence");a.removeAttribute("media")}c=r.get(l)||h;c===h&&(h=a);r.set(l,a);c?c.parentNode.insertBefore(a,c.nextSibling):(c=t.head,c.insertBefore(a,c.firstChild))}if(p=document.getElementById(n))p.previousSibling.data=\n"$~";Promise.all(b).then($RC.bind(null,n,w),$RX.bind(null,n,"CSS failed to load"))};';
14 export const completeSegment =
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js
+12
@@ -232,6 +232,12 @@ export function revealCompletedBoundariesWithViewTransitions(
232 appearingViewTransitions.set(name, null); // mark claimed
233 }
234 }
235 + // Relay the exit to nested ViewTransitions that opted in
236 + const relayExitElements =
237 + exitElement.querySelectorAll('[vt-parent-exit]');
238 + for (let j = 0; j < relayExitElements.length; j++) {
239 + applyViewTransitionName(relayExitElements[j], 'vt-parent-exit');
240 + }
241 }
242 node = node.nextSibling;
243 }
@@ -246,6 +252,12 @@ export function revealCompletedBoundariesWithViewTransitions(
252 if (!paired) {
253 applyViewTransitionName(enterElement, 'vt-enter');
254 }
255 + // Relay the enter to nested ViewTransitions that opted in
256 + const relayEnterElements =
257 + enterElement.querySelectorAll('[vt-parent-enter]');
258 + for (let j = 0; j < relayEnterElements.length; j++) {
259 + applyViewTransitionName(relayEnterElements[j], 'vt-parent-enter');
260 + }
261 enterElement = enterElement.nextElementSibling;
262 }
263
packages/react-dom/src/__tests__/ReactDOMFizzViewTransition-test.js
+415
@@ -332,4 +332,419 @@ describe('ReactDOMFizzViewTransition', () => {
332 ReactDOMClient.hydrateRoot(container, <App />);
333 });
334 });
335 +
336 + // @gate enableViewTransition && enableViewTransitionParentEnterExit
337 + it('stops the parentExit relay when an intermediate class is "none"', async () => {
338 + const promise = new Promise(() => {});
339 + function Suspend() {
340 + return React.use(promise);
341 + }
342 + function App() {
343 + const fallback = (
344 + <ViewTransition exit="page-exit">
345 + <div id="A">
346 + <ViewTransition parentExit="none">
347 + <div id="B">
348 + <ViewTransition parentExit="nested-exit">
349 + <div id="C">Deep</div>
350 + </ViewTransition>
351 + </div>
352 + </ViewTransition>
353 + <ViewTransition parentExit="nested-exit">
354 + <span id="D">Shallow</span>
355 + </ViewTransition>
356 + </div>
357 + </ViewTransition>
358 + );
359 + return (
360 + <div>
361 + <Suspense fallback={fallback}>
362 + <Suspend />
363 + </Suspense>
364 + </div>
365 + );
366 + }
367 +
368 + await serverAct(async () => {
369 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
370 + pipe(writable);
371 + });
372 +
373 + // The "none" on the wrapper stops the relay, so the deep child (C) never
374 + // gets a vt-parent-exit annotation. The sibling (D), which is not behind a
375 + // "none" boundary, still relays.
376 + expect(getVisibleChildren(container)).toEqual(
377 + <div>
378 + <div id="A" vt-update="auto" vt-exit="page-exit">
379 + <div id="B" vt-update="auto">
380 + <div id="C" vt-update="auto">
381 + Deep
382 + </div>
383 + </div>
384 + <span id="D" vt-update="auto" vt-parent-exit="nested-exit">
385 + Shallow
386 + </span>
387 + </div>
388 + </div>,
389 + );
390 + });
391 +
392 + // @gate enableViewTransition && enableViewTransitionParentEnterExit
393 + it('stops the parentEnter relay when an intermediate class is "none"', async () => {
394 + let resolve;
395 + const promise = new Promise(r => (resolve = r));
396 + function Suspend() {
397 + return React.use(promise);
398 + }
399 + function App() {
400 + return (
401 + <div>
402 + <Suspense fallback={<div>loading</div>}>
403 + <ViewTransition enter="page-enter">
404 + <Suspend />
405 + </ViewTransition>
406 + </Suspense>
407 + </div>
408 + );
409 + }
410 +
411 + await serverAct(async () => {
412 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
413 + pipe(writable);
414 + });
415 + await serverAct(async () => {
416 + await resolve(
417 + <div id="A">
418 + <ViewTransition parentEnter="none">
419 + <div id="B">
420 + <ViewTransition parentEnter="nested-enter">
421 + <div id="C">Deep</div>
422 + </ViewTransition>
423 + </div>
424 + </ViewTransition>
425 + <ViewTransition parentEnter="nested-enter">
426 + <span id="D">Shallow</span>
427 + </ViewTransition>
428 + </div>,
429 + );
430 + });
431 +
432 + // The "none" on the wrapper stops the relay, so the deep child (C) never
433 + // gets a vt-parent-enter annotation. The sibling (D) still relays.
434 + expect(getVisibleChildren(container)).toEqual(
435 + <div>
436 + <div id="A" vt-update="auto" vt-enter="page-enter">
437 + <div id="B" vt-update="auto">
438 + <div id="C" vt-update="auto">
439 + Deep
440 + </div>
441 + </div>
442 + <span id="D" vt-update="auto" vt-parent-enter="nested-enter">
443 + Shallow
444 + </span>
445 + </div>
446 + </div>,
447 + );
448 +
449 + // Hydration should not yield any errors.
450 + await clientAct(async () => {
451 + ReactDOMClient.hydrateRoot(container, <App />);
452 + });
453 + });
454 +
455 + // @gate enableViewTransition
456 + it('breaks the parentExit relay through a ViewTransition without parentExit', async () => {
457 + const promise = new Promise(() => {});
458 + function Suspend() {
459 + return React.use(promise);
460 + }
461 + function App() {
462 + const fallback = (
463 + <ViewTransition exit="page-exit">
464 + <div id="A">
465 + <ViewTransition>
466 + <div id="B">
467 + <ViewTransition parentExit="nested-exit">
468 + <div id="C">Deep</div>
469 + </ViewTransition>
470 + </div>
471 + </ViewTransition>
472 + </div>
473 + </ViewTransition>
474 + );
475 + return (
476 + <div>
477 + <Suspense fallback={fallback}>
478 + <Suspend />
479 + </Suspense>
480 + </div>
481 + );
482 + }
483 +
484 + await serverAct(async () => {
485 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
486 + pipe(writable);
487 + });
488 +
489 + // A ViewTransition that doesn't opt in with parentExit breaks the relay,
490 + // just like the client runtime, so C is not annotated.
491 + expect(getVisibleChildren(container)).toEqual(
492 + <div>
493 + <div id="A" vt-update="auto" vt-exit="page-exit">
494 + <div id="B" vt-update="auto">
495 + <div id="C" vt-update="auto">
496 + Deep
497 + </div>
498 + </div>
499 + </div>
500 + </div>,
501 + );
502 + });
503 +
504 + // @gate enableViewTransition && enableViewTransitionParentEnterExit
505 + it('relays the parentExit chain through an "auto" parentExit', async () => {
506 + const promise = new Promise(() => {});
507 + function Suspend() {
508 + return React.use(promise);
509 + }
510 + function App() {
511 + const fallback = (
512 + <ViewTransition exit="page-exit">
513 + <div id="A">
514 + <ViewTransition parentExit="auto">
515 + <div id="B">
516 + <ViewTransition parentExit="nested-exit">
517 + <div id="C">Deep</div>
518 + </ViewTransition>
519 + </div>
520 + </ViewTransition>
521 + </div>
522 + </ViewTransition>
523 + );
524 + return (
525 + <div>
526 + <Suspense fallback={fallback}>
527 + <Suspend />
528 + </Suspense>
529 + </div>
530 + );
531 + }
532 +
533 + await serverAct(async () => {
534 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
535 + pipe(writable);
536 + });
537 +
538 + // An "auto" parentExit emits no annotation of its own but still relays, so
539 + // the deep child (C) is annotated.
540 + expect(getVisibleChildren(container)).toEqual(
541 + <div>
542 + <div id="A" vt-update="auto" vt-exit="page-exit">
543 + <div id="B" vt-update="auto">
544 + <div id="C" vt-update="auto" vt-parent-exit="nested-exit">
545 + Deep
546 + </div>
547 + </div>
548 + </div>
549 + </div>,
550 + );
551 + });
552 +
553 + // @gate enableViewTransition && enableViewTransitionParentEnterExit
554 + it('relays the parentExit chain through a handler-only ViewTransition', async () => {
555 + const promise = new Promise(() => {});
556 + function Suspend() {
557 + return React.use(promise);
558 + }
559 + function App() {
560 + const fallback = (
561 + <ViewTransition exit="page-exit">
562 + <div id="A">
563 + <ViewTransition onParentExit={() => {}}>
564 + <div id="B">
565 + <ViewTransition parentExit="nested-exit">
566 + <div id="C">Deep</div>
567 + </ViewTransition>
568 + </div>
569 + </ViewTransition>
570 + </div>
571 + </ViewTransition>
572 + );
573 + return (
574 + <div>
575 + <Suspense fallback={fallback}>
576 + <Suspend />
577 + </Suspense>
578 + </div>
579 + );
580 + }
581 +
582 + await serverAct(async () => {
583 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
584 + pipe(writable);
585 + });
586 +
587 + // A ViewTransition with only an onParentExit handler (no class) emits no
588 + // annotation but still relays, just like the client runtime, so the deep
589 + // child (C) is annotated.
590 + expect(getVisibleChildren(container)).toEqual(
591 + <div>
592 + <div id="A" vt-update="auto" vt-exit="page-exit">
593 + <div id="B" vt-update="auto">
594 + <div id="C" vt-update="auto" vt-parent-exit="nested-exit">
595 + Deep
596 + </div>
597 + </div>
598 + </div>
599 + </div>,
600 + );
601 + });
602 +
603 + // @gate enableViewTransition && enableViewTransitionParentEnterExit
604 + it('relays the parentEnter chain through a handler-only ViewTransition', async () => {
605 + let resolve;
606 + const promise = new Promise(r => (resolve = r));
607 + function Suspend() {
608 + return React.use(promise);
609 + }
610 + function App() {
611 + return (
612 + <div>
613 + <Suspense fallback={<div>loading</div>}>
614 + <ViewTransition enter="page-enter">
615 + <Suspend />
616 + </ViewTransition>
617 + </Suspense>
618 + </div>
619 + );
620 + }
621 +
622 + await serverAct(async () => {
623 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
624 + pipe(writable);
625 + });
626 + await serverAct(async () => {
627 + await resolve(
628 + <div id="A">
629 + <ViewTransition onParentEnter={() => {}}>
630 + <div id="B">
631 + <ViewTransition parentEnter="nested-enter">
632 + <div id="C">Deep</div>
633 + </ViewTransition>
634 + </div>
635 + </ViewTransition>
636 + </div>,
637 + );
638 + });
639 +
640 + // A ViewTransition with only an onParentEnter handler still relays, so the
641 + // deep child (C) is annotated.
642 + expect(getVisibleChildren(container)).toEqual(
643 + <div>
644 + <div id="A" vt-update="auto" vt-enter="page-enter">
645 + <div id="B" vt-update="auto">
646 + <div id="C" vt-update="auto" vt-parent-enter="nested-enter">
647 + Deep
648 + </div>
649 + </div>
650 + </div>
651 + </div>,
652 + );
653 + });
654 +
655 + // @gate enableViewTransition && enableViewTransitionParentEnterExit
656 + it('applies view-transition-name to nested parentEnter/parentExit on streaming reveal', async () => {
657 + // Capture the view transition class applied to each element at the moment
658 + // the reveal starts a view transition (the names are reverted once it
659 + // begins, so we read them synchronously here).
660 + const applied = new Map();
661 + document.startViewTransition = function (arg) {
662 + const update = typeof arg === 'function' ? arg : arg.update;
663 + container.querySelectorAll('*').forEach(el => {
664 + if (el.id && el.style && el.style.viewTransitionName) {
665 + applied.set(el.id, el.style.viewTransitionClass);
666 + }
667 + });
668 + if (update) {
669 + update();
670 + }
671 + return {
672 + ready: Promise.resolve(),
673 + finished: Promise.resolve(),
674 + skipTransition() {},
675 + types: [],
676 + };
677 + };
678 + if (!global.window.CSS) {
679 + global.window.CSS = {escape: s => s};
680 + }
681 + Object.defineProperty(document, 'fonts', {
682 + value: {status: 'loaded', ready: Promise.resolve()},
683 + configurable: true,
684 + });
685 + // The reveal skips a boundary whose parent measures as empty, so give
686 + // elements a non-zero rect.
687 + global.window.Element.prototype.getBoundingClientRect = function () {
688 + return {left: 0, top: 0, width: 100, height: 20, right: 100, bottom: 20};
689 + };
690 +
691 + let resolve;
692 + const promise = new Promise(r => (resolve = r));
693 + function Suspend() {
694 + return React.use(promise);
695 + }
696 + function App() {
697 + const fallback = (
698 + <ViewTransition exit="page-exit">
699 + <div id="fbA">
700 + <ViewTransition parentExit="skeleton-exit">
701 + <div id="fbC">
702 + <ViewTransition parentExit="skeleton-exit-deep">
703 + <div id="fbD">Loading</div>
704 + </ViewTransition>
705 + </div>
706 + </ViewTransition>
707 + </div>
708 + </ViewTransition>
709 + );
710 + return (
711 + <div>
712 + <Suspense fallback={fallback}>
713 + <ViewTransition enter="page-enter">
714 + <Suspend />
715 + </ViewTransition>
716 + </Suspense>
717 + </div>
718 + );
719 + }
720 +
721 + await serverAct(async () => {
722 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
723 + pipe(writable);
724 + });
725 + await serverAct(async () => {
726 + await resolve(
727 + <div id="A">
728 + <ViewTransition parentEnter="nested-enter">
729 + <div id="C">
730 + <ViewTransition parentEnter="nested-enter-deep">
731 + <div id="D">Deep</div>
732 + </ViewTransition>
733 + </div>
734 + </ViewTransition>
735 + </div>,
736 + );
737 + });
738 +
739 + // Top-level enter/exit were already applied by the reveal. The nested parent
740 + // relay is the new behavior, and it relays through multiple levels: the
741 + // relay continues through C/fbC (non-none classes) down to D/fbD, matching
742 + // the client runtime's commitParentEnter/ExitViewTransitions.
743 + expect(applied.get('A')).toBe('page-enter');
744 + expect(applied.get('C')).toBe('nested-enter');
745 + expect(applied.get('D')).toBe('nested-enter-deep');
746 + expect(applied.get('fbA')).toBe('page-exit');
747 + expect(applied.get('fbC')).toBe('skeleton-exit');
748 + expect(applied.get('fbD')).toBe('skeleton-exit-deep');
749 + });
750 });
packages/react-markup/src/ReactFizzConfigMarkup.js
+4
@@ -95,6 +95,10 @@ export function getViewTransitionFormatContext(
95 enter: void | null | 'none' | 'auto' | string,
96 exit: void | null | 'none' | 'auto' | string,
97 share: void | null | 'none' | 'auto' | string,
98 + parentEnter: void | null | 'none' | 'auto' | string,
99 + parentExit: void | null | 'none' | 'auto' | string,
100 + hasParentEnterHandler: boolean,
101 + hasParentExitHandler: boolean,
102 name: void | null | 'auto' | string,
103 autoName: string, // name or an autogenerated unique name
104 ): FormatContext {
packages/react-server/src/ReactFizzServer.js
+15 -2
@@ -181,6 +181,7 @@ import {
181 enableScopeAPI,
182 enableAsyncIterableChildren,
183 enableViewTransition,
184 + enableViewTransitionParentEnterExit,
185 enableFizzBlockingRender,
186 enableAsyncDebugInfo,
187 enableCPUSuspense,
@@ -2953,6 +2954,20 @@ function renderViewTransition(
2954 getViewTransitionClassName(props.default, props.enter),
2955 getViewTransitionClassName(props.default, props.exit),
2956 getViewTransitionClassName(props.default, props.share),
2957 + // Pass `undefined` (rather than the resolved class) when the prop is absent
2958 + // so the format context can distinguish "no parentEnter/parentExit" (which
2959 + // stops the relay) from an explicit "auto"/class (which continues it).
2960 + enableViewTransitionParentEnterExit && props.parentEnter !== undefined
2961 + ? getViewTransitionClassName(props.default, props.parentEnter)
2962 + : undefined,
2963 + enableViewTransitionParentEnterExit && props.parentExit !== undefined
2964 + ? getViewTransitionClassName(props.default, props.parentExit)
2965 + : undefined,
2966 + // A ViewTransition with an onParentEnter/onParentExit handler but no class
2967 + // still relays the activation to its descendants, so the relay must continue
2968 + // through it even though the handler itself emits no annotation.
2969 + enableViewTransitionParentEnterExit && props.onParentEnter != null,
2970 + enableViewTransitionParentEnterExit && props.onParentExit != null,
2971 props.name,
2972 autoName,
2973 );
@@ -5312,8 +5327,6 @@ function retryRenderTask(
5327 x.message === 'Maximum call stack size exceeded' &&
5328 task.node !== startNode
5329 ) {
5315 - // Stack overflow after making forward progress. Retry from a fresh stack.
5316 - // No progress (e.g. overflow inside the component itself) falls through.
5330 segment.status = PENDING;
5331 task.thenableState = null;
5332 // Immediately schedule the task for retrying.