@samitouri / QOS-React / commits / 9fde224a53

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