Repository: facebook/react
Pull request: Warn if addTransitionType is called when there are no pending Actions

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

Changed files:
filename: packages/react-reconciler/src/ReactFiberHooks.js
patch: @@ -2219,6 +2219,11 @@ function handleActionReturnValue<S, P>(
     typeof returnValue.then === 'function'
   ) {
     const thenable = ((returnValue: any): Thenable<Awaited<S>>);
+    if (__DEV__) {
+      // Keep track of the number of async transitions still running so we can warn.
+      ReactSharedInternals.asyncTransitions++;
+      thenable.then(releaseAsyncTransition, releaseAsyncTransition);
+    }
     // Attach a listener to read the return state of the action. As soon as
     // this resolves, we can run the next action in the sequence.
     thenable.then(
@@ -3026,6 +3031,12 @@ function updateDeferredValueImpl<T>(
   }
 }
 
+function releaseAsyncTransition() {
+  if (__DEV__) {
+    ReactSharedInternals.asyncTransitions--;
+  }
+}
+
 function startTransition<S>(
   fiber: Fiber,
   queue: UpdateQueue<S | Thenable<S>, BasicStateAction<S | Thenable<S>>>,
@@ -3083,6 +3094,11 @@ function startTransition<S>(
       typeof returnValue.then === 'function'
     ) {
       const thenable = ((returnValue: any): Thenable<mixed>);
+      if (__DEV__) {
+        // Keep track of the number of async transitions still running so we can warn.
+        ReactSharedInternals.asyncTransitions++;
+        thenable.then(releaseAsyncTransition, releaseAsyncTransition);
+      }
       // Create a thenable that resolves to `finishedState` once the async
       // action has completed.
       const thenableForFinishedState = chainThenableValue(

filename: packages/react/src/ReactSharedInternalsClient.js
patch: @@ -39,6 +39,9 @@ export type SharedStateClient = {
   // ReactCurrentActQueue
   actQueue: null | Array<RendererTask>,
 
+  // When zero this means we're outside an async startTransition.
+  asyncTransitions: number,
+
   // Used to reproduce behavior of `batchedUpdates` in legacy mode.
   isBatchingLegacy: boolean,
   didScheduleLegacyUpdate: boolean,
@@ -75,6 +78,7 @@ if (enableViewTransition) {
 
 if (__DEV__) {
   ReactSharedInternals.actQueue = null;
+  ReactSharedInternals.asyncTransitions = 0;
   ReactSharedInternals.isBatchingLegacy = false;
   ReactSharedInternals.didScheduleLegacyUpdate = false;
   ReactSharedInternals.didUsePromise = false;

filename: packages/react/src/ReactStartTransition.js
patch: @@ -37,6 +37,12 @@ export type Transition = {
   ...
 };
 
+function releaseAsyncTransition() {
+  if (__DEV__) {
+    ReactSharedInternals.asyncTransitions--;
+  }
+}
+
 export function startTransition(
   scope: () => void,
   options?: StartTransitionOptions,
@@ -67,6 +73,11 @@ export function startTransition(
       returnValue !== null &&
       typeof returnValue.then === 'function'
     ) {
+      if (__DEV__) {
+        // Keep track of the number of async transitions still running so we can warn.
+        ReactSharedInternals.asyncTransitions++;
+        returnValue.then(releaseAsyncTransition, releaseAsyncTransition);
+      }
       returnValue.then(noop, reportGlobalError);
     }
   } catch (error) {

filename: packages/react/src/ReactTransitionType.js
patch: @@ -45,6 +45,25 @@ export function addTransitionType(type: string): void {
         pendingTransitionTypes = pendingGestureTransitionTypes = [];
       }
     } else {
+      if (__DEV__) {
+        if (
+          ReactSharedInternals.T === null &&
+          ReactSharedInternals.asyncTransitions === 0
+        ) {
+          if (enableGestureTransition) {
+            console.error(
+              'addTransitionType can only be called inside a `startTransition()` ' +
+                'or `startGestureTransition()` callback. ' +
+                'It must be associated with a specific Transition.',
+            );
+          } else {
+            console.error(
+              'addTransitionType can only be called inside a `startTransition()` ' +
+                'callback. It must be associated with a specific Transition.',
+            );
+          }
+        }
+      }
       // Otherwise we're either inside a synchronous startTransition
       // or in the async gap of one, which we track globally.
       pendingTransitionTypes = ReactSharedInternals.V;