@samitouri / QOS-React-2 / commits / 47d1ad1454

[Flight] Skip `transferReferencedDebugInfo` during debug info resolution (#35795)

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.

Hendrik Liebau committed Feb 16, 2026 at 09:22 UTC 47d1ad1454759859c5a2b29616658e10a1ce049f
2 files changed +77 -19
packages/react-client/src/ReactFlightClient.js
+41 -19
@@ -943,6 +943,7 @@ type InitializationHandler = {
943 };
944 let initializingHandler: null | InitializationHandler = null;
945 let initializingChunk: null | BlockedChunk<any> = null;
946 +let isInitializingDebugInfo: boolean = false;
947
948 function initializeDebugChunk(
949 response: Response,
@@ -951,6 +952,8 @@ function initializeDebugChunk(
952 const debugChunk = chunk._debugChunk;
953 if (debugChunk !== null) {
954 const debugInfo = chunk._debugInfo;
955 + const prevIsInitializingDebugInfo = isInitializingDebugInfo;
956 + isInitializingDebugInfo = true;
957 try {
958 if (debugChunk.status === RESOLVED_MODEL) {
959 // Find the index of this debug info by walking the linked list.
@@ -1015,6 +1018,8 @@ function initializeDebugChunk(
1018 }
1019 } catch (error) {
1020 triggerErrorOnChunk(response, chunk, error);
1021 + } finally {
1022 + isInitializingDebugInfo = prevIsInitializingDebugInfo;
1023 }
1024 }
1025 }
@@ -1632,7 +1637,9 @@ function fulfillReference(
1637 const element: any = handler.value;
1638 switch (key) {
1639 case '3':
1635 - transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
1640 + if (__DEV__) {
1641 + transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
1642 + }
1643 element.props = mappedValue;
1644 break;
1645 case '4':
@@ -1648,7 +1655,9 @@ function fulfillReference(
1655 }
1656 break;
1657 default:
1651 - transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
1658 + if (__DEV__) {
1659 + transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
1660 + }
1661 break;
1662 }
1663 } else if (__DEV__ && !reference.isDebug) {
@@ -2086,7 +2095,7 @@ function getOutlinedModel<T>(
2095 response,
2096 map,
2097 path.slice(i - 1),
2089 - false,
2098 + isInitializingDebugInfo,
2099 );
2100 }
2101 case HALTED: {
@@ -2158,14 +2167,21 @@ function getOutlinedModel<T>(
2167 }
2168
2169 const chunkValue = map(response, value, parentObject, key);
2161 - if (
2162 - parentObject[0] === REACT_ELEMENT_TYPE &&
2163 - (key === '4' || key === '5')
2164 - ) {
2165 - // If we're resolving the "owner" or "stack" slot of an Element array, we don't call
2166 - // transferReferencedDebugInfo because this reference is to a debug chunk.
2167 - } else {
2168 - transferReferencedDebugInfo(initializingChunk, chunk);
2170 + if (__DEV__) {
2171 + if (
2172 + parentObject[0] === REACT_ELEMENT_TYPE &&
2173 + (key === '4' || key === '5')
2174 + ) {
2175 + // If we're resolving the "owner" or "stack" slot of an Element array,
2176 + // we don't call transferReferencedDebugInfo because this reference is
2177 + // to a debug chunk.
2178 + } else if (isInitializingDebugInfo) {
2179 + // If we're resolving references as part of debug info resolution, we
2180 + // don't call transferReferencedDebugInfo because these references are
2181 + // to debug chunks.
2182 + } else {
2183 + transferReferencedDebugInfo(initializingChunk, chunk);
2184 + }
2185 }
2186 return chunkValue;
2187 case PENDING:
@@ -2177,7 +2193,7 @@ function getOutlinedModel<T>(
2193 response,
2194 map,
2195 path,
2180 - false,
2196 + isInitializingDebugInfo,
2197 );
2198 case HALTED: {
2199 // Add a dependency that will never resolve.
@@ -4264,15 +4280,21 @@ function resolveIOInfo(
4280 ): void {
4281 const chunks = response._chunks;
4282 let chunk = chunks.get(id);
4267 - if (!chunk) {
4268 - chunk = createResolvedModelChunk(response, model);
4269 - chunks.set(id, chunk);
4270 - initializeModelChunk(chunk);
4271 - } else {
4272 - resolveModelChunk(response, chunk, model);
4273 - if (chunk.status === RESOLVED_MODEL) {
4283 + const prevIsInitializingDebugInfo = isInitializingDebugInfo;
4284 + isInitializingDebugInfo = true;
4285 + try {
4286 + if (!chunk) {
4287 + chunk = createResolvedModelChunk(response, model);
4288 + chunks.set(id, chunk);
4289 initializeModelChunk(chunk);
4290 + } else {
4291 + resolveModelChunk(response, chunk, model);
4292 + if (chunk.status === RESOLVED_MODEL) {
4293 + initializeModelChunk(chunk);
4294 + }
4295 }
4296 + } finally {
4297 + isInitializingDebugInfo = prevIsInitializingDebugInfo;
4298 }
4299 if (chunk.status === INITIALIZED) {
4300 initializeIOInfo(response, chunk.value);
packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js
+36
@@ -3633,4 +3633,40 @@ describe('ReactFlightAsyncDebugInfo', () => {
3633 `);
3634 }
3635 });
3636 +
3637 + it('should not exponentially accumulate debug info on outlined debug chunks', async () => {
3638 + // Regression test: Each Level wraps its received `context` prop in a new
3639 + // object before passing it down. This creates props deduplication
3640 + // references to the parent's outlined chunk alongside the owner reference,
3641 + // giving 2 references per level to the direct parent's chunk. Without
3642 + // skipping transferReferencedDebugInfo during debug info resolution, this
3643 + // test would fail with an infinite loop detection error.
3644 + async function Level({depth, context}) {
3645 + await delay(0);
3646 + if (depth === 0) {
3647 + return <div>Hello, World!</div>;
3648 + }
3649 + const newContext = {prev: context, id: depth};
3650 + return ReactServer.createElement(Level, {
3651 + depth: depth - 1,
3652 + context: newContext,
3653 + });
3654 + }
3655 +
3656 + const stream = ReactServerDOMServer.renderToPipeableStream(
3657 + ReactServer.createElement(Level, {depth: 20, context: {root: true}}),
3658 + );
3659 +
3660 + const readable = new Stream.PassThrough(streamOptions);
3661 + const result = ReactServerDOMClient.createFromNodeStream(readable, {
3662 + moduleMap: {},
3663 + moduleLoading: {},
3664 + });
3665 + stream.pipe(readable);
3666 +
3667 + const resolved = await result;
3668 + expect(resolved.type).toBe('div');
3669 +
3670 + await finishLoadingStream(readable);
3671 + });
3672 });