Repository: facebook/react
Pull request: [Fiber] Add additional debugInfo to React.lazy constructors in DEV

This creates a debug info object for the React.lazy call when it's called on the client. We have some additional information we can track for these since they're created by React earlier.

We can track the stack trace where `React.lazy` was called to associate it back to something useful. We can track the start time when we initialized it for the first time and the end time when it resolves. The name from the promise if available.

This data is currently only picked up in child position and not component position. The component position is in a follow up.

<img width="592" height="451" alt="Screenshot 2025-08-08 at 2 49 33 PM" src="https://github.com/user-attachments/assets/913d2629-6df5-40f6-b036-ae13631379b9" />

This begs for ignore listing in the front end since these stacks aren't filtered on the server.

Changed files:
filename: packages/react-client/src/__tests__/ReactFlight-test.js
patch: @@ -2822,7 +2822,7 @@ describe('ReactFlight', () => {
       expect(getDebugInfo(promise)).toEqual(
         __DEV__
           ? [
-              {time: 20},
+              {time: gate(flags => flags.enableAsyncDebugInfo) ? 22 : 20},
               {
                 name: 'ServerComponent',
                 env: 'Server',
@@ -2832,7 +2832,7 @@ describe('ReactFlight', () => {
                   transport: expect.arrayContaining([]),
                 },
               },
-              {time: 21},
+              {time: gate(flags => flags.enableAsyncDebugInfo) ? 23 : 21},
             ]
           : undefined,
       );
@@ -2843,46 +2843,46 @@ describe('ReactFlight', () => {
       expect(getDebugInfo(thirdPartyChildren[0])).toEqual(
         __DEV__
           ? [
-              {time: 22}, // Clamped to the start
+              {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22}, // Clamped to the start
               {
                 name: 'ThirdPartyComponent',
                 env: 'third-party',
                 key: null,
                 stack: '    in Object.<anonymous> (at **)',
                 props: {},
               },
-              {time: 22},
-              {time: 23}, // This last one is when the promise resolved into the first party.
+              {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22},
+              {time: gate(flags => flags.enableAsyncDebugInfo) ? 25 : 23}, // This last one is when the promise resolved into the first party.
             ]
           : undefined,
       );
       expect(getDebugInfo(thirdPartyChildren[1])).toEqual(
         __DEV__
           ? [
-              {time: 22}, // Clamped to the start
+              {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22}, // Clamped to the start
               {
                 name: 'ThirdPartyLazyComponent',
                 env: 'third-party',
                 key: null,
                 stack: '    in myLazy (at **)\n    in lazyInitializer (at **)',
                 props: {},
               },
-              {time: 22},
+              {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22},
             ]
           : undefined,
       );
       expect(getDebugInfo(thirdPartyChildren[2])).toEqual(
         __DEV__
           ? [
-              {time: 22},
+              {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22},
               {
                 name: 'ThirdPartyFragmentComponent',
                 env: 'third-party',
                 key: '3',
                 stack: '    in Object.<anonymous> (at **)',
                 props: {},
               },
-              {time: 22},
+              {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22},
             ]
           : undefined,
       );

filename: packages/react/src/ReactLazy.js
patch: @@ -7,7 +7,16 @@
  * @flow
  */
 
-import type {Wakeable, Thenable, ReactDebugInfo} from 'shared/ReactTypes';
+import type {
+  Wakeable,
+  Thenable,
+  FulfilledThenable,
+  RejectedThenable,
+  ReactDebugInfo,
+  ReactIOInfo,
+} from 'shared/ReactTypes';
+
+import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags';
 
 import {REACT_LAZY_TYPE} from 'shared/ReactSymbols';
 
@@ -19,21 +28,25 @@ const Rejected = 2;
 type UninitializedPayload<T> = {
   _status: -1,
   _result: () => Thenable<{default: T, ...}>,
+  _ioInfo?: ReactIOInfo, // DEV-only
 };
 
 type PendingPayload = {
   _status: 0,
   _result: Wakeable,
+  _ioInfo?: ReactIOInfo, // DEV-only
 };
 
 type ResolvedPayload<T> = {
   _status: 1,
   _result: {default: T, ...},
+  _ioInfo?: ReactIOInfo, // DEV-only
 };
 
 type RejectedPayload = {
   _status: 2,
   _result: mixed,
+  _ioInfo?: ReactIOInfo, // DEV-only
 };
 
 type Payload<T> =
@@ -51,6 +64,14 @@ export type LazyComponent<T, P> = {
 
 function lazyInitializer<T>(payload: Payload<T>): T {
   if (payload._status === Uninitialized) {
+    if (__DEV__ && enableAsyncDebugInfo) {
+      const ioInfo = payload._ioInfo;
+      if (ioInfo != null) {
+        // Mark when we first kicked off the lazy request.
+        // $FlowFixMe[cannot-write]
+        ioInfo.start = ioInfo.end = performance.now();
+      }
+    }
     const ctor = payload._result;
     const thenable = ctor();
     // Transition to the next state.
@@ -68,6 +89,21 @@ function lazyInitializer<T>(payload: Payload<T>): T {
           const resolved: ResolvedPayload<T> = (payload: any);
           resolved._status = Resolved;
           resolved._result = moduleObject;
+          if (__DEV__) {
+            const ioInfo = payload._ioInfo;
+            if (ioInfo != null) {
+              // Mark the end time of when we resolved.
+              // $FlowFixMe[cannot-write]
+              ioInfo.end = performance.now();
+            }
+            // Make the thenable introspectable
+            if (thenable.status === undefined) {
+              const fulfilledThenable: FulfilledThenable<{default: T, ...}> =
+                (thenable: any);
+              fulfilledThenable.status = 'fulfilled';
+              fulfilledThenable.value = moduleObject;
+            }
+          }
         }
       },
       error => {
@@ -79,9 +115,37 @@ function lazyInitializer<T>(payload: Payload<T>): T {
           const rejected: RejectedPayload = (payload: any);
           rejected._status = Rejected;
           rejected._result = error;
+          if (__DEV__ && enableAsyncDebugInfo) {
+            const ioInfo = payload._ioInfo;
+            if (ioInfo != null) {
+              // Mark the end time of when we rejected.
+              // $FlowFixMe[cannot-write]
+              ioInfo.end = performance.now();
+            }
+            // Make the thenable introspectable
+            if (thenable.status === undefined) {
+              const rejectedThenable: RejectedThenable<{default: T, ...}> =
+                (thenable: any);
+              rejectedThenable.status = 'rejected';
+              rejectedThenable.reason = error;
+            }
+          }
         }
       },
     );
+    if (__DEV__ && enableAsyncDebugInfo) {
+      const ioInfo = payload._ioInfo;
+      if (ioInfo != null) {
+        // Stash the thenable for introspection of the value later.
+        // $FlowFixMe[cannot-write]
+        ioInfo.value = thenable;
+        const displayName = thenable.displayName;
+        if (typeof displayName === 'string') {
+          // $FlowFixMe[cannot-write]
+          ioInfo.name = displayName;
+        }
+      }
+    }
     if (payload._status === Uninitialized) {
       // In case, we're still uninitialized, then we're waiting for the thenable
       // to resolve. Set it as pending in the meantime.
@@ -140,5 +204,26 @@ export function lazy<T>(
     _init: lazyInitializer,
   };
 
+  if (__DEV__ && enableAsyncDebugInfo) {
+    // TODO: We should really track the owner here but currently ReactIOInfo
+    // can only contain ReactComponentInfo and not a Fiber. It's unusual to
+    // create a lazy inside an owner though since they should be in module scope.
+    const owner = null;
+    const ioInfo: ReactIOInfo = {
+      name: 'lazy',
+      start: -1,
+      end: -1,
+      value: null,
+      owner: owner,
+      debugStack: new Error('react-stack-top-frame'),
+      // eslint-disable-next-line react-internal/no-production-logging
+      debugTask: console.createTask ? console.createTask('lazy()') : null,
+    };
+    payload._ioInfo = ioInfo;
+    // Add debug info to the lazy, but this doesn't have an await stack yet.
+    // That will be inferred by later usage.
+    lazyType._debugInfo = [{awaited: ioInfo}];
+  }
+
   return lazyType;
 }

filename: packages/shared/ReactTypes.js
patch: @@ -108,6 +108,7 @@ interface ThenableImpl<T> {
     onFulfill: (value: T) => mixed,
     onReject: (error: mixed) => mixed,
   ): void | Wakeable;
+  displayName?: string;
 }
 interface UntrackedThenable<T> extends ThenableImpl<T> {
   status?: void;