@samitouri / QOS-React-1 / commits / 731ae3e0ad

Solidify addTransitionType Semantics (#32797)

Stacked on #32793. This is meant to model the intended semantics of `addTransitionType` better. The previous hack just consumed all transition types when any root committed so it could steal them from other roots. Really each root should get its own set. Really each transition lane should get its own set. We can't implement the full ideal semantics yet because 1) we currently entangle transition lanes 2) we lack `AsyncContext` on the client so for async actions we can't associate a `addTransitionType` call to a specific `startTransition`. This starts by modeling Transition Types to be stored on the Transition instance. Conceptually they belong to the Transition instance of that `startTransition` they belong to. That instance is otherwise mostly just used for Transition Tracing but it makes sense that those would be able to be passed the Transition Types for that specific instance. Nested `startTransition` need to get entangled. So that this `addTransitionType` can be associated with the `setState`: ```js startTransition(() => { startTransition(() => { addTransitionType(...) }); setState(...); }); ``` Ideally we'd probably just use the same Transition instance itself since these are conceptually all part of one entangled one. But transition tracing uses multiple names and start times. Unclear what we want to do with that. So I kept separate instances but shared `types` set. Next I collect the types added during a `startTransition` to any root scheduled with a Transition. This should really be collected one set per Transition lane in a `LaneMap`. In fact, the information would already be there if Transition Tracing was always enabled because it tracks all Transition instances per lane. For now I just keep track of one set for all Transition lanes. Maybe we should only add it if a `setState` was done on this root in this particular `startTransition` call rather having already scheduled any Transition earlier. While async transitions are entangled, we don't know if there will be a startTransition+setState on a new root in the future. Therefore, we collect all transition types while this is happening and if a new root gets startTransition+setState they get added to that root. ```js startTransition(async () => { addTransitionType(...) await ...; setState(...); }); ```

Sebastian Markbåge committed Apr 1, 2025 at 12:11 UTC 731ae3e0ade1ac2a79e5f9f52b3244f3d02d5ac8
10 files changed +227 -71
packages/react-reconciler/src/ReactFiberAsyncAction.js
+2
@@ -25,6 +25,7 @@ import {
25 enableComponentPerformanceTrack,
26 enableProfilerTimer,
27 } from 'shared/ReactFeatureFlags';
28 +import {clearEntangledAsyncTransitionTypes} from './ReactFiberTransitionTypes';
29
30 // If there are multiple, concurrent async actions, they are entangled. All
31 // transition updates that occur while the async action is still in progress
@@ -84,6 +85,7 @@ function pingEngtangledActionScope() {
85 clearAsyncTransitionTimer();
86 }
87 }
88 + clearEntangledAsyncTransitionTypes();
89 if (currentEntangledListeners !== null) {
90 // All the actions have finished. Close the entangled async action scope
91 // and notify all the listeners.
packages/react-reconciler/src/ReactFiberHooks.js
+59
@@ -42,6 +42,7 @@ import {
42 enableLegacyCache,
43 disableLegacyMode,
44 enableNoCloningMemoCache,
45 + enableViewTransition,
46 enableGestureTransition,
47 } from 'shared/ReactFeatureFlags';
48 import {
@@ -2159,6 +2160,17 @@ function runActionStateAction<S, P>(
2160 // This is a fork of startTransition
2161 const prevTransition = ReactSharedInternals.T;
2162 const currentTransition: Transition = ({}: any);
2163 + if (enableViewTransition) {
2164 + currentTransition.types =
2165 + prevTransition !== null
2166 + ? // If we're a nested transition, we should use the same set as the parent
2167 + // since we're conceptually always joined into the same entangled transition.
2168 + // In practice, this only matters if we add transition types in the inner
2169 + // without setting state. In that case, the inner transition can finish
2170 + // without waiting for the outer.
2171 + prevTransition.types
2172 + : null;
2173 + }
2174 if (enableGestureTransition) {
2175 currentTransition.gesture = null;
2176 }
@@ -2180,6 +2192,24 @@ function runActionStateAction<S, P>(
2192 } catch (error) {
2193 onActionError(actionQueue, node, error);
2194 } finally {
2195 + if (prevTransition !== null && currentTransition.types !== null) {
2196 + // If we created a new types set in the inner transition, we transfer it to the parent
2197 + // since they should share the same set. They're conceptually entangled.
2198 + if (__DEV__) {
2199 + if (
2200 + prevTransition.types !== null &&
2201 + prevTransition.types !== currentTransition.types
2202 + ) {
2203 + // Just assert that assumption holds that we're not overriding anything.
2204 + console.error(
2205 + 'We expected inner Transitions to have transferred the outer types set and ' +
2206 + 'that you cannot add to the outer Transition while inside the inner.' +
2207 + 'This is a bug in React.',
2208 + );
2209 + }
2210 + }
2211 + prevTransition.types = currentTransition.types;
2212 + }
2213 ReactSharedInternals.T = prevTransition;
2214
2215 if (__DEV__) {
@@ -3052,6 +3082,17 @@ function startTransition<S>(
3082
3083 const prevTransition = ReactSharedInternals.T;
3084 const currentTransition: Transition = ({}: any);
3085 + if (enableViewTransition) {
3086 + currentTransition.types =
3087 + prevTransition !== null
3088 + ? // If we're a nested transition, we should use the same set as the parent
3089 + // since we're conceptually always joined into the same entangled transition.
3090 + // In practice, this only matters if we add transition types in the inner
3091 + // without setting state. In that case, the inner transition can finish
3092 + // without waiting for the outer.
3093 + prevTransition.types
3094 + : null;
3095 + }
3096 if (enableGestureTransition) {
3097 currentTransition.gesture = null;
3098 }
@@ -3137,6 +3178,24 @@ function startTransition<S>(
3178 } finally {
3179 setCurrentUpdatePriority(previousPriority);
3180
3181 + if (prevTransition !== null && currentTransition.types !== null) {
3182 + // If we created a new types set in the inner transition, we transfer it to the parent
3183 + // since they should share the same set. They're conceptually entangled.
3184 + if (__DEV__) {
3185 + if (
3186 + prevTransition.types !== null &&
3187 + prevTransition.types !== currentTransition.types
3188 + ) {
3189 + // Just assert that assumption holds that we're not overriding anything.
3190 + console.error(
3191 + 'We expected inner Transitions to have transferred the outer types set and ' +
3192 + 'that you cannot add to the outer Transition while inside the inner.' +
3193 + 'This is a bug in React.',
3194 + );
3195 + }
3196 + }
3197 + prevTransition.types = currentTransition.types;
3198 + }
3199 ReactSharedInternals.T = prevTransition;
3200
3201 if (__DEV__) {
packages/react-reconciler/src/ReactFiberRoot.js
+5
@@ -33,6 +33,7 @@ import {
33 enableUpdaterTracking,
34 enableTransitionTracing,
35 disableLegacyMode,
36 + enableViewTransition,
37 enableGestureTransition,
38 } from 'shared/ReactFeatureFlags';
39 import {initializeUpdateQueue} from './ReactFiberClassUpdateQueue';
@@ -98,6 +99,10 @@ function FiberRootNode(
99
100 this.formState = formState;
101
102 + if (enableViewTransition) {
103 + this.transitionTypes = null;
104 + }
105 +
106 if (enableGestureTransition) {
107 this.pendingGestures = null;
108 this.stoppingGestures = null;
packages/react-reconciler/src/ReactFiberTransition.js
+39 -6
@@ -12,15 +12,15 @@ import type {
12 GestureProvider,
13 GestureOptions,
14 } from 'shared/ReactTypes';
15 -import type {Lanes} from './ReactFiberLane';
15 +import {NoLane, type Lanes} from './ReactFiberLane';
16 import type {StackCursor} from './ReactFiberStack';
17 import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent';
18 import type {Transition} from 'react/src/ReactStartTransition';
19 import type {ScheduledGesture} from './ReactFiberGestureScheduler';
20 -import type {TransitionTypes} from 'react/src/ReactTransitionType';
20
21 import {
22 enableTransitionTracing,
23 + enableViewTransition,
24 enableGestureTransition,
25 } from 'shared/ReactFeatureFlags';
26 import {isPrimaryRenderer} from './ReactFiberConfig';
@@ -34,9 +34,17 @@ import {
34 retainCache,
35 CacheContext,
36 } from './ReactFiberCacheComponent';
37 +import {
38 + queueTransitionTypes,
39 + entangleAsyncTransitionTypes,
40 + entangledTransitionTypes,
41 +} from './ReactFiberTransitionTypes';
42
43 import ReactSharedInternals from 'shared/ReactSharedInternals';
39 -import {entangleAsyncAction} from './ReactFiberAsyncAction';
44 +import {
45 + entangleAsyncAction,
46 + peekEntangledActionLane,
47 +} from './ReactFiberAsyncAction';
48 import {startAsyncTransitionTimer} from './ReactProfilerTimer';
49 import {firstScheduledRoot} from './ReactFiberRootScheduler';
50 import {
@@ -87,6 +95,33 @@ ReactSharedInternals.S = function onStartTransitionFinishForReconciler(
95 const thenable: Thenable<mixed> = (returnValue: any);
96 entangleAsyncAction(transition, thenable);
97 }
98 + if (enableViewTransition) {
99 + if (entangledTransitionTypes !== null) {
100 + // If we scheduled work on any new roots, we need to add any entangled async
101 + // transition types to those roots too.
102 + let root = firstScheduledRoot;
103 + while (root !== null) {
104 + queueTransitionTypes(root, entangledTransitionTypes);
105 + root = root.next;
106 + }
107 + }
108 + const transitionTypes = transition.types;
109 + if (transitionTypes !== null) {
110 + // Within this Transition we should've now scheduled any roots we have updates
111 + // to work on. If there are no updates on a root, then the Transition type won't
112 + // be applied to that root.
113 + let root = firstScheduledRoot;
114 + while (root !== null) {
115 + queueTransitionTypes(root, transitionTypes);
116 + root = root.next;
117 + }
118 + if (peekEntangledActionLane() !== NoLane) {
119 + // If we have entangled, async actions going on, the update associated with
120 + // these types might come later. We need to save them for later.
121 + entangleAsyncTransitionTypes(transitionTypes);
122 + }
123 + }
124 + }
125 if (prevOnStartTransitionFinish !== null) {
126 prevOnStartTransitionFinish(transition, returnValue);
127 }
@@ -113,7 +148,6 @@ if (enableGestureTransition) {
148 transition: Transition,
149 provider: GestureProvider,
150 options: ?GestureOptions,
116 - transitionTypes: null | TransitionTypes,
151 ): () => void {
152 let cancel = null;
153 if (prevOnStartGestureTransitionFinish !== null) {
@@ -121,7 +155,6 @@ if (enableGestureTransition) {
155 transition,
156 provider,
157 options,
124 - transitionTypes,
158 );
159 }
160 // For every root that has work scheduled, check if there's a ScheduledGesture
@@ -138,7 +171,7 @@ if (enableGestureTransition) {
171 root,
172 provider,
173 options,
141 - transitionTypes,
174 + transition.types,
175 );
176 if (scheduledGesture !== null) {
177 cancel = chainGestureCancellation(root, scheduledGesture, cancel);
packages/react-reconciler/src/ReactFiberTransitionTypes.js new
+70
@@ -0,0 +1,70 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {FiberRoot} from './ReactInternalTypes';
11 +import type {TransitionTypes} from 'react/src/ReactTransitionType';
12 +
13 +import {enableViewTransition} from 'shared/ReactFeatureFlags';
14 +import {includesTransitionLane} from './ReactFiberLane';
15 +
16 +export function queueTransitionTypes(
17 + root: FiberRoot,
18 + transitionTypes: TransitionTypes,
19 +): void {
20 + if (enableViewTransition) {
21 + // TODO: We should really store transitionTypes per lane in a LaneMap on
22 + // the root. Then merge it when we commit. We currently assume that all
23 + // Transitions are entangled.
24 + if (includesTransitionLane(root.pendingLanes)) {
25 + let queued = root.transitionTypes;
26 + if (queued === null) {
27 + queued = root.transitionTypes = [];
28 + }
29 + for (let i = 0; i < transitionTypes.length; i++) {
30 + const transitionType = transitionTypes[i];
31 + if (queued.indexOf(transitionType) === -1) {
32 + queued.push(transitionType);
33 + }
34 + }
35 + }
36 + }
37 +}
38 +
39 +// Store all types while we're entangled with an async Transition.
40 +export let entangledTransitionTypes: null | TransitionTypes = null;
41 +
42 +export function entangleAsyncTransitionTypes(
43 + transitionTypes: TransitionTypes,
44 +): void {
45 + if (enableViewTransition) {
46 + let queued = entangledTransitionTypes;
47 + if (queued === null) {
48 + queued = entangledTransitionTypes = [];
49 + }
50 + for (let i = 0; i < transitionTypes.length; i++) {
51 + const transitionType = transitionTypes[i];
52 + if (queued.indexOf(transitionType) === -1) {
53 + queued.push(transitionType);
54 + }
55 + }
56 + }
57 +}
58 +
59 +export function clearEntangledAsyncTransitionTypes() {
60 + // Called when all Async Actions are done.
61 + entangledTransitionTypes = null;
62 +}
63 +
64 +export function claimQueuedTransitionTypes(
65 + root: FiberRoot,
66 +): null | TransitionTypes {
67 + const claimed = root.transitionTypes;
68 + root.transitionTypes = null;
69 + return claimed;
70 +}
packages/react-reconciler/src/ReactFiberWorkLoop.js
+2 -5
@@ -358,6 +358,7 @@ import {
358 deleteScheduledGesture,
359 stopCompletedGestures,
360 } from './ReactFiberGestureScheduler';
361 +import {claimQueuedTransitionTypes} from './ReactFiberTransitionTypes';
362
363 const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
364
@@ -3404,11 +3405,7 @@ function commitRoot(
3405 pendingViewTransitionEvents = null;
3406 if (includesOnlyViewTransitionEligibleLanes(lanes)) {
3407 // Claim any pending Transition Types for this commit.
3407 - // This means that multiple roots committing independent View Transitions
3408 - // 1) end up staggered because we can only have one at a time.
3409 - // 2) only the first one gets all the Transition Types.
3410 - pendingTransitionTypes = ReactSharedInternals.V;
3411 - ReactSharedInternals.V = null;
3408 + pendingTransitionTypes = claimQueuedTransitionTypes(root);
3409 passiveSubtreeMask = PassiveTransitionMask;
3410 } else {
3411 pendingTransitionTypes = null;
packages/react-reconciler/src/ReactInternalTypes.js
+3
@@ -18,6 +18,7 @@ import type {
18 ReactComponentInfo,
19 ReactDebugInfo,
20 } from 'shared/ReactTypes';
21 +import type {TransitionTypes} from 'react/src/ReactTransitionType';
22 import type {WorkTag} from './ReactWorkTags';
23 import type {TypeOfMode} from './ReactTypeOfMode';
24 import type {Flags} from './ReactFiberFlags';
@@ -280,6 +281,8 @@ type BaseFiberRootProperties = {
281
282 formState: ReactFormState<any, any> | null,
283
284 + // enableViewTransition only
285 + transitionTypes: null | TransitionTypes, // TODO: Make this a LaneMap.
286 // enableGestureTransition only
287 pendingGestures: null | ScheduledGesture,
288 stoppingGestures: null | ScheduledGesture,
packages/react/src/ReactSharedInternalsClient.js
+1 -10
@@ -10,20 +10,15 @@
10 import type {Dispatcher} from 'react-reconciler/src/ReactInternalTypes';
11 import type {AsyncDispatcher} from 'react-reconciler/src/ReactInternalTypes';
12 import type {Transition} from './ReactStartTransition';
13 -import type {TransitionTypes} from './ReactTransitionType';
13 import type {GestureProvider, GestureOptions} from 'shared/ReactTypes';
14
16 -import {
17 - enableViewTransition,
18 - enableGestureTransition,
19 -} from 'shared/ReactFeatureFlags';
15 +import {enableGestureTransition} from 'shared/ReactFeatureFlags';
16
17 type onStartTransitionFinish = (Transition, mixed) => void;
18 type onStartGestureTransitionFinish = (
19 Transition,
20 GestureProvider,
21 ?GestureOptions,
26 - transitionTypes: null | TransitionTypes,
22 ) => () => void;
23
24 export type SharedStateClient = {
@@ -32,7 +27,6 @@ export type SharedStateClient = {
27 T: null | Transition, // ReactCurrentBatchConfig for Transitions
28 S: null | onStartTransitionFinish,
29 G: null | onStartGestureTransitionFinish,
35 - V: null | TransitionTypes, // Pending Transition Types for the Next Transition
30
31 // DEV-only
32
@@ -72,9 +66,6 @@ const ReactSharedInternals: SharedStateClient = ({
66 if (enableGestureTransition) {
67 ReactSharedInternals.G = null;
68 }
75 -if (enableViewTransition) {
76 - ReactSharedInternals.V = null;
77 -}
69
70 if (__DEV__) {
71 ReactSharedInternals.actQueue = null;
packages/react/src/ReactStartTransition.js
+35 -11
@@ -13,23 +13,20 @@ import type {
13 GestureProvider,
14 GestureOptions,
15 } from 'shared/ReactTypes';
16 +import type {TransitionTypes} from './ReactTransitionType';
17
18 import ReactSharedInternals from 'shared/ReactSharedInternals';
19
20 import {
21 enableTransitionTracing,
22 + enableViewTransition,
23 enableGestureTransition,
24 } from 'shared/ReactFeatureFlags';
25
24 -import {
25 - pendingGestureTransitionTypes,
26 - pushPendingGestureTransitionTypes,
27 - popPendingGestureTransitionTypes,
28 -} from './ReactTransitionType';
29 -
26 import reportGlobalError from 'shared/reportGlobalError';
27
28 export type Transition = {
29 + types: null | TransitionTypes, // enableViewTransition
30 gesture: null | GestureProvider, // enableGestureTransition
31 name: null | string, // enableTransitionTracing only
32 startTime: number, // enableTransitionTracing only
@@ -49,6 +46,17 @@ export function startTransition(
46 ): void {
47 const prevTransition = ReactSharedInternals.T;
48 const currentTransition: Transition = ({}: any);
49 + if (enableViewTransition) {
50 + currentTransition.types =
51 + prevTransition !== null
52 + ? // If we're a nested transition, we should use the same set as the parent
53 + // since we're conceptually always joined into the same entangled transition.
54 + // In practice, this only matters if we add transition types in the inner
55 + // without setting state. In that case, the inner transition can finish
56 + // without waiting for the outer.
57 + prevTransition.types
58 + : null;
59 + }
60 if (enableGestureTransition) {
61 currentTransition.gesture = null;
62 }
@@ -84,6 +92,24 @@ export function startTransition(
92 reportGlobalError(error);
93 } finally {
94 warnAboutTransitionSubscriptions(prevTransition, currentTransition);
95 + if (prevTransition !== null && currentTransition.types !== null) {
96 + // If we created a new types set in the inner transition, we transfer it to the parent
97 + // since they should share the same set. They're conceptually entangled.
98 + if (__DEV__) {
99 + if (
100 + prevTransition.types !== null &&
101 + prevTransition.types !== currentTransition.types
102 + ) {
103 + // Just assert that assumption holds that we're not overriding anything.
104 + console.error(
105 + 'We expected inner Transitions to have transferred the outer types set and ' +
106 + 'that you cannot add to the outer Transition while inside the inner.' +
107 + 'This is a bug in React.',
108 + );
109 + }
110 + }
111 + prevTransition.types = currentTransition.types;
112 + }
113 ReactSharedInternals.T = prevTransition;
114 }
115 }
@@ -109,6 +135,9 @@ export function startGestureTransition(
135 }
136 const prevTransition = ReactSharedInternals.T;
137 const currentTransition: Transition = ({}: any);
138 + if (enableViewTransition) {
139 + currentTransition.types = null;
140 + }
141 if (enableGestureTransition) {
142 currentTransition.gesture = provider;
143 }
@@ -122,8 +151,6 @@ export function startGestureTransition(
151 }
152 ReactSharedInternals.T = currentTransition;
153
125 - const prevTransitionTypes = pushPendingGestureTransitionTypes();
126 -
154 try {
155 const returnValue = scope();
156 if (__DEV__) {
@@ -137,20 +164,17 @@ export function startGestureTransition(
164 );
165 }
166 }
140 - const transitionTypes = pendingGestureTransitionTypes;
167 const onStartGestureTransitionFinish = ReactSharedInternals.G;
168 if (onStartGestureTransitionFinish !== null) {
169 return onStartGestureTransitionFinish(
170 currentTransition,
171 provider,
172 options,
147 - transitionTypes,
173 );
174 }
175 } catch (error) {
176 reportGlobalError(error);
177 } finally {
153 - popPendingGestureTransitionTypes(prevTransitionTypes);
178 ReactSharedInternals.T = prevTransition;
179 }
180 return function cancelGesture() {
packages/react/src/ReactTransitionType.js
+11 -39
@@ -12,44 +12,24 @@ import {
12 enableViewTransition,
13 enableGestureTransition,
14 } from 'shared/ReactFeatureFlags';
15 +import {startTransition} from './ReactStartTransition';
16
17 export type TransitionTypes = Array<string>;
18
18 -// This one is only available synchronously so we don't need to use ReactSharedInternals
19 -// for this state. Instead, we track it in isomorphic and pass it to the renderer.
20 -export let pendingGestureTransitionTypes: null | TransitionTypes = null;
21 -
22 -export function pushPendingGestureTransitionTypes(): null | TransitionTypes {
23 - const prev = pendingGestureTransitionTypes;
24 - pendingGestureTransitionTypes = null;
25 - return prev;
26 -}
27 -
28 -export function popPendingGestureTransitionTypes(
29 - prev: null | TransitionTypes,
30 -): void {
31 - pendingGestureTransitionTypes = prev;
32 -}
33 -
19 export function addTransitionType(type: string): void {
20 if (enableViewTransition) {
36 - let pendingTransitionTypes: null | TransitionTypes;
37 - if (
38 - enableGestureTransition &&
39 - ReactSharedInternals.T !== null &&
40 - ReactSharedInternals.T.gesture !== null
41 - ) {
42 - // We're inside a startGestureTransition which is always sync.
43 - pendingTransitionTypes = pendingGestureTransitionTypes;
44 - if (pendingTransitionTypes === null) {
45 - pendingTransitionTypes = pendingGestureTransitionTypes = [];
21 + const transition = ReactSharedInternals.T;
22 + if (transition !== null) {
23 + const transitionTypes = transition.types;
24 + if (transitionTypes === null) {
25 + transition.types = [type];
26 + } else if (transitionTypes.indexOf(type) === -1) {
27 + transitionTypes.push(type);
28 }
29 } else {
30 + // We're in the async gap. Simulate an implicit startTransition around it.
31 if (__DEV__) {
49 - if (
50 - ReactSharedInternals.T === null &&
51 - ReactSharedInternals.asyncTransitions === 0
52 - ) {
32 + if (ReactSharedInternals.asyncTransitions === 0) {
33 if (enableGestureTransition) {
34 console.error(
35 'addTransitionType can only be called inside a `startTransition()` ' +
@@ -64,15 +44,7 @@ export function addTransitionType(type: string): void {
44 }
45 }
46 }
67 - // Otherwise we're either inside a synchronous startTransition
68 - // or in the async gap of one, which we track globally.
69 - pendingTransitionTypes = ReactSharedInternals.V;
70 - if (pendingTransitionTypes === null) {
71 - pendingTransitionTypes = ReactSharedInternals.V = [];
72 - }
73 - }
74 - if (pendingTransitionTypes.indexOf(type) === -1) {
75 - pendingTransitionTypes.push(type);
47 + startTransition(addTransitionType.bind(null, type));
48 }
49 }
50 }