From 1a7fc60985dbb74c957cc20607ae144d8b9866a5 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 16 Sep 2025 14:50:09 +0200 Subject: [PATCH 01/18] Reduce flight fixture to a minimum --- fixtures/flight/src/App.js | 233 +------------------------------------ 1 file changed, 1 insertion(+), 232 deletions(-) diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js index df2c8922b8424..41ab9802036da 100644 --- a/fixtures/flight/src/App.js +++ b/fixtures/flight/src/App.js @@ -1,246 +1,15 @@ import * as React from 'react'; import {renderToReadableStream} from 'react-server-dom-webpack/server'; import {createFromReadableStream} from 'react-server-dom-webpack/client'; -import {PassThrough, Readable} from 'stream'; - -import Container from './Container.js'; - -import {Counter} from './Counter.js'; -import {Counter as Counter2} from './Counter2.js'; -import AsyncModule from './cjs/Counter3.js'; -const Counter3 = await(AsyncModule); - -import ShowMore from './ShowMore.js'; -import Button from './Button.js'; -import Form from './Form.js'; -import {Dynamic} from './Dynamic.js'; -import {Client} from './Client.js'; -import {Navigate} from './Navigate.js'; - -import {Note} from './cjs/Note.js'; - -import {GenerateImage} from './GenerateImage.js'; - -import LargeContent from './LargeContent.js'; - -import {like, greet, increment} from './actions.js'; - -import {getServerState} from './ServerState.js'; -import {sdkMethod} from './library.js'; - -const promisedText = new Promise(resolve => - setTimeout(() => resolve('deferred text'), 50) -); - -function Foo({children}) { - return
{children}
; -} - -async function delayedError(text, ms) { - return new Promise((_, reject) => - setTimeout(() => reject(new Error(text)), ms) - ); -} - -async function delay(text, ms) { - return new Promise(resolve => setTimeout(() => resolve(text), ms)); -} - -async function delayTwice() { - try { - await delayedError('Delayed exception', 20); - } catch (x) { - // Ignored - } - await delay('', 10); -} - -async function delayTrice() { - const p = delayTwice(); - await delay('', 40); - return p; -} - -async function Bar({children}) { - await delayTrice(); - return
{children}
; -} - -async function ThirdPartyComponent() { - return await delay('hello from a 3rd party', 30); -} - -let cachedThirdPartyStream; - -// We create the Component outside of AsyncLocalStorage so that it has no owner. -// That way it gets the owner from the call to createFromNodeStream. -const thirdPartyComponent = ; - -function simulateFetch(cb, latencyMs) { - return new Promise(resolve => { - // Request latency - setTimeout(() => { - const result = cb(); - // Response latency - setTimeout(() => { - resolve(result); - }, latencyMs); - }, latencyMs); - }); -} - -async function fetchThirdParty(noCache) { - // We're using the Web Streams APIs for tee'ing convenience. - let stream; - if (cachedThirdPartyStream && !noCache) { - stream = cachedThirdPartyStream; - } else { - stream = await simulateFetch( - () => - renderToReadableStream( - thirdPartyComponent, - {}, - {environmentName: 'third-party'} - ), - 25 - ); - } - - const [stream1, stream2] = stream.tee(); - cachedThirdPartyStream = stream1; - - return createFromReadableStream(stream2, { - serverConsumerManifest: { - moduleMap: {}, - serverModuleMap: null, - moduleLoading: null, - }, - }); -} - -async function ServerComponent({noCache}) { - await delay('deferred text', 50); - return await fetchThirdParty(noCache); -} - -let veryDeepObject = [ - { - bar: { - baz: { - a: {}, - }, - }, - }, - { - bar: { - baz: { - a: {}, - }, - }, - }, - { - bar: { - baz: { - a: {}, - }, - }, - }, - { - bar: { - baz: { - a: { - b: { - c: { - d: { - e: { - f: { - g: { - h: { - i: { - j: { - k: { - l: { - m: { - yay: 'You reached the end', - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, -]; export default async function App({prerender, noCache}) { - const res = await fetch('http://localhost:3001/todos'); - const todos = await res.json(); - await sdkMethod('http://localhost:3001/todos'); - - console.log('Expand me:', veryDeepObject); - - const dedupedChild = ; - const message = getServerState(); return ( - - Flight - - {prerender ? ( - - ) : ( - - )} -

{message}

- -
- Promise as a child hydrates without errors: {promisedText} -
-
- - - -
    - {todos.map(todo => ( -
  • {todo.text}
  • - ))} -
- -

Lorem ipsum

-
-
-
- -
-
- loaded statically: -
-
- -
- - - {dedupedChild} - {Promise.resolve([dedupedChild])} - - {prerender ? null : ( // TODO: prerender is broken for large content for some reason. - - - - )} - +

hello

); From 1537f4677dd21b5c3ed42b73082fc8636c6694e6 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 16 Sep 2025 14:50:48 +0200 Subject: [PATCH 02/18] Delay debug channel messages --- fixtures/flight/src/index.js | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/fixtures/flight/src/index.js b/fixtures/flight/src/index.js index 447b1957c8c09..50f9a9d6272e5 100644 --- a/fixtures/flight/src/index.js +++ b/fixtures/flight/src/index.js @@ -14,6 +14,40 @@ function findSourceMapURL(fileName) { ); } +function createDelayedStream( + stream: ReadableStream +): ReadableStream { + return new ReadableStream({ + async start(controller) { + const reader = stream.getReader(); + + try { + while (true) { + const {done, value} = await reader.read(); + if (done) { + if (!controller.desiredSize === null) { + controller.close(); + } + break; + } else { + // Artificially delay between enqueuing chunks. + await new Promise(resolve => setTimeout(resolve, 10)); + if (controller.desiredSize !== null) { + controller.enqueue(value); + } + } + } + } catch (error) { + if (controller.desiredSize !== null) { + controller.error(error); + } + } finally { + reader.releaseLock(); + } + }, + }); +} + async function createWebSocketStream(url) { const ws = new WebSocket(url); ws.binaryType = 'arraybuffer'; @@ -49,7 +83,7 @@ async function createWebSocketStream(url) { }, }); - return {readable, writable}; + return {readable: createDelayedStream(readable), writable}; } let updateRoot; From f689b0ff612e4eb6d0fc335872fedb24729563cf Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 16 Sep 2025 16:00:16 +0200 Subject: [PATCH 03/18] Add a failing test for missing debug info --- .../__tests__/ReactFlightDOMBrowser-test.js | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index a49e268ebf040..8e9a109aa9c7c 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -27,6 +27,7 @@ let webpackMap; let webpackServerMap; let act; let serverAct; +let getDebugInfo; let React; let ReactDOM; let ReactDOMClient; @@ -48,6 +49,10 @@ describe('ReactFlightDOMBrowser', () => { ReactServerScheduler = require('scheduler'); patchMessageChannel(ReactServerScheduler); serverAct = require('internal-test-utils').serverAct; + getDebugInfo = require('internal-test-utils').getDebugInfo.bind(null, { + ignoreProps: true, + useFixedTime: true, + }); // Simulate the condition resolution @@ -2906,4 +2911,134 @@ describe('ReactFlightDOMBrowser', () => { '
HiSebbie
', ); }); + + it('should fully resolve debug info when transported through a (slow) debug channel', async () => { + function Paragraph({children}) { + return ReactServer.createElement('p', null, children); + } + + let debugReadableStreamController; + + const debugReadableStream = new ReadableStream({ + start(controller) { + debugReadableStreamController = controller; + }, + }); + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + { + root: ReactServer.createElement( + ReactServer.Fragment, + null, + ReactServer.createElement(Paragraph, null, 'foo'), + ReactServer.createElement(Paragraph, null, 'bar'), + ), + }, + webpackMap, + { + debugChannel: { + writable: new WritableStream({ + write(chunk) { + debugReadableStreamController.enqueue(chunk); + }, + close() { + debugReadableStreamController.close(); + }, + }), + }, + }, + ), + ); + + function ClientRoot({response}) { + const {root} = use(response); + return root; + } + + const response = ReactServerDOMClient.createFromReadableStream(stream, { + // TODO: The test succeeds without a debug channel, but fails with a debug + // channel. However, it fails even without a delay, whereas in the flight + // fixture, only an artificial delaying of the debug chunks reproduces the + // issue that server components are missing from the devtools component + // tree. + // debugChannel: {readable: createDelayedStream(debugReadableStream)}, + debugChannel: {readable: debugReadableStream}, + }); + + const container = document.createElement('div'); + const clientRoot = ReactDOMClient.createRoot(container); + + await act(() => { + clientRoot.render(); + }); + + expect(container.innerHTML).toBe('

foo

bar

'); + + if ( + __DEV__ && + gate( + flags => + flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo, + ) + ) { + const result = await response; + const firstParagraph = result.root[0]; + + expect(getDebugInfo(firstParagraph)).toMatchInlineSnapshot(` + [ + { + "time": 0, + }, + { + "env": "Server", + "key": null, + "name": "Paragraph", + "props": {}, + "stack": [ + [ + "", + "/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js", + 2935, + 27, + 2929, + 34, + ], + [ + "serverAct", + "/packages/internal-test-utils/internalAct.js", + 270, + 19, + 231, + 1, + ], + [ + "Object.", + "/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js", + 2929, + 18, + 2916, + 89, + ], + ], + }, + { + "time": 0, + }, + { + "awaited": { + "byteSize": 0, + "end": 0, + "name": "RSC stream", + "owner": null, + "start": 0, + "value": { + "value": "stream", + }, + }, + }, + ] + `); + } + }); }); From 6e4bc9a1ea28a9097f79a3115483474d843c2ea5 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 16 Sep 2025 15:27:52 -0400 Subject: [PATCH 04/18] Ensure blocked debug info entries are properly resolved - We must not unset the debug chunk before all entries are resolved. - We must ensure that the "RSC Stream" IO debug info entry is pushed last, after all other entries were resolved. --- .../react-client/src/ReactFlightClient.js | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 6e96f2f0378ec..4f64cdeec6869 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -649,7 +649,6 @@ function triggerErrorOnChunk( } try { initializeDebugChunk(response, chunk); - chunk._debugChunk = null; if (initializingHandler !== null) { if (initializingHandler.errored) { // Ignore error parsing debug info, we'll report the original error instead. @@ -932,9 +931,9 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { } if (__DEV__) { - // Lazily initialize any debug info and block the initializing chunk on any unresolved entries. + // Initialize any debug info and block the initializing chunk on any + // unresolved entries. initializeDebugChunk(response, chunk); - chunk._debugChunk = null; } try { @@ -2714,9 +2713,19 @@ function resolveChunkDebugInfo( chunk: SomeChunk, ): void { if (__DEV__ && enableAsyncDebugInfo) { - // Push the currently resolving chunk's debug info representing the stream on the Promise - // that was waiting on the stream. - chunk._debugInfo.push({awaited: streamState._debugInfo}); + // Push the currently resolving chunk's debug info representing the stream + // on the Promise that was waiting on the stream. + const ioInfo = streamState._debugInfo; + const debugChunk = chunk._debugChunk; + if (debugChunk != null) { + // If there's a debug chunk, then we wait for it to resolve before adding + // the stream info as the last entry. + debugChunk.then(() => { + chunk._debugInfo.push({awaited: ioInfo}); + }); + } else { + chunk._debugInfo.push({awaited: ioInfo}); + } } } @@ -2923,7 +2932,6 @@ function resolveStream>( } try { initializeDebugChunk(response, chunk); - chunk._debugChunk = null; if (initializingHandler !== null) { if (initializingHandler.errored) { // Ignore error parsing debug info, we'll report the original error instead. From 257ad78517ebd18d204fe3f63762029b0ddbbe30 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 17 Sep 2025 14:52:18 +0200 Subject: [PATCH 05/18] Make `App` a sync server component --- fixtures/flight/src/App.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js index 41ab9802036da..738b33c322111 100644 --- a/fixtures/flight/src/App.js +++ b/fixtures/flight/src/App.js @@ -2,7 +2,7 @@ import * as React from 'react'; import {renderToReadableStream} from 'react-server-dom-webpack/server'; import {createFromReadableStream} from 'react-server-dom-webpack/client'; -export default async function App({prerender, noCache}) { +export default function App({prerender, noCache}) { return ( From b27a350fd3a60ac21b99d3d0df25ddb8e2e54609 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 17 Sep 2025 14:56:20 +0200 Subject: [PATCH 06/18] Make the test fail again by using a slow debug channel --- .../__tests__/ReactFlightDOMBrowser-test.js | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index 8e9a109aa9c7c..49cc28535e92d 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -1772,6 +1772,9 @@ describe('ReactFlightDOMBrowser', () => { webpackMap, ), ); + + // Snapshot updates change this formatting, so we let prettier ignore it. + // prettier-ignore const response = await ReactServerDOMClient.createFromReadableStream(stream); @@ -2956,14 +2959,11 @@ describe('ReactFlightDOMBrowser', () => { return root; } + const [slowDebugStream1, slowDebugStream2] = + createDelayedStream(debugReadableStream).tee(); + const response = ReactServerDOMClient.createFromReadableStream(stream, { - // TODO: The test succeeds without a debug channel, but fails with a debug - // channel. However, it fails even without a delay, whereas in the flight - // fixture, only an artificial delaying of the debug chunks reproduces the - // issue that server components are missing from the devtools component - // tree. - // debugChannel: {readable: createDelayedStream(debugReadableStream)}, - debugChannel: {readable: debugReadableStream}, + debugChannel: {readable: slowDebugStream1}, }); const container = document.createElement('div'); @@ -2973,6 +2973,18 @@ describe('ReactFlightDOMBrowser', () => { clientRoot.render(); }); + if (__DEV__) { + const debugStreamReader = slowDebugStream2.getReader(); + while (true) { + const {done} = await debugStreamReader.read(); + if (done) { + break; + } + // Allow the client to process each debug chunk as it arrives. + await act(() => {}); + } + } + expect(container.innerHTML).toBe('

foo

bar

'); if ( @@ -2999,9 +3011,9 @@ describe('ReactFlightDOMBrowser', () => { [ "", "/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js", - 2935, + 2937, 27, - 2929, + 2931, 34, ], [ @@ -3015,9 +3027,9 @@ describe('ReactFlightDOMBrowser', () => { [ "Object.", "/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js", - 2929, + 2931, 18, - 2916, + 2918, 89, ], ], From a68f0d4ba3ed83502499a65c6e6a2d27f3f92c7b Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 17 Sep 2025 20:29:31 +0200 Subject: [PATCH 07/18] Revert "Reduce flight fixture to a minimum" This reverts commit dcff98c547c819cf6bd83ff0acd7a456c3744eb4. --- fixtures/flight/src/App.js | 235 ++++++++++++++++++++++++++++++++++++- 1 file changed, 233 insertions(+), 2 deletions(-) diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js index 738b33c322111..df2c8922b8424 100644 --- a/fixtures/flight/src/App.js +++ b/fixtures/flight/src/App.js @@ -1,15 +1,246 @@ import * as React from 'react'; import {renderToReadableStream} from 'react-server-dom-webpack/server'; import {createFromReadableStream} from 'react-server-dom-webpack/client'; +import {PassThrough, Readable} from 'stream'; -export default function App({prerender, noCache}) { +import Container from './Container.js'; + +import {Counter} from './Counter.js'; +import {Counter as Counter2} from './Counter2.js'; +import AsyncModule from './cjs/Counter3.js'; +const Counter3 = await(AsyncModule); + +import ShowMore from './ShowMore.js'; +import Button from './Button.js'; +import Form from './Form.js'; +import {Dynamic} from './Dynamic.js'; +import {Client} from './Client.js'; +import {Navigate} from './Navigate.js'; + +import {Note} from './cjs/Note.js'; + +import {GenerateImage} from './GenerateImage.js'; + +import LargeContent from './LargeContent.js'; + +import {like, greet, increment} from './actions.js'; + +import {getServerState} from './ServerState.js'; +import {sdkMethod} from './library.js'; + +const promisedText = new Promise(resolve => + setTimeout(() => resolve('deferred text'), 50) +); + +function Foo({children}) { + return
{children}
; +} + +async function delayedError(text, ms) { + return new Promise((_, reject) => + setTimeout(() => reject(new Error(text)), ms) + ); +} + +async function delay(text, ms) { + return new Promise(resolve => setTimeout(() => resolve(text), ms)); +} + +async function delayTwice() { + try { + await delayedError('Delayed exception', 20); + } catch (x) { + // Ignored + } + await delay('', 10); +} + +async function delayTrice() { + const p = delayTwice(); + await delay('', 40); + return p; +} + +async function Bar({children}) { + await delayTrice(); + return
{children}
; +} + +async function ThirdPartyComponent() { + return await delay('hello from a 3rd party', 30); +} + +let cachedThirdPartyStream; + +// We create the Component outside of AsyncLocalStorage so that it has no owner. +// That way it gets the owner from the call to createFromNodeStream. +const thirdPartyComponent = ; + +function simulateFetch(cb, latencyMs) { + return new Promise(resolve => { + // Request latency + setTimeout(() => { + const result = cb(); + // Response latency + setTimeout(() => { + resolve(result); + }, latencyMs); + }, latencyMs); + }); +} + +async function fetchThirdParty(noCache) { + // We're using the Web Streams APIs for tee'ing convenience. + let stream; + if (cachedThirdPartyStream && !noCache) { + stream = cachedThirdPartyStream; + } else { + stream = await simulateFetch( + () => + renderToReadableStream( + thirdPartyComponent, + {}, + {environmentName: 'third-party'} + ), + 25 + ); + } + + const [stream1, stream2] = stream.tee(); + cachedThirdPartyStream = stream1; + + return createFromReadableStream(stream2, { + serverConsumerManifest: { + moduleMap: {}, + serverModuleMap: null, + moduleLoading: null, + }, + }); +} + +async function ServerComponent({noCache}) { + await delay('deferred text', 50); + return await fetchThirdParty(noCache); +} + +let veryDeepObject = [ + { + bar: { + baz: { + a: {}, + }, + }, + }, + { + bar: { + baz: { + a: {}, + }, + }, + }, + { + bar: { + baz: { + a: {}, + }, + }, + }, + { + bar: { + baz: { + a: { + b: { + c: { + d: { + e: { + f: { + g: { + h: { + i: { + j: { + k: { + l: { + m: { + yay: 'You reached the end', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +]; + +export default async function App({prerender, noCache}) { + const res = await fetch('http://localhost:3001/todos'); + const todos = await res.json(); + await sdkMethod('http://localhost:3001/todos'); + + console.log('Expand me:', veryDeepObject); + + const dedupedChild = ; + const message = getServerState(); return ( + + Flight -

hello

+ + {prerender ? ( + + ) : ( + + )} +

{message}

+ +
+ Promise as a child hydrates without errors: {promisedText} +
+
+ + + +
    + {todos.map(todo => ( +
  • {todo.text}
  • + ))} +
+ +

Lorem ipsum

+
+ +
+ +
+
+ loaded statically: +
+
+ +
+ + + {dedupedChild} + {Promise.resolve([dedupedChild])} + + {prerender ? null : ( // TODO: prerender is broken for large content for some reason. + + + + )} +
); From ecbce805036812e775af45cc03a702841e96c66e Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 17 Sep 2025 21:06:19 +0200 Subject: [PATCH 08/18] Transfer debug info onto lazy types --- .../react-client/src/ReactFlightClient.js | 34 +++++++++++++------ .../__tests__/ReactFlightDOMBrowser-test.js | 16 ++------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 4f64cdeec6869..3800486bbc5ac 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -1150,15 +1150,25 @@ function initializeElement( initializeFakeStack(response, owner); } - // In case the JSX runtime has validated the lazy type as a static child, we - // need to transfer this information to the element. - if ( - lazyType && - lazyType._store && - lazyType._store.validated && - !element._store.validated - ) { - element._store.validated = lazyType._store.validated; + if (lazyType !== null) { + // In case the JSX runtime has validated the lazy type as a static child, we + // need to transfer this information to the element. + if ( + lazyType._store && + lazyType._store.validated && + !element._store.validated + ) { + element._store.validated = lazyType._store.validated; + } + + // If the lazy type has debug info, and the element doesn't have any, + // we should forward it. + if (lazyType._debugInfo !== null && element._debugInfo === null) { + element._debugInfo = lazyType._debugInfo; + // We need to clean it from the lazy type now, so the element won't show + // up twice in the component tree. + lazyType._debugInfo = null; + } } // TODO: We should be freezing the element but currently, we might write into @@ -1826,7 +1836,8 @@ function transferReferencedDebugInfo( referencedValue !== null && (isArray(referencedValue) || typeof referencedValue[ASYNC_ITERATOR] === 'function' || - referencedValue.$$typeof === REACT_ELEMENT_TYPE) + referencedValue.$$typeof === REACT_ELEMENT_TYPE || + referencedValue.$$typeof === REACT_LAZY_TYPE) ) { // We should maybe use a unique symbol for arrays but this is a React owned array. // $FlowFixMe[prop-missing]: This should be added to elements. @@ -2918,7 +2929,8 @@ function resolveStream>( const resolveListeners = chunk.value; if (__DEV__) { - // Lazily initialize any debug info and block the initializing chunk on any unresolved entries. + // Initialize any debug info and block the initializing chunk on any + // unresolved entries. if (chunk._debugChunk != null) { const prevHandler = initializingHandler; const prevChunk = initializingChunk; diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index 49cc28535e92d..125eca6e4dc6e 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -2995,7 +2995,8 @@ describe('ReactFlightDOMBrowser', () => { ) ) { const result = await response; - const firstParagraph = result.root[0]; + const lazy = result.root[0]; + const firstParagraph = lazy._init(lazy._payload); expect(getDebugInfo(firstParagraph)).toMatchInlineSnapshot(` [ @@ -3037,20 +3038,9 @@ describe('ReactFlightDOMBrowser', () => { { "time": 0, }, - { - "awaited": { - "byteSize": 0, - "end": 0, - "name": "RSC stream", - "owner": null, - "start": 0, - "value": { - "value": "stream", - }, - }, - }, ] `); + // TODO: This should also have the "RSC stream" entry. } }); }); From 21e579503864bdbf866e41b74679835cd12e1801 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 18 Sep 2025 10:10:08 +0200 Subject: [PATCH 09/18] Forward debug info from initializing chunk to lazy nodes --- .../react-client/src/ReactFlightClient.js | 72 ++++++++++--------- .../__tests__/ReactFlightDOMBrowser-test.js | 16 ++++- 2 files changed, 51 insertions(+), 37 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 3800486bbc5ac..193bd91f8fb48 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -1078,7 +1078,7 @@ function getTaskName(type: mixed): string { function initializeElement( response: Response, element: any, - lazyType: null | LazyComponent< + lazyNode: null | LazyComponent< React$Element, SomeChunk>, >, @@ -1150,25 +1150,15 @@ function initializeElement( initializeFakeStack(response, owner); } - if (lazyType !== null) { - // In case the JSX runtime has validated the lazy type as a static child, we - // need to transfer this information to the element. - if ( - lazyType._store && - lazyType._store.validated && - !element._store.validated - ) { - element._store.validated = lazyType._store.validated; - } - - // If the lazy type has debug info, and the element doesn't have any, - // we should forward it. - if (lazyType._debugInfo !== null && element._debugInfo === null) { - element._debugInfo = lazyType._debugInfo; - // We need to clean it from the lazy type now, so the element won't show - // up twice in the component tree. - lazyType._debugInfo = null; - } + // In case the JSX runtime has validated the lazy type as a static child, we + // need to transfer this information to the element. + if ( + lazyNode && + lazyNode._store && + lazyNode._store.validated && + !element._store.validated + ) { + element._store.validated = lazyNode._store.validated; } // TODO: We should be freezing the element but currently, we might write into @@ -1178,6 +1168,7 @@ function initializeElement( function createElement( response: Response, + isRoot: boolean, type: mixed, key: mixed, props: mixed, @@ -1286,15 +1277,24 @@ function createElement( // a Lazy node referencing this Element to let everything around it proceed. const blockedChunk: BlockedChunk> = createBlockedChunk(response); + if (__DEV__) { + // If this is the root element, forward the live debug info of the + // initializing chunk to the blocked chunk. + if (isRoot && initializingChunk !== null) { + blockedChunk._debugInfo = initializingChunk._debugInfo; + } + } handler.value = element; handler.chunk = blockedChunk; - const lazyType = createLazyChunkWrapper(blockedChunk, validated); + const lazyNode = createLazyChunkWrapper(blockedChunk, validated); if (__DEV__) { + // Forward the live debug info of the lazy node to the element. + element._debugInfo = lazyNode._debugInfo; // After we have initialized any blocked references, initialize stack etc. - const init = initializeElement.bind(null, response, element, lazyType); + const init = initializeElement.bind(null, response, element, lazyNode); blockedChunk.then(init, init); } - return lazyType; + return lazyNode; } } if (__DEV__) { @@ -1836,8 +1836,7 @@ function transferReferencedDebugInfo( referencedValue !== null && (isArray(referencedValue) || typeof referencedValue[ASYNC_ITERATOR] === 'function' || - referencedValue.$$typeof === REACT_ELEMENT_TYPE || - referencedValue.$$typeof === REACT_LAZY_TYPE) + referencedValue.$$typeof === REACT_ELEMENT_TYPE) ) { // We should maybe use a unique symbol for arrays but this is a React owned array. // $FlowFixMe[prop-missing]: This should be added to elements. @@ -1860,14 +1859,16 @@ function transferReferencedDebugInfo( // is extracted, or if the root is rendered as is. if (parentChunk !== null) { const parentDebugInfo = parentChunk._debugInfo; - for (let i = 0; i < referencedDebugInfo.length; ++i) { - const debugInfoEntry = referencedDebugInfo[i]; - if (debugInfoEntry.name != null) { - (debugInfoEntry: ReactComponentInfo); - // We're not transferring Component info since we use Component info - // in Debug info to fill in gaps between Fibers for the parent stack. - } else { - parentDebugInfo.push(debugInfoEntry); + if (parentDebugInfo !== referencedDebugInfo) { + for (let i = 0; i < referencedDebugInfo.length; ++i) { + const debugInfoEntry = referencedDebugInfo[i]; + if (debugInfoEntry.name != null) { + (debugInfoEntry: ReactComponentInfo); + // We're not transferring Component info since we use Component info + // in Debug info to fill in gaps between Fibers for the parent stack. + } else { + parentDebugInfo.push(debugInfoEntry); + } } } } @@ -2467,6 +2468,7 @@ function parseModelString( function parseModelTuple( response: Response, value: {+[key: string]: JSONValue} | $ReadOnlyArray, + isRoot: boolean, ): any { const tuple: [mixed, mixed, mixed, mixed] = (value: any); @@ -2475,6 +2477,7 @@ function parseModelTuple( // Or even change the ReactElement type to be an array. return createElement( response, + isRoot, tuple[1], tuple[2], tuple[3], @@ -5041,7 +5044,8 @@ function createFromJSONCallback(response: Response) { return parseModelString(response, this, key, value); } if (typeof value === 'object' && value !== null) { - return parseModelTuple(response, value); + const isRoot = key === ''; + return parseModelTuple(response, value, isRoot); } return value; }; diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index 125eca6e4dc6e..49cc28535e92d 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -2995,8 +2995,7 @@ describe('ReactFlightDOMBrowser', () => { ) ) { const result = await response; - const lazy = result.root[0]; - const firstParagraph = lazy._init(lazy._payload); + const firstParagraph = result.root[0]; expect(getDebugInfo(firstParagraph)).toMatchInlineSnapshot(` [ @@ -3038,9 +3037,20 @@ describe('ReactFlightDOMBrowser', () => { { "time": 0, }, + { + "awaited": { + "byteSize": 0, + "end": 0, + "name": "RSC stream", + "owner": null, + "start": 0, + "value": { + "value": "stream", + }, + }, + }, ] `); - // TODO: This should also have the "RSC stream" entry. } }); }); From bdc9052cb1ec9da9135f9afa6f26b1aa5ed03344 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 23 Sep 2025 17:48:31 +0200 Subject: [PATCH 10/18] Transfer debug info in `wakeChunk` and `initializeModelChunk` --- .../react-client/src/ReactFlightClient.js | 214 ++++++++++-------- .../src/__tests__/ReactFlight-test.js | 55 ++--- .../src/__tests__/ReactFlightDOMEdge-test.js | 16 +- .../src/__tests__/ReactFlightDOMNode-test.js | 24 +- 4 files changed, 168 insertions(+), 141 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 193bd91f8fb48..678b1aaa29213 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -512,6 +512,27 @@ function wakeChunk( fulfillReference(listener, value, chunk); } } + + if (__DEV__ && chunk.status === INITIALIZED) { + const resolvedValue = resolveLazy(value); + if (isReactElementOrArrayLike(resolvedValue) || isLazy(resolvedValue)) { + const debugInfo = chunk._debugInfo.splice(0); + if (resolvedValue._debugInfo) { + // $FlowFixMe[method-unbinding] + resolvedValue._debugInfo.push.apply( + resolvedValue._debugInfo, + debugInfo, + ); + } else { + Object.defineProperty(resolvedValue, '_debugInfo', { + configurable: false, + enumerable: false, + writable: true, + value: debugInfo, + }); + } + } + } } function rejectChunk( @@ -959,6 +980,24 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { return; } } + + if (__DEV__) { + if (isReactElementOrArrayLike(value)) { + const debugInfo = chunk._debugInfo.splice(0); + if (value._debugInfo) { + // $FlowFixMe[method-unbinding] + value._debugInfo.push.apply(value._debugInfo, debugInfo); + } else { + Object.defineProperty(value, '_debugInfo', { + configurable: false, + enumerable: false, + writable: true, + value: debugInfo, + }); + } + } + } + const initializedChunk: InitializedChunk = (chunk: any); initializedChunk.status = INITIALIZED; initializedChunk.value = value; @@ -1052,11 +1091,7 @@ function getTaskName(type: mixed): string { // the client. There should only be one for any given owner chain. return '"use client"'; } - if ( - typeof type === 'object' && - type !== null && - type.$$typeof === REACT_LAZY_TYPE - ) { + if (isLazy(type)) { if (type._init === readChunk) { // This is a lazy node created by Flight. It is probably a client reference. // We use the "use client" string to indicate that this is the boundary into @@ -1168,7 +1203,6 @@ function initializeElement( function createElement( response: Response, - isRoot: boolean, type: mixed, key: mixed, props: mixed, @@ -1277,19 +1311,10 @@ function createElement( // a Lazy node referencing this Element to let everything around it proceed. const blockedChunk: BlockedChunk> = createBlockedChunk(response); - if (__DEV__) { - // If this is the root element, forward the live debug info of the - // initializing chunk to the blocked chunk. - if (isRoot && initializingChunk !== null) { - blockedChunk._debugInfo = initializingChunk._debugInfo; - } - } handler.value = element; handler.chunk = blockedChunk; const lazyNode = createLazyChunkWrapper(blockedChunk, validated); if (__DEV__) { - // Forward the live debug info of the lazy node to the element. - element._debugInfo = lazyNode._debugInfo; // After we have initialized any blocked references, initialize stack etc. const init = initializeElement.bind(null, response, element, lazyNode); blockedChunk.then(init, init); @@ -1346,11 +1371,7 @@ function fulfillReference( const {response, handler, parentObject, key, map, path} = reference; for (let i = 1; i < path.length; i++) { - while ( - typeof value === 'object' && - value !== null && - value.$$typeof === REACT_LAZY_TYPE - ) { + while (isLazy(value)) { // We never expect to see a Lazy node on this path because we encode those as // separate models. This must mean that we have inserted an extra lazy node // e.g. to replace a blocked element. We must instead look for it inside. @@ -1422,11 +1443,7 @@ function fulfillReference( value = value[path[i]]; } - while ( - typeof value === 'object' && - value !== null && - value.$$typeof === REACT_LAZY_TYPE - ) { + while (isLazy(value)) { // If what we're referencing is a Lazy it must be because we inserted one as a virtual node // while it was blocked by other data. If it's no longer blocked, we can unwrap it. const referencedChunk: SomeChunk = value._payload; @@ -1475,7 +1492,7 @@ function fulfillReference( const element: any = handler.value; switch (key) { case '3': - transferReferencedDebugInfo(handler.chunk, fulfilledChunk, mappedValue); + transferReferencedDebugInfo(handler.chunk, fulfilledChunk); element.props = mappedValue; break; case '4': @@ -1491,11 +1508,11 @@ function fulfillReference( } break; default: - transferReferencedDebugInfo(handler.chunk, fulfilledChunk, mappedValue); + transferReferencedDebugInfo(handler.chunk, fulfilledChunk); break; } } else if (__DEV__ && !reference.isDebug) { - transferReferencedDebugInfo(handler.chunk, fulfilledChunk, mappedValue); + transferReferencedDebugInfo(handler.chunk, fulfilledChunk); } handler.deps--; @@ -1817,49 +1834,59 @@ function loadServerReference, T>( return (null: any); } +function isReactElementOrArrayLike( + value: any, + // eslint-disable-next-line no-undef +): value is {_debugInfo: null | ReactDebugInfo, ...} { + return ( + typeof value === 'object' && + value !== null && + (isArray(value) || + typeof value[ASYNC_ITERATOR] === 'function' || + value.$$typeof === REACT_ELEMENT_TYPE) + ); +} + +function isLazy( + value: any, + // eslint-disable-next-line no-undef +): implies value is LazyComponent< + React$Element, + SomeChunk>, +> { + return ( + typeof value === 'object' && + value !== null && + value.$$typeof === REACT_LAZY_TYPE + ); +} + +function resolveLazy(value: mixed): mixed { + while (isLazy(value)) { + const payload: SomeChunk = value._payload; + if (payload.status === INITIALIZED) { + value = payload.value; + continue; + } + break; + } + + return value; +} + function transferReferencedDebugInfo( parentChunk: null | SomeChunk, referencedChunk: SomeChunk, - referencedValue: mixed, ): void { if (__DEV__) { - const referencedDebugInfo = referencedChunk._debugInfo; - // If we have a direct reference to an object that was rendered by a synchronous - // server component, it might have some debug info about how it was rendered. - // We forward this to the underlying object. This might be a React Element or - // an Array fragment. - // If this was a string / number return value we lose the debug info. We choose - // that tradeoff to allow sync server components to return plain values and not - // use them as React Nodes necessarily. We could otherwise wrap them in a Lazy. - if ( - typeof referencedValue === 'object' && - referencedValue !== null && - (isArray(referencedValue) || - typeof referencedValue[ASYNC_ITERATOR] === 'function' || - referencedValue.$$typeof === REACT_ELEMENT_TYPE) - ) { - // We should maybe use a unique symbol for arrays but this is a React owned array. - // $FlowFixMe[prop-missing]: This should be added to elements. - const existingDebugInfo: ?ReactDebugInfo = - (referencedValue._debugInfo: any); - if (existingDebugInfo == null) { - Object.defineProperty((referencedValue: any), '_debugInfo', { - configurable: false, - enumerable: false, - writable: true, - value: referencedDebugInfo.slice(0), // Clone so that pushing later isn't going into the original - }); - } else { - // $FlowFixMe[method-unbinding] - existingDebugInfo.push.apply(existingDebugInfo, referencedDebugInfo); - } - } - // We also add the debug info to the initializing chunk since the resolution of that promise is - // also blocked by the referenced debug info. By adding it to both we can track it even if the array/element - // is extracted, or if the root is rendered as is. + // We add the debug info to the initializing chunk since the resolution of + // that promise is also blocked by the referenced debug info. By adding it + // to both we can track it even if the array/element/lazy is extracted, or + // if the root is rendered as is. if (parentChunk !== null) { - const parentDebugInfo = parentChunk._debugInfo; - if (parentDebugInfo !== referencedDebugInfo) { + const referencedDebugInfo = referencedChunk._debugInfo; + if (referencedDebugInfo !== null) { + const parentDebugInfo = parentChunk._debugInfo; for (let i = 0; i < referencedDebugInfo.length; ++i) { const debugInfoEntry = referencedDebugInfo[i]; if (debugInfoEntry.name != null) { @@ -1903,11 +1930,7 @@ function getOutlinedModel( case INITIALIZED: let value = chunk.value; for (let i = 1; i < path.length; i++) { - while ( - typeof value === 'object' && - value !== null && - value.$$typeof === REACT_LAZY_TYPE - ) { + while (isLazy(value)) { const referencedChunk: SomeChunk = value._payload; switch (referencedChunk.status) { case RESOLVED_MODEL: @@ -1977,11 +2000,7 @@ function getOutlinedModel( value = value[path[i]]; } - while ( - typeof value === 'object' && - value !== null && - value.$$typeof === REACT_LAZY_TYPE - ) { + while (isLazy(value)) { // If what we're referencing is a Lazy it must be because we inserted one as a virtual node // while it was blocked by other data. If it's no longer blocked, we can unwrap it. const referencedChunk: SomeChunk = value._payload; @@ -2010,7 +2029,7 @@ function getOutlinedModel( // 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, chunkValue); + transferReferencedDebugInfo(initializingChunk, chunk); } return chunkValue; case PENDING: @@ -2468,7 +2487,6 @@ function parseModelString( function parseModelTuple( response: Response, value: {+[key: string]: JSONValue} | $ReadOnlyArray, - isRoot: boolean, ): any { const tuple: [mixed, mixed, mixed, mixed] = (value: any); @@ -2477,7 +2495,6 @@ function parseModelTuple( // Or even change the ReactElement type to be an array. return createElement( response, - isRoot, tuple[1], tuple[2], tuple[3], @@ -2727,18 +2744,34 @@ function resolveChunkDebugInfo( chunk: SomeChunk, ): void { if (__DEV__ && enableAsyncDebugInfo) { - // Push the currently resolving chunk's debug info representing the stream - // on the Promise that was waiting on the stream. - const ioInfo = streamState._debugInfo; - const debugChunk = chunk._debugChunk; - if (debugChunk != null) { - // If there's a debug chunk, then we wait for it to resolve before adding - // the stream info as the last entry. - debugChunk.then(() => { - chunk._debugInfo.push({awaited: ioInfo}); - }); + // Add the currently resolving chunk's debug info representing the stream + // to the Promise that was waiting on the stream, or its underlying value. + const debugInfoEntry: ReactAsyncInfo = {awaited: streamState._debugInfo}; + + const addDebugInfo = () => { + const value = resolveLazy(chunk.value); + if (isReactElementOrArrayLike(value)) { + const debugInfo: ReactDebugInfo = [debugInfoEntry]; + if (value._debugInfo) { + // $FlowFixMe[method-unbinding] + value._debugInfo.push.apply(value._debugInfo, debugInfo); + } else { + Object.defineProperty(value, '_debugInfo', { + configurable: false, + enumerable: false, + writable: true, + value: debugInfo, + }); + } + } else if (chunk._debugInfo !== null) { + chunk._debugInfo.push(debugInfoEntry); + } + }; + + if (chunk.status === PENDING || chunk.status === BLOCKED) { + chunk.then(addDebugInfo, addDebugInfo); } else { - chunk._debugInfo.push({awaited: ioInfo}); + addDebugInfo(); } } } @@ -5044,8 +5077,7 @@ function createFromJSONCallback(response: Response) { return parseModelString(response, this, key, value); } if (typeof value === 'object' && value !== null) { - const isRoot = key === ''; - return parseModelTuple(response, value, isRoot); + return parseModelTuple(response, value); } return value; }; diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 0baee5a1f5098..b0f539bf2572c 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -327,8 +327,8 @@ describe('ReactFlight', () => { const transport = ReactNoopFlightServer.render(root); await act(async () => { - const promise = ReactNoopFlightClient.read(transport); - expect(getDebugInfo(promise)).toEqual( + const result = await ReactNoopFlightClient.read(transport); + expect(getDebugInfo(result)).toEqual( __DEV__ ? [ {time: 12}, @@ -346,7 +346,7 @@ describe('ReactFlight', () => { ] : undefined, ); - ReactNoop.render(await promise); + ReactNoop.render(result); }); expect(ReactNoop).toMatchRenderedOutput(Hello, Seb Smith); @@ -1378,9 +1378,7 @@ describe('ReactFlight', () => { environmentName: 'Server', }, ], - findSourceMapURLCalls: [ - [__filename, 'Server'], - [__filename, 'Server'], + findSourceMapURLCalls: expect.arrayContaining([ // TODO: What should we request here? The outer () or the inner (inspected-page.html)? ['inspected-page.html:29:11), ', 'Server'], [ @@ -1389,8 +1387,7 @@ describe('ReactFlight', () => { ], ['file:///testing.js', 'Server'], ['', 'Server'], - [__filename, 'Server'], - ], + ]), }); } else { expect(errors.map(getErrorForJestMatcher)).toEqual([ @@ -2785,8 +2782,8 @@ describe('ReactFlight', () => { ); await act(async () => { - const promise = ReactNoopFlightClient.read(transport); - expect(getDebugInfo(promise)).toEqual( + const result = await ReactNoopFlightClient.read(transport); + expect(getDebugInfo(result)).toEqual( __DEV__ ? [ {time: gate(flags => flags.enableAsyncDebugInfo) ? 22 : 20}, @@ -2803,11 +2800,10 @@ describe('ReactFlight', () => { ] : undefined, ); - const result = await promise; const thirdPartyChildren = await result.props.children[1]; // We expect the debug info to be transferred from the inner stream to the outer. - expect(getDebugInfo(thirdPartyChildren[0])).toEqual( + expect(getDebugInfo(await thirdPartyChildren[0])).toEqual( __DEV__ ? [ {time: gate(flags => flags.enableAsyncDebugInfo) ? 54 : 22}, // Clamped to the start @@ -2910,8 +2906,8 @@ describe('ReactFlight', () => { ); await act(async () => { - const promise = ReactNoopFlightClient.read(transport); - expect(getDebugInfo(promise)).toEqual( + const result = await ReactNoopFlightClient.read(transport); + expect(getDebugInfo(result)).toEqual( __DEV__ ? [ {time: 16}, @@ -2924,17 +2920,10 @@ describe('ReactFlight', () => { transport: expect.arrayContaining([]), }, }, - { - time: 16, - }, - { - time: 16, - }, {time: 31}, ] : undefined, ); - const result = await promise; const thirdPartyFragment = await result.props.children; expect(getDebugInfo(thirdPartyFragment)).toEqual( __DEV__ @@ -2949,15 +2938,7 @@ describe('ReactFlight', () => { children: {}, }, }, - { - time: 33, - }, - { - time: 33, - }, - { - time: 33, - }, + {time: 33}, ] : undefined, ); @@ -3013,8 +2994,8 @@ describe('ReactFlight', () => { ); await act(async () => { - const promise = ReactNoopFlightClient.read(transport); - expect(getDebugInfo(promise)).toEqual( + const result = await ReactNoopFlightClient.read(transport); + expect(getDebugInfo(result)).toEqual( __DEV__ ? [ {time: 16}, @@ -3040,7 +3021,6 @@ describe('ReactFlight', () => { ] : undefined, ); - const result = await promise; ReactNoop.render(result); }); @@ -3891,15 +3871,6 @@ describe('ReactFlight', () => { { time: 13, }, - { - time: 14, - }, - { - time: 15, - }, - { - time: 16, - }, ]); } else { expect(root._debugInfo).toBe(undefined); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index 55b434ce3eeff..7aaf4150db087 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -1240,7 +1240,7 @@ describe('ReactFlightDOMEdge', () => { env: 'Server', }); if (gate(flags => flags.enableAsyncDebugInfo)) { - expect(lazyWrapper._debugInfo).toEqual([ + expect(greeting._debugInfo).toEqual([ {time: 12}, greetInfo, {time: 13}, @@ -1259,7 +1259,7 @@ describe('ReactFlightDOMEdge', () => { } // The owner that created the span was the outer server component. // We expect the debug info to be referentially equal to the owner. - expect(greeting._owner).toBe(lazyWrapper._debugInfo[1]); + expect(greeting._owner).toBe(greeting._debugInfo[1]); } else { expect(lazyWrapper._debugInfo).toBe(undefined); expect(greeting._owner).toBe(undefined); @@ -1930,11 +1930,19 @@ describe('ReactFlightDOMEdge', () => { if (__DEV__) { expect(normalizeCodeLocInfo(componentStack)).toBe( - '\n in Component\n in Suspense\n in body\n in html\n in ClientRoot (at **)', + '\n in Component\n' + + ' in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in App (at **)\n' + + ' in ClientRoot (at **)', ); } else { expect(normalizeCodeLocInfo(componentStack)).toBe( - '\n in Suspense\n in body\n in html\n in ClientRoot (at **)', + '\n in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in ClientRoot (at **)', ); } diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index f069b23b293c0..59df3c24d6f40 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -722,11 +722,19 @@ describe('ReactFlightDOMNode', () => { if (__DEV__) { expect(normalizeCodeLocInfo(componentStack)).toBe( - '\n in Component (at **)\n in Suspense\n in body\n in html\n in ClientRoot (at **)', + '\n in Component (at **)\n' + + ' in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in App (at **)\n' + + ' in ClientRoot (at **)', ); } else { expect(normalizeCodeLocInfo(componentStack)).toBe( - '\n in Suspense\n in body\n in html\n in ClientRoot (at **)', + '\n in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in ClientRoot (at **)', ); } @@ -861,11 +869,19 @@ describe('ReactFlightDOMNode', () => { if (__DEV__) { expect(normalizeCodeLocInfo(componentStack)).toBe( - '\n in Component (at **)\n in Suspense\n in body\n in html\n in ClientRoot (at **)', + '\n in Component (at **)\n' + + ' in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in App (at **)\n' + + ' in ClientRoot (at **)', ); } else { expect(normalizeCodeLocInfo(componentStack)).toBe( - '\n in Suspense\n in body\n in html\n in ClientRoot (at **)', + '\n in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in ClientRoot (at **)', ); } From 81aab7078434066a3d8365082fb7f112f7c4c105 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 23 Sep 2025 21:23:06 +0200 Subject: [PATCH 11/18] Revert "Delay debug channel messages" This reverts commit 63d312cd67a562feffb4db07a92b7e99dbe8c4b9. --- fixtures/flight/src/index.js | 36 +----------------------------------- 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/fixtures/flight/src/index.js b/fixtures/flight/src/index.js index 50f9a9d6272e5..447b1957c8c09 100644 --- a/fixtures/flight/src/index.js +++ b/fixtures/flight/src/index.js @@ -14,40 +14,6 @@ function findSourceMapURL(fileName) { ); } -function createDelayedStream( - stream: ReadableStream -): ReadableStream { - return new ReadableStream({ - async start(controller) { - const reader = stream.getReader(); - - try { - while (true) { - const {done, value} = await reader.read(); - if (done) { - if (!controller.desiredSize === null) { - controller.close(); - } - break; - } else { - // Artificially delay between enqueuing chunks. - await new Promise(resolve => setTimeout(resolve, 10)); - if (controller.desiredSize !== null) { - controller.enqueue(value); - } - } - } - } catch (error) { - if (controller.desiredSize !== null) { - controller.error(error); - } - } finally { - reader.releaseLock(); - } - }, - }); -} - async function createWebSocketStream(url) { const ws = new WebSocket(url); ws.binaryType = 'arraybuffer'; @@ -83,7 +49,7 @@ async function createWebSocketStream(url) { }, }); - return {readable: createDelayedStream(readable), writable}; + return {readable, writable}; } let updateRoot; From 74e37216f1fb1d5eb0ac127c04d8ac16768d9991 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 23 Sep 2025 22:45:55 +0200 Subject: [PATCH 12/18] Transfer debug info from initialized lazy node to element --- .../react-client/src/ReactFlightClient.js | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 678b1aaa29213..ac589442e774d 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -1185,15 +1185,31 @@ function initializeElement( initializeFakeStack(response, owner); } - // In case the JSX runtime has validated the lazy type as a static child, we - // need to transfer this information to the element. - if ( - lazyNode && - lazyNode._store && - lazyNode._store.validated && - !element._store.validated - ) { - element._store.validated = lazyNode._store.validated; + if (lazyNode) { + // In case the JSX runtime has validated the lazy type as a static child, we + // need to transfer this information to the element. + if ( + lazyNode._store && + lazyNode._store.validated && + !element._store.validated + ) { + element._store.validated = lazyNode._store.validated; + } + + if (lazyNode._payload.status === INITIALIZED) { + const debugInfo = lazyNode._debugInfo.splice(0); + if (element._debugInfo) { + // $FlowFixMe[method-unbinding] + element._debugInfo.push.apply(element._debugInfo, debugInfo); + } else { + Object.defineProperty(element, '_debugInfo', { + configurable: false, + enumerable: false, + writable: true, + value: debugInfo, + }); + } + } } // TODO: We should be freezing the element but currently, we might write into From 5ef845247512b6ae26fa7db249e9324f78f90636 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 23 Sep 2025 22:52:33 +0200 Subject: [PATCH 13/18] Polish --- .../react-client/src/ReactFlightClient.js | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index ac589442e774d..5c6736ada7e20 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -981,20 +981,18 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { } } - if (__DEV__) { - if (isReactElementOrArrayLike(value)) { - const debugInfo = chunk._debugInfo.splice(0); - if (value._debugInfo) { - // $FlowFixMe[method-unbinding] - value._debugInfo.push.apply(value._debugInfo, debugInfo); - } else { - Object.defineProperty(value, '_debugInfo', { - configurable: false, - enumerable: false, - writable: true, - value: debugInfo, - }); - } + if (__DEV__ && isReactElementOrArrayLike(value)) { + const debugInfo = chunk._debugInfo.splice(0); + if (value._debugInfo) { + // $FlowFixMe[method-unbinding] + value._debugInfo.push.apply(value._debugInfo, debugInfo); + } else { + Object.defineProperty(value, '_debugInfo', { + configurable: false, + enumerable: false, + writable: true, + value: debugInfo, + }); } } @@ -1185,7 +1183,7 @@ function initializeElement( initializeFakeStack(response, owner); } - if (lazyNode) { + if (lazyNode !== null) { // In case the JSX runtime has validated the lazy type as a static child, we // need to transfer this information to the element. if ( @@ -1196,7 +1194,7 @@ function initializeElement( element._store.validated = lazyNode._store.validated; } - if (lazyNode._payload.status === INITIALIZED) { + if (lazyNode._payload.status === INITIALIZED && lazyNode._debugInfo) { const debugInfo = lazyNode._debugInfo.splice(0); if (element._debugInfo) { // $FlowFixMe[method-unbinding] @@ -1901,17 +1899,15 @@ function transferReferencedDebugInfo( // if the root is rendered as is. if (parentChunk !== null) { const referencedDebugInfo = referencedChunk._debugInfo; - if (referencedDebugInfo !== null) { - const parentDebugInfo = parentChunk._debugInfo; - for (let i = 0; i < referencedDebugInfo.length; ++i) { - const debugInfoEntry = referencedDebugInfo[i]; - if (debugInfoEntry.name != null) { - (debugInfoEntry: ReactComponentInfo); - // We're not transferring Component info since we use Component info - // in Debug info to fill in gaps between Fibers for the parent stack. - } else { - parentDebugInfo.push(debugInfoEntry); - } + const parentDebugInfo = parentChunk._debugInfo; + for (let i = 0; i < referencedDebugInfo.length; ++i) { + const debugInfoEntry = referencedDebugInfo[i]; + if (debugInfoEntry.name != null) { + (debugInfoEntry: ReactComponentInfo); + // We're not transferring Component info since we use Component info + // in Debug info to fill in gaps between Fibers for the parent stack. + } else { + parentDebugInfo.push(debugInfoEntry); } } } @@ -2779,7 +2775,7 @@ function resolveChunkDebugInfo( value: debugInfo, }); } - } else if (chunk._debugInfo !== null) { + } else { chunk._debugInfo.push(debugInfoEntry); } }; From 488ed8a3fc223bd5441b21f1bfbe830c276fe0b0 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 24 Sep 2025 19:04:09 +0200 Subject: [PATCH 14/18] Inline brand checks --- .../react-client/src/ReactFlightClient.js | 97 +++++++++++-------- 1 file changed, 57 insertions(+), 40 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 5c6736ada7e20..6d22af48f0f55 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -515,16 +515,23 @@ function wakeChunk( if (__DEV__ && chunk.status === INITIALIZED) { const resolvedValue = resolveLazy(value); - if (isReactElementOrArrayLike(resolvedValue) || isLazy(resolvedValue)) { + if ( + typeof resolvedValue === 'object' && + resolvedValue !== null && + (isArray(resolvedValue) || + typeof resolvedValue[ASYNC_ITERATOR] === 'function' || + resolvedValue.$$typeof === REACT_ELEMENT_TYPE || + resolvedValue.$$typeof === REACT_LAZY_TYPE) + ) { const debugInfo = chunk._debugInfo.splice(0); - if (resolvedValue._debugInfo) { + if (isArray(resolvedValue._debugInfo)) { // $FlowFixMe[method-unbinding] resolvedValue._debugInfo.push.apply( resolvedValue._debugInfo, debugInfo, ); } else { - Object.defineProperty(resolvedValue, '_debugInfo', { + Object.defineProperty((resolvedValue: any), '_debugInfo', { configurable: false, enumerable: false, writable: true, @@ -981,13 +988,20 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { } } - if (__DEV__ && isReactElementOrArrayLike(value)) { + if ( + __DEV__ && + typeof value === 'object' && + value !== null && + (isArray(value) || + typeof value[ASYNC_ITERATOR] === 'function' || + value.$$typeof === REACT_ELEMENT_TYPE) + ) { const debugInfo = chunk._debugInfo.splice(0); - if (value._debugInfo) { + if (isArray(value._debugInfo)) { // $FlowFixMe[method-unbinding] value._debugInfo.push.apply(value._debugInfo, debugInfo); } else { - Object.defineProperty(value, '_debugInfo', { + Object.defineProperty((value: any), '_debugInfo', { configurable: false, enumerable: false, writable: true, @@ -1089,7 +1103,11 @@ function getTaskName(type: mixed): string { // the client. There should only be one for any given owner chain. return '"use client"'; } - if (isLazy(type)) { + if ( + typeof type === 'object' && + type !== null && + type.$$typeof === REACT_LAZY_TYPE + ) { if (type._init === readChunk) { // This is a lazy node created by Flight. It is probably a client reference. // We use the "use client" string to indicate that this is the boundary into @@ -1385,7 +1403,11 @@ function fulfillReference( const {response, handler, parentObject, key, map, path} = reference; for (let i = 1; i < path.length; i++) { - while (isLazy(value)) { + while ( + typeof value === 'object' && + value !== null && + value.$$typeof === REACT_LAZY_TYPE + ) { // We never expect to see a Lazy node on this path because we encode those as // separate models. This must mean that we have inserted an extra lazy node // e.g. to replace a blocked element. We must instead look for it inside. @@ -1457,7 +1479,11 @@ function fulfillReference( value = value[path[i]]; } - while (isLazy(value)) { + while ( + typeof value === 'object' && + value !== null && + value.$$typeof === REACT_LAZY_TYPE + ) { // If what we're referencing is a Lazy it must be because we inserted one as a virtual node // while it was blocked by other data. If it's no longer blocked, we can unwrap it. const referencedChunk: SomeChunk = value._payload; @@ -1848,35 +1874,12 @@ function loadServerReference, T>( return (null: any); } -function isReactElementOrArrayLike( - value: any, - // eslint-disable-next-line no-undef -): value is {_debugInfo: null | ReactDebugInfo, ...} { - return ( - typeof value === 'object' && - value !== null && - (isArray(value) || - typeof value[ASYNC_ITERATOR] === 'function' || - value.$$typeof === REACT_ELEMENT_TYPE) - ); -} - -function isLazy( - value: any, - // eslint-disable-next-line no-undef -): implies value is LazyComponent< - React$Element, - SomeChunk>, -> { - return ( +function resolveLazy(value: any): mixed { + while ( typeof value === 'object' && value !== null && value.$$typeof === REACT_LAZY_TYPE - ); -} - -function resolveLazy(value: mixed): mixed { - while (isLazy(value)) { + ) { const payload: SomeChunk = value._payload; if (payload.status === INITIALIZED) { value = payload.value; @@ -1942,7 +1945,11 @@ function getOutlinedModel( case INITIALIZED: let value = chunk.value; for (let i = 1; i < path.length; i++) { - while (isLazy(value)) { + while ( + typeof value === 'object' && + value !== null && + value.$$typeof === REACT_LAZY_TYPE + ) { const referencedChunk: SomeChunk = value._payload; switch (referencedChunk.status) { case RESOLVED_MODEL: @@ -2012,7 +2019,11 @@ function getOutlinedModel( value = value[path[i]]; } - while (isLazy(value)) { + while ( + typeof value === 'object' && + value !== null && + value.$$typeof === REACT_LAZY_TYPE + ) { // If what we're referencing is a Lazy it must be because we inserted one as a virtual node // while it was blocked by other data. If it's no longer blocked, we can unwrap it. const referencedChunk: SomeChunk = value._payload; @@ -2762,13 +2773,19 @@ function resolveChunkDebugInfo( const addDebugInfo = () => { const value = resolveLazy(chunk.value); - if (isReactElementOrArrayLike(value)) { + if ( + typeof value === 'object' && + value !== null && + (isArray(value) || + typeof value[ASYNC_ITERATOR] === 'function' || + value.$$typeof === REACT_ELEMENT_TYPE) + ) { const debugInfo: ReactDebugInfo = [debugInfoEntry]; - if (value._debugInfo) { + if (isArray(value._debugInfo)) { // $FlowFixMe[method-unbinding] value._debugInfo.push.apply(value._debugInfo, debugInfo); } else { - Object.defineProperty(value, '_debugInfo', { + Object.defineProperty((value: any), '_debugInfo', { configurable: false, enumerable: false, writable: true, From 020d65ee25bcc41f5fc839b66a6fddfbac52fce8 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 24 Sep 2025 19:49:14 +0200 Subject: [PATCH 15/18] Use the same debug info logic in `initializeModelChunk` and `wakeChunk` --- .../react-client/src/ReactFlightClient.js | 65 ++++++++++++------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 6d22af48f0f55..52f9b1acfd23f 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -502,7 +502,7 @@ function createErrorChunk( function wakeChunk( listeners: Array mixed)>, value: T, - chunk: SomeChunk, + chunk: InitializedChunk, ): void { for (let i = 0; i < listeners.length; i++) { const listener = listeners[i]; @@ -513,7 +513,10 @@ function wakeChunk( } } - if (__DEV__ && chunk.status === INITIALIZED) { + if (__DEV__) { + // Remove the debug info from the initialized chunk, and add it to the inner + // value instead. This can be a React element, an array, or an uninitialized + // Lazy. const resolvedValue = resolveLazy(value); if ( typeof resolvedValue === 'object' && @@ -973,7 +976,14 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { if (resolveListeners !== null) { cyclicChunk.value = null; cyclicChunk.reason = null; - wakeChunk(resolveListeners, value, cyclicChunk); + for (let i = 0; i < resolveListeners.length; i++) { + const listener = resolveListeners[i]; + if (typeof listener === 'function') { + listener(value); + } else { + fulfillReference(listener, value, cyclicChunk); + } + } } if (initializingHandler !== null) { if (initializingHandler.errored) { @@ -988,25 +998,34 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { } } - if ( - __DEV__ && - typeof value === 'object' && - value !== null && - (isArray(value) || - typeof value[ASYNC_ITERATOR] === 'function' || - value.$$typeof === REACT_ELEMENT_TYPE) - ) { - const debugInfo = chunk._debugInfo.splice(0); - if (isArray(value._debugInfo)) { - // $FlowFixMe[method-unbinding] - value._debugInfo.push.apply(value._debugInfo, debugInfo); - } else { - Object.defineProperty((value: any), '_debugInfo', { - configurable: false, - enumerable: false, - writable: true, - value: debugInfo, - }); + if (__DEV__) { + // Remove the debug info from the initialized chunk, and add it to the + // inner value instead. This can be a React element, an array, or an + // uninitialized Lazy. + const resolvedValue = resolveLazy(value); + if ( + typeof resolvedValue === 'object' && + resolvedValue !== null && + (isArray(resolvedValue) || + typeof resolvedValue[ASYNC_ITERATOR] === 'function' || + resolvedValue.$$typeof === REACT_ELEMENT_TYPE || + resolvedValue.$$typeof === REACT_LAZY_TYPE) + ) { + const debugInfo = chunk._debugInfo.splice(0); + if (isArray(resolvedValue._debugInfo)) { + // $FlowFixMe[method-unbinding] + resolvedValue._debugInfo.push.apply( + resolvedValue._debugInfo, + debugInfo, + ); + } else { + Object.defineProperty((resolvedValue: any), '_debugInfo', { + configurable: false, + enumerable: false, + writable: true, + value: debugInfo, + }); + } } } @@ -3032,7 +3051,7 @@ function resolveStream>( resolvedChunk.value = stream; resolvedChunk.reason = controller; if (resolveListeners !== null) { - wakeChunk(resolveListeners, chunk.value, chunk); + wakeChunk(resolveListeners, chunk.value, (chunk: any)); } } From bbfef70f85e4abe998b223f713931f2c039a54a0 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 24 Sep 2025 19:53:34 +0200 Subject: [PATCH 16/18] Extract common logic into `moveDebugInfoFromChunkToInnerValue` --- .../react-client/src/ReactFlightClient.js | 98 +++++++------------ 1 file changed, 38 insertions(+), 60 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 52f9b1acfd23f..8676aae26e4e5 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -499,6 +499,37 @@ function createErrorChunk( return new ReactPromise(ERRORED, null, error); } +function moveDebugInfoFromChunkToInnerValue( + chunk: InitializedChunk, + value: T, +): void { + // Remove the debug info from the initialized chunk, and add it to the inner + // value instead. This can be a React element, an array, or an uninitialized + // Lazy. + const resolvedValue = resolveLazy(value); + if ( + typeof resolvedValue === 'object' && + resolvedValue !== null && + (isArray(resolvedValue) || + typeof resolvedValue[ASYNC_ITERATOR] === 'function' || + resolvedValue.$$typeof === REACT_ELEMENT_TYPE || + resolvedValue.$$typeof === REACT_LAZY_TYPE) + ) { + const debugInfo = chunk._debugInfo.splice(0); + if (isArray(resolvedValue._debugInfo)) { + // $FlowFixMe[method-unbinding] + resolvedValue._debugInfo.push.apply(resolvedValue._debugInfo, debugInfo); + } else { + Object.defineProperty((resolvedValue: any), '_debugInfo', { + configurable: false, + enumerable: false, + writable: true, + value: debugInfo, + }); + } + } +} + function wakeChunk( listeners: Array mixed)>, value: T, @@ -514,34 +545,7 @@ function wakeChunk( } if (__DEV__) { - // Remove the debug info from the initialized chunk, and add it to the inner - // value instead. This can be a React element, an array, or an uninitialized - // Lazy. - const resolvedValue = resolveLazy(value); - if ( - typeof resolvedValue === 'object' && - resolvedValue !== null && - (isArray(resolvedValue) || - typeof resolvedValue[ASYNC_ITERATOR] === 'function' || - resolvedValue.$$typeof === REACT_ELEMENT_TYPE || - resolvedValue.$$typeof === REACT_LAZY_TYPE) - ) { - const debugInfo = chunk._debugInfo.splice(0); - if (isArray(resolvedValue._debugInfo)) { - // $FlowFixMe[method-unbinding] - resolvedValue._debugInfo.push.apply( - resolvedValue._debugInfo, - debugInfo, - ); - } else { - Object.defineProperty((resolvedValue: any), '_debugInfo', { - configurable: false, - enumerable: false, - writable: true, - value: debugInfo, - }); - } - } + moveDebugInfoFromChunkToInnerValue(chunk, value); } } @@ -997,41 +1001,13 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { return; } } - - if (__DEV__) { - // Remove the debug info from the initialized chunk, and add it to the - // inner value instead. This can be a React element, an array, or an - // uninitialized Lazy. - const resolvedValue = resolveLazy(value); - if ( - typeof resolvedValue === 'object' && - resolvedValue !== null && - (isArray(resolvedValue) || - typeof resolvedValue[ASYNC_ITERATOR] === 'function' || - resolvedValue.$$typeof === REACT_ELEMENT_TYPE || - resolvedValue.$$typeof === REACT_LAZY_TYPE) - ) { - const debugInfo = chunk._debugInfo.splice(0); - if (isArray(resolvedValue._debugInfo)) { - // $FlowFixMe[method-unbinding] - resolvedValue._debugInfo.push.apply( - resolvedValue._debugInfo, - debugInfo, - ); - } else { - Object.defineProperty((resolvedValue: any), '_debugInfo', { - configurable: false, - enumerable: false, - writable: true, - value: debugInfo, - }); - } - } - } - const initializedChunk: InitializedChunk = (chunk: any); initializedChunk.status = INITIALIZED; initializedChunk.value = value; + + if (__DEV__) { + moveDebugInfoFromChunkToInnerValue(initializedChunk, value); + } } catch (error) { const erroredChunk: ErroredChunk = (chunk: any); erroredChunk.status = ERRORED; @@ -1231,6 +1207,8 @@ function initializeElement( element._store.validated = lazyNode._store.validated; } + // If the lazy node is initialized, we move its debug info to the inner + // value. if (lazyNode._payload.status === INITIALIZED && lazyNode._debugInfo) { const debugInfo = lazyNode._debugInfo.splice(0); if (element._debugInfo) { From 57eee57e121bce7fcf20b6fd86e209e373781668 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 24 Sep 2025 20:01:17 +0200 Subject: [PATCH 17/18] Unshift instead of push debug info --- packages/react-client/src/ReactFlightClient.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 8676aae26e4e5..b7f9236fe7024 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -518,7 +518,10 @@ function moveDebugInfoFromChunkToInnerValue( const debugInfo = chunk._debugInfo.splice(0); if (isArray(resolvedValue._debugInfo)) { // $FlowFixMe[method-unbinding] - resolvedValue._debugInfo.push.apply(resolvedValue._debugInfo, debugInfo); + resolvedValue._debugInfo.unshift.apply( + resolvedValue._debugInfo, + debugInfo, + ); } else { Object.defineProperty((resolvedValue: any), '_debugInfo', { configurable: false, @@ -1213,7 +1216,7 @@ function initializeElement( const debugInfo = lazyNode._debugInfo.splice(0); if (element._debugInfo) { // $FlowFixMe[method-unbinding] - element._debugInfo.push.apply(element._debugInfo, debugInfo); + element._debugInfo.unshift.apply(element._debugInfo, debugInfo); } else { Object.defineProperty(element, '_debugInfo', { configurable: false, From 74ce56a72dbafc13c4c8c45c89daf00b924379dd Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 24 Sep 2025 20:58:29 +0200 Subject: [PATCH 18/18] Avoid creating a closure unnecessarily Also add stream debug info to Lazy. --- .../react-client/src/ReactFlightClient.js | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index b7f9236fe7024..701d9df33cdd9 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -2762,6 +2762,33 @@ function incrementChunkDebugInfo( } } +function addDebugInfo(chunk: SomeChunk, debugInfo: ReactDebugInfo): void { + const value = resolveLazy(chunk.value); + if ( + typeof value === 'object' && + value !== null && + (isArray(value) || + typeof value[ASYNC_ITERATOR] === 'function' || + value.$$typeof === REACT_ELEMENT_TYPE || + value.$$typeof === REACT_LAZY_TYPE) + ) { + if (isArray(value._debugInfo)) { + // $FlowFixMe[method-unbinding] + value._debugInfo.push.apply(value._debugInfo, debugInfo); + } else { + Object.defineProperty((value: any), '_debugInfo', { + configurable: false, + enumerable: false, + writable: true, + value: debugInfo, + }); + } + } else { + // $FlowFixMe[method-unbinding] + chunk._debugInfo.push.apply(chunk._debugInfo, debugInfo); + } +} + function resolveChunkDebugInfo( streamState: StreamState, chunk: SomeChunk, @@ -2769,38 +2796,12 @@ function resolveChunkDebugInfo( if (__DEV__ && enableAsyncDebugInfo) { // Add the currently resolving chunk's debug info representing the stream // to the Promise that was waiting on the stream, or its underlying value. - const debugInfoEntry: ReactAsyncInfo = {awaited: streamState._debugInfo}; - - const addDebugInfo = () => { - const value = resolveLazy(chunk.value); - if ( - typeof value === 'object' && - value !== null && - (isArray(value) || - typeof value[ASYNC_ITERATOR] === 'function' || - value.$$typeof === REACT_ELEMENT_TYPE) - ) { - const debugInfo: ReactDebugInfo = [debugInfoEntry]; - if (isArray(value._debugInfo)) { - // $FlowFixMe[method-unbinding] - value._debugInfo.push.apply(value._debugInfo, debugInfo); - } else { - Object.defineProperty((value: any), '_debugInfo', { - configurable: false, - enumerable: false, - writable: true, - value: debugInfo, - }); - } - } else { - chunk._debugInfo.push(debugInfoEntry); - } - }; - + const debugInfo: ReactDebugInfo = [{awaited: streamState._debugInfo}]; if (chunk.status === PENDING || chunk.status === BLOCKED) { - chunk.then(addDebugInfo, addDebugInfo); + const boundAddDebugInfo = addDebugInfo.bind(null, chunk, debugInfo); + chunk.then(boundAddDebugInfo, boundAddDebugInfo); } else { - addDebugInfo(); + addDebugInfo(chunk, debugInfo); } } }