Materialize the tree ID when ViewTransition name=auto consumes one (#32651)
ViewTransition uses the `useId` algorithm to auto-assign names. This ensures that we could animate between SSR content and client content by ensuring that the names line up. However, I missed that we need to bump the id (materialize it) when we do that. This is what function components do if they use one or more `useId()`. This caused duplicate names when two ViewTransitions were nested without any siblings since they would share name.
Sebastian Markbåge committed
Mar 17, 2025 at 16:17 UTC
9fde224a53693101a4d15e038d6db37e7a3596ff
3 files changed
+47
-7
fixtures/view-transition/src/components/Page.js
+15
-3
@@ -4,6 +4,7 @@ import React, {
4
unstable_useSwipeTransition as useSwipeTransition,
5
useEffect,
6
useState,
7
+ useId,
8
} from 'react';
9
10
import SwipeRecognizer from './SwipeRecognizer';
@@ -39,6 +40,11 @@ function Component() {
40
);
41
}
42
43
+function Id() {
44
+ // This is just testing that Id inside a ViewTransition can hydrate correctly.
45
+ return <span id={useId()} />;
46
+}
47
+
48
export default function Page({url, navigate}) {
49
const [renderedUrl, startGesture] = useSwipeTransition('/?a', url, '/?b');
50
const show = renderedUrl === '/?b';
@@ -77,8 +83,12 @@ export default function Page({url, navigate}) {
83
</button>
84
<ViewTransition className="none">
85
<div>
80
- <ViewTransition className={transitions['slide-on-nav']}>
81
- <h1>{!show ? 'A' : 'B'}</h1>
86
+ <ViewTransition>
87
+ <div>
88
+ <ViewTransition className={transitions['slide-on-nav']}>
89
+ <h1>{!show ? 'A' : 'B'}</h1>
90
+ </ViewTransition>
91
+ </div>
92
</ViewTransition>
93
<ViewTransition
94
className={{
@@ -102,7 +112,9 @@ export default function Page({url, navigate}) {
112
{show ? <div>hello{exclamation}</div> : <section>Loading</section>}
113
</ViewTransition>
114
<p>scroll me</p>
105
- <p></p>
115
+ <p>
116
+ <Id />
117
+ </p>
118
<p></p>
119
<p></p>
120
<p></p>
packages/react-reconciler/src/ReactFiberBeginWork.js
+3
@@ -3298,6 +3298,9 @@ function updateViewTransition(
3298
// to client rendered content. If we don't end up using that we could just assign an incremeting
3299
// counter in the commit phase instead.
3300
assignViewTransitionAutoName(pendingProps, instance);
3301
+ if (getIsHydrating()) {
3302
+ pushMaterializedTreeId(workInProgress);
3303
+ }
3304
}
3305
if (current !== null && current.memoizedProps.name !== pendingProps.name) {
3306
// If the name changes, we schedule a ref effect to create a new ref instance.
packages/react-server/src/ReactFizzServer.js
+29
-4
@@ -2211,6 +2211,34 @@ function renderOffscreen(
2211
}
2212
}
2213
2214
+function renderViewTransition(
2215
+ request: Request,
2216
+ task: Task,
2217
+ keyPath: KeyNode,
2218
+ props: Object,
2219
+) {
2220
+ const prevKeyPath = task.keyPath;
2221
+ task.keyPath = keyPath;
2222
+ if (props.name != null && props.name !== 'auto') {
2223
+ renderNodeDestructive(request, task, props.children, -1);
2224
+ } else {
2225
+ // This will be auto-assigned a name which claims a "useId" slot.
2226
+ // This component materialized an id. We treat this as its own level, with
2227
+ // a single "child" slot.
2228
+ const prevTreeContext = task.treeContext;
2229
+ const totalChildren = 1;
2230
+ const index = 0;
2231
+ // Modify the id context. Because we'll need to reset this if something
2232
+ // suspends or errors, we'll use the non-destructive render path.
2233
+ task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index);
2234
+ renderNode(request, task, props.children, -1);
2235
+ // Like the other contexts, this does not need to be in a finally block
2236
+ // because renderNode takes care of unwinding the stack.
2237
+ task.treeContext = prevTreeContext;
2238
+ }
2239
+ task.keyPath = prevKeyPath;
2240
+}
2241
+
2242
function renderElement(
2243
request: Request,
2244
task: Task,
@@ -2267,10 +2295,7 @@ function renderElement(
2295
}
2296
case REACT_VIEW_TRANSITION_TYPE: {
2297
if (enableViewTransition) {
2270
- const prevKeyPath = task.keyPath;
2271
- task.keyPath = keyPath;
2272
- renderNodeDestructive(request, task, props.children, -1);
2273
- task.keyPath = prevKeyPath;
2298
+ renderViewTransition(request, task, keyPath, props);
2299
return;
2300
}
2301
// Fallthrough