@samitouri / QOS-React-1 / commits / deca96520f

Warn if addTransitionType is called when there are no pending Actions (#32793)

Stacked on #32792. It's tricky to associate a specific `addTransitionType` call to a specific `startTransition` call because we don't have `AsyncContext` in browsers yet. However, we can keep track if there are any async transitions running at all, and if not, warn. This should cover most cases. This also errors when inside a React render which might be a legit way to associate a Transition Type to a specific render (e.g. based on props changing) but we want to be a more conservative about allowing that yet. If we wanted to support calling it in render, we might want to set which Transition object is currently rendering but it's still tricky if the render has `async function` components. So it might at least be restricted to sync components (like Hooks).

Sebastian Markbåge committed Apr 1, 2025 at 12:10 UTC deca96520f1e9e804b0e5b0d81563327d9c55521
4 files changed +50
packages/react-reconciler/src/ReactFiberHooks.js
+16
@@ -2219,6 +2219,11 @@ function handleActionReturnValue<S, P>(
2219 typeof returnValue.then === 'function'
2220 ) {
2221 const thenable = ((returnValue: any): Thenable<Awaited<S>>);
2222 + if (__DEV__) {
2223 + // Keep track of the number of async transitions still running so we can warn.
2224 + ReactSharedInternals.asyncTransitions++;
2225 + thenable.then(releaseAsyncTransition, releaseAsyncTransition);
2226 + }
2227 // Attach a listener to read the return state of the action. As soon as
2228 // this resolves, we can run the next action in the sequence.
2229 thenable.then(
@@ -3026,6 +3031,12 @@ function updateDeferredValueImpl<T>(
3031 }
3032 }
3033
3034 +function releaseAsyncTransition() {
3035 + if (__DEV__) {
3036 + ReactSharedInternals.asyncTransitions--;
3037 + }
3038 +}
3039 +
3040 function startTransition<S>(
3041 fiber: Fiber,
3042 queue: UpdateQueue<S | Thenable<S>, BasicStateAction<S | Thenable<S>>>,
@@ -3083,6 +3094,11 @@ function startTransition<S>(
3094 typeof returnValue.then === 'function'
3095 ) {
3096 const thenable = ((returnValue: any): Thenable<mixed>);
3097 + if (__DEV__) {
3098 + // Keep track of the number of async transitions still running so we can warn.
3099 + ReactSharedInternals.asyncTransitions++;
3100 + thenable.then(releaseAsyncTransition, releaseAsyncTransition);
3101 + }
3102 // Create a thenable that resolves to `finishedState` once the async
3103 // action has completed.
3104 const thenableForFinishedState = chainThenableValue(
packages/react/src/ReactSharedInternalsClient.js
+4
@@ -39,6 +39,9 @@ export type SharedStateClient = {
39 // ReactCurrentActQueue
40 actQueue: null | Array<RendererTask>,
41
42 + // When zero this means we're outside an async startTransition.
43 + asyncTransitions: number,
44 +
45 // Used to reproduce behavior of `batchedUpdates` in legacy mode.
46 isBatchingLegacy: boolean,
47 didScheduleLegacyUpdate: boolean,
@@ -75,6 +78,7 @@ if (enableViewTransition) {
78
79 if (__DEV__) {
80 ReactSharedInternals.actQueue = null;
81 + ReactSharedInternals.asyncTransitions = 0;
82 ReactSharedInternals.isBatchingLegacy = false;
83 ReactSharedInternals.didScheduleLegacyUpdate = false;
84 ReactSharedInternals.didUsePromise = false;
packages/react/src/ReactStartTransition.js
+11
@@ -37,6 +37,12 @@ export type Transition = {
37 ...
38 };
39
40 +function releaseAsyncTransition() {
41 + if (__DEV__) {
42 + ReactSharedInternals.asyncTransitions--;
43 + }
44 +}
45 +
46 export function startTransition(
47 scope: () => void,
48 options?: StartTransitionOptions,
@@ -67,6 +73,11 @@ export function startTransition(
73 returnValue !== null &&
74 typeof returnValue.then === 'function'
75 ) {
76 + if (__DEV__) {
77 + // Keep track of the number of async transitions still running so we can warn.
78 + ReactSharedInternals.asyncTransitions++;
79 + returnValue.then(releaseAsyncTransition, releaseAsyncTransition);
80 + }
81 returnValue.then(noop, reportGlobalError);
82 }
83 } catch (error) {
packages/react/src/ReactTransitionType.js
+19
@@ -45,6 +45,25 @@ export function addTransitionType(type: string): void {
45 pendingTransitionTypes = pendingGestureTransitionTypes = [];
46 }
47 } else {
48 + if (__DEV__) {
49 + if (
50 + ReactSharedInternals.T === null &&
51 + ReactSharedInternals.asyncTransitions === 0
52 + ) {
53 + if (enableGestureTransition) {
54 + console.error(
55 + 'addTransitionType can only be called inside a `startTransition()` ' +
56 + 'or `startGestureTransition()` callback. ' +
57 + 'It must be associated with a specific Transition.',
58 + );
59 + } else {
60 + console.error(
61 + 'addTransitionType can only be called inside a `startTransition()` ' +
62 + 'callback. It must be associated with a specific Transition.',
63 + );
64 + }
65 + }
66 + }
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;