Repository: facebook/react
Pull request: [Flight] Skip `transferReferencedDebugInfo` during debug info resolution

When the Flight Client resolves chunk references during model parsing, it calls `transferReferencedDebugInfo` to propagate debug info entries from referenced chunks to the parent chunk. Debug info on chunks is later moved to their resolved values, where it is used by React DevTools to show performance tracks and what a component was suspended by.

Debug chunks themselves (specifically `ReactComponentInfo`, `ReactAsyncInfo`, `ReactIOInfo`, and their outlined references) are metadata that is never rendered. They don't need debug info attached to them. Without this fix, debug info entries accumulate on outlined debug chunks via their references to other debug chunks (e.g. owner chains and props deduplication paths). Since each outlined chunk's accumulated entries are copied to every chunk that references it, this creates exponential growth in deep component trees, which can cause the dev server to hang and run out of memory.

This generalizes the existing skip of `transferReferencedDebugInfo` for Element owner/stack references (which already recognizes that references to debug chunks don't need debug info transferred) to all references resolved during debug info resolution. It adds an `isInitializingDebugInfo` flag set in `initializeDebugChunk` and `resolveIOInfo`, which propagates through all nested `initializeModelChunk` calls within the same synchronous stack. For the async path, `waitForReference` captures the flag at call time into `InitializationReference.isDebug`, so deferred fulfillments also skip the transfer.

Changed files:
filename: packages/react-client/src/ReactFlightClient.js
patch: @@ -943,6 +943,7 @@ type InitializationHandler = {
 };
 let initializingHandler: null | InitializationHandler = null;
 let initializingChunk: null | BlockedChunk<any> = null;
+let isInitializingDebugInfo: boolean = false;
 
 function initializeDebugChunk(
   response: Response,
@@ -951,6 +952,8 @@ function initializeDebugChunk(
   const debugChunk = chunk._debugChunk;
   if (debugChunk !== null) {
     const debugInfo = chunk._debugInfo;
+    const prevIsInitializingDebugInfo = isInitializingDebugInfo;
+    isInitializingDebugInfo = true;
     try {
       if (debugChunk.status === RESOLVED_MODEL) {
         // Find the index of this debug info by walking the linked list.
@@ -1015,6 +1018,8 @@ function initializeDebugChunk(
       }
     } catch (error) {
       triggerErrorOnChunk(response, chunk, error);
+    } finally {
+      isInitializingDebugInfo = prevIsInitializingDebugInfo;
     }
   }
 }
@@ -1632,7 +1637,9 @@ function fulfillReference(
       const element: any = handler.value;
       switch (key) {
         case '3':
-          transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
+          if (__DEV__) {
+            transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
+          }
           element.props = mappedValue;
           break;
         case '4':
@@ -1648,7 +1655,9 @@ function fulfillReference(
           }
           break;
         default:
-          transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
+          if (__DEV__) {
+            transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
+          }
           break;
       }
     } else if (__DEV__ && !reference.isDebug) {
@@ -2086,7 +2095,7 @@ function getOutlinedModel<T>(
                 response,
                 map,
                 path.slice(i - 1),
-                false,
+                isInitializingDebugInfo,
               );
             }
             case HALTED: {
@@ -2158,14 +2167,21 @@ function getOutlinedModel<T>(
       }
 
       const chunkValue = map(response, value, parentObject, key);
-      if (
-        parentObject[0] === REACT_ELEMENT_TYPE &&
-        (key === '4' || key === '5')
-      ) {
-        // If we're resolving the "owner" or "stack" slot of an Element array, we don't call
-        // transferReferencedDebugInfo because this reference is to a debug chunk.
-      } else {
-        transferReferencedDebugInfo(initializingChunk, chunk);
+      if (__DEV__) {
+        if (
+          parentObject[0] === REACT_ELEMENT_TYPE &&
+          (key === '4' || key === '5')
+        ) {
+          // If we're resolving the "owner" or "stack" slot of an Element array,
+          // we don't call transferReferencedDebugInfo because this reference is
+          // to a debug chunk.
+        } else if (isInitializingDebugInfo) {
+          // If we're resolving references as part of debug info resolution, we
+          // don't call transferReferencedDebugInfo because these references are
+          // to debug chunks.
+        } else {
+          transferReferencedDebugInfo(initializingChunk, chunk);
+        }
       }
       return chunkValue;
     case PENDING:
@@ -2177,7 +2193,7 @@ function getOutlinedModel<T>(
         response,
         map,
         path,
-        false,
+        isInitializingDebugInfo,
       );
     case HALTED: {
       // Add a dependency that will never resolve.
@@ -4264,15 +4280,21 @@ function resolveIOInfo(
 ): void {
   const chunks = response._chunks;
   let chunk = chunks.get(id);
-  if (!chunk) {
-    chunk = createResolvedModelChunk(response, model);
-    chunks.set(id, chunk);
-    initializeModelChunk(chunk);
-  } else {
-    resolveModelChunk(response, chunk, model);
-    if (chunk.status === RESOLVED_MODEL) {
+  const prevIsInitializingDebugInfo = isInitializingDebugInfo;
+  isInitializingDebugInfo = true;
+  try {
+    if (!chunk) {
+      chunk = createResolvedModelChunk(response, model);
+      chunks.set(id, chunk);
       initializeModelChunk(chunk);
+    } else {
+      resolveModelChunk(response, chunk, model);
+      if (chunk.status === RESOLVED_MODEL) {
+        initializeModelChunk(chunk);
+      }
     }
+  } finally {
+    isInitializingDebugInfo = prevIsInitializingDebugInfo;
   }
   if (chunk.status === INITIALIZED) {
     initializeIOInfo(response, chunk.value);

filename: packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js
patch: @@ -3633,4 +3633,40 @@ describe('ReactFlightAsyncDebugInfo', () => {
       `);
     }
   });
+
+  it('should not exponentially accumulate debug info on outlined debug chunks', async () => {
+    // Regression test: Each Level wraps its received `context` prop in a new
+    // object before passing it down. This creates props deduplication
+    // references to the parent's outlined chunk alongside the owner reference,
+    // giving 2 references per level to the direct parent's chunk. Without
+    // skipping transferReferencedDebugInfo during debug info resolution, this
+    // test would fail with an infinite loop detection error.
+    async function Level({depth, context}) {
+      await delay(0);
+      if (depth === 0) {
+        return <div>Hello, World!</div>;
+      }
+      const newContext = {prev: context, id: depth};
+      return ReactServer.createElement(Level, {
+        depth: depth - 1,
+        context: newContext,
+      });
+    }
+
+    const stream = ReactServerDOMServer.renderToPipeableStream(
+      ReactServer.createElement(Level, {depth: 20, context: {root: true}}),
+    );
+
+    const readable = new Stream.PassThrough(streamOptions);
+    const result = ReactServerDOMClient.createFromNodeStream(readable, {
+      moduleMap: {},
+      moduleLoading: {},
+    });
+    stream.pipe(readable);
+
+    const resolved = await result;
+    expect(resolved.type).toBe('div');
+
+    await finishLoadingStream(readable);
+  });
 });