[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.