From 64c896b55dc0c16cf07571557a9e080e444995bf Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 30 Oct 2025 15:31:49 +0100 Subject: [PATCH 1/9] [Fizz] Push halted await to the owner stack for late-arriving I/O info To quote from #33634: > If an aborted task is not rendering, then this is an async abort. > Conceptually it's as if the abort happened inside the async gap. The > abort reason's stack frame won't have that on the stack so instead we > use the owner stack and debug task of any halted async debug info. This PR extends that logic to also try to resolve lazy components to find debug info that has been transferred to the inner value. In addition, we ignore any time and component info that might precede the I/O info, effectively allowing resolved I/O to also be considered for the owner stack. This is useful in a scenario where the Flight rendering might have been completed (and not prematurely aborted), but then the Fizz rendering is intentionally aborted before all chunks were received, while still allowing the remaining chunks (including I/O info for halted components) to be processed while the prerender is in the aborting state. --- .../src/__tests__/ReactFlightDOMNode-test.js | 215 +++++++++++++++++- packages/react-server/src/ReactFizzServer.js | 40 +++- 2 files changed, 241 insertions(+), 14 deletions(-) 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 d59f298f99496..2e47e77db906f 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -86,12 +86,25 @@ describe('ReactFlightDOMNode', () => { ); } - function normalizeCodeLocInfo(str) { + const relativeFilename = path.relative(__dirname, __filename); + + function normalizeCodeLocInfo(str, {preserveLocation = false} = {}) { return ( str && - str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) { - return ' in ' + name + (/\d/.test(m) ? ' (at **)' : ''); - }) + str.replace( + /^ +(?:at|in) ([\S]+) ([^\n]*)/gm, + function (m, name, location) { + return ( + ' in ' + + name + + (/\d/.test(m) + ? preserveLocation + ? ' ' + location.replace(__filename, relativeFilename) + : ' (at **)' + : '') + ); + }, + ) ); } @@ -1169,4 +1182,198 @@ describe('ReactFlightDOMNode', () => { // Must not throw an error. await readable.pipeTo(writable); }); + + describe('with real timers', () => { + // These tests schedule their rendering in a way that requires real timers + // to be used to accurately represent how this interacts with React's + // internal scheduling. + + beforeEach(() => { + jest.useRealTimers(); + }); + + afterEach(() => { + jest.useFakeTimers(); + }); + + it('should use late-arriving I/O debug info to enhance component and owner stacks when aborting a prerender', async () => { + let resolveDynamicData; + + async function getCachedData() { + // Cached data resolves in microtasks. + return Promise.resolve('Hi'); + } + + async function getDynamicData() { + return new Promise(resolve => { + resolveDynamicData = resolve; + }); + } + + async function Dynamic() { + const cachedData = await getCachedData(); + const dynamicData = await getDynamicData(); + + return ( +

+ {cachedData} {dynamicData} +

+ ); + } + + function App() { + return ReactServer.createElement( + 'html', + null, + ReactServer.createElement( + 'body', + null, + ReactServer.createElement(Dynamic), + ), + ); + } + + const stream = await ReactServerDOMServer.renderToPipeableStream( + ReactServer.createElement(App), + webpackMap, + {filterStackFrame}, + ); + + const staticChunks = []; + const dynamicChunks = []; + let isStatic = true; + + const passThrough = new Stream.PassThrough(streamOptions); + stream.pipe(passThrough); + + // Split chunks into static and dynamic chunks. + passThrough.on('data', chunk => { + if (isStatic) { + staticChunks.push(chunk); + } else { + dynamicChunks.push(chunk); + } + }); + + await new Promise(resolve => { + setTimeout(() => { + isStatic = false; + resolveDynamicData('Josh'); + resolve(); + }); + }); + + await new Promise(resolve => { + passThrough.on('end', resolve); + }); + + // Create a new Readable and push all static chunks immediately. + const readable = new Stream.Readable({...streamOptions, read() {}}); + for (let i = 0; i < staticChunks.length; i++) { + readable.push(staticChunks[i]); + } + + const abortController = new AbortController(); + + // When prerendering is aborted, push all dynamic chunks. + abortController.signal.addEventListener( + 'abort', + () => { + for (let i = 0; i < dynamicChunks.length; i++) { + readable.push(dynamicChunks[i]); + } + }, + {once: true}, + ); + + const response = ReactServerDOMClient.createFromNodeStream(readable, { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + + function ClientRoot() { + return use(response); + } + + let componentStack; + let ownerStack; + + const {prelude} = await new Promise(resolve => { + let result; + + setTimeout(() => { + result = ReactDOMFizzStatic.prerenderToNodeStream( + React.createElement(ClientRoot), + { + signal: abortController.signal, + onError(error, errorInfo) { + componentStack = errorInfo.componentStack; + ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + }, + }, + ); + }); + + setTimeout(() => { + abortController.abort(); + resolve(result); + }); + }); + + const prerenderHTML = await readResult(prelude); + + expect(prerenderHTML).toContain(''); + + if (__DEV__) { + expect( + normalizeCodeLocInfo(componentStack, {preserveLocation: true}), + ).toBe( + '\n' + + ' in Dynamic' + + (gate(flags => flags.enableAsyncDebugInfo) + ? ' (file://ReactFlightDOMNode-test.js:1215:33)\n' + : '\n') + + ' in body\n' + + ' in html\n' + + ' in App (file://ReactFlightDOMNode-test.js:1231:25)\n' + + ' in ClientRoot (ReactFlightDOMNode-test.js:1297:16)', + ); + } else { + expect( + normalizeCodeLocInfo(componentStack, {preserveLocation: true}), + ).toBe( + '\n' + + ' in body\n' + + ' in html\n' + + ' in ClientRoot (ReactFlightDOMNode-test.js:1297:16)', + ); + } + + if (__DEV__) { + if (gate(flags => flags.enableAsyncDebugInfo)) { + expect( + normalizeCodeLocInfo(ownerStack, {preserveLocation: true}), + ).toBe( + '\n' + + ' in Dynamic (file://ReactFlightDOMNode-test.js:1215:33)\n' + + ' in App (file://ReactFlightDOMNode-test.js:1231:25)', + ); + } else { + expect( + normalizeCodeLocInfo(ownerStack, {preserveLocation: true}), + ).toBe( + '' + + '\n' + + ' in App (file://ReactFlightDOMNode-test.js:1231:25)', + ); + } + } else { + expect(ownerStack).toBeNull(); + } + }); + }); }); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 07408d64e8817..572e674286c09 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -1004,14 +1004,6 @@ function pushHaltedAwaitOnComponentStack( if (debugInfo != null) { for (let i = debugInfo.length - 1; i >= 0; i--) { const info = debugInfo[i]; - if (typeof info.name === 'string') { - // This is a Server Component. Any awaits in previous Server Components already resolved. - break; - } - if (typeof info.time === 'number') { - // This had an end time. Any awaits before this must have already resolved. - break; - } if (info.awaited != null) { const asyncInfo: ReactAsyncInfo = (info: any); const bestStack = @@ -4653,10 +4645,38 @@ function abortTask(task: Task, request: Request, error: mixed): void { // If the task is not rendering, then this is an async abort. Conceptually it's as if // the abort happened inside the async gap. The abort reason's stack frame won't have that // on the stack so instead we use the owner stack and debug task of any halted async debug info. - const node: any = task.node; + let node: any = task.node; if (node !== null && typeof node === 'object') { // Push a fake component stack frame that represents the await. - pushHaltedAwaitOnComponentStack(task, node._debugInfo); + let debugInfo = node._debugInfo; + if (debugInfo == null || debugInfo.length === 0) { + // If there's no debug info, try resolving lazy components to find debug + // info that has been transferred to the inner value. + while ( + typeof node === 'object' && + node !== null && + node.$$typeof === REACT_LAZY_TYPE + ) { + const payload = node._payload; + if (payload.status === 'fulfilled') { + node = payload.value; + continue; + } + break; + } + if ( + typeof node === 'object' && + node !== null && + (isArray(node) || + typeof node[ASYNC_ITERATOR] === 'function' || + node.$$typeof === REACT_ELEMENT_TYPE || + node.$$typeof === REACT_LAZY_TYPE) && + isArray(node._debugInfo) + ) { + debugInfo = node._debugInfo; + } + } + pushHaltedAwaitOnComponentStack(task, debugInfo); /* if (task.thenableState !== null) { // TODO: If we were stalled inside use() of a Client Component then we should From ebc1668be03619241cb230d53f1de177bfd82377 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 30 Oct 2025 16:11:11 +0100 Subject: [PATCH 2/9] Always prefer debug info from inner values of lazy nodes --- packages/react-server/src/ReactFizzServer.js | 48 ++++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 572e674286c09..5913cf8eb6446 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -4649,32 +4649,30 @@ function abortTask(task: Task, request: Request, error: mixed): void { if (node !== null && typeof node === 'object') { // Push a fake component stack frame that represents the await. let debugInfo = node._debugInfo; - if (debugInfo == null || debugInfo.length === 0) { - // If there's no debug info, try resolving lazy components to find debug - // info that has been transferred to the inner value. - while ( - typeof node === 'object' && - node !== null && - node.$$typeof === REACT_LAZY_TYPE - ) { - const payload = node._payload; - if (payload.status === 'fulfilled') { - node = payload.value; - continue; - } - break; - } - if ( - typeof node === 'object' && - node !== null && - (isArray(node) || - typeof node[ASYNC_ITERATOR] === 'function' || - node.$$typeof === REACT_ELEMENT_TYPE || - node.$$typeof === REACT_LAZY_TYPE) && - isArray(node._debugInfo) - ) { - debugInfo = node._debugInfo; + // First resolve lazy nodes to find debug info that has been transferred + // to the inner value. + while ( + typeof node === 'object' && + node !== null && + node.$$typeof === REACT_LAZY_TYPE + ) { + const payload = node._payload; + if (payload.status === 'fulfilled') { + node = payload.value; + continue; } + break; + } + if ( + typeof node === 'object' && + node !== null && + (isArray(node) || + typeof node[ASYNC_ITERATOR] === 'function' || + node.$$typeof === REACT_ELEMENT_TYPE || + node.$$typeof === REACT_LAZY_TYPE) && + isArray(node._debugInfo) + ) { + debugInfo = node._debugInfo; } pushHaltedAwaitOnComponentStack(task, debugInfo); /* From 728d8545df9ea761bd6932e738804f24b52c7821 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 30 Oct 2025 16:15:53 +0100 Subject: [PATCH 3/9] Fix `prerenderHTML` check --- .../src/__tests__/ReactFlightDOMNode-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2e47e77db906f..569bd561b38ea 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -1326,7 +1326,7 @@ describe('ReactFlightDOMNode', () => { const prerenderHTML = await readResult(prelude); - expect(prerenderHTML).toContain(''); + expect(prerenderHTML).toBe(''); if (__DEV__) { expect( From 65104ef35c02a655ae3142c64c9966068a6eeabe Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sat, 1 Nov 2025 09:17:03 +0100 Subject: [PATCH 4/9] Add (debug) end time to Flight client Not really happy about calling the public option `endTime` instead of `debugEndTime`, but this is following the `startTime`/`debugStartTime` naming pattern. --- packages/react-client/src/ReactFlightClient.js | 5 +++++ .../src/client/ReactFlightDOMClientBrowser.js | 2 ++ .../src/client/ReactFlightDOMClientNode.js | 2 ++ .../src/client/ReactFlightDOMClientBrowser.js | 2 ++ .../src/client/ReactFlightDOMClientEdge.js | 2 ++ .../src/client/ReactFlightDOMClientNode.js | 2 ++ .../src/client/ReactFlightDOMClientBrowser.js | 2 ++ .../src/client/ReactFlightDOMClientEdge.js | 2 ++ .../src/client/ReactFlightDOMClientNode.js | 2 ++ .../src/client/ReactFlightDOMClientBrowser.js | 2 ++ .../src/client/ReactFlightDOMClientEdge.js | 2 ++ .../src/client/ReactFlightDOMClientNode.js | 2 ++ 12 files changed, 27 insertions(+) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 503f79e4cc582..299fb67e42650 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -367,6 +367,7 @@ type Response = { _debugRootStack?: null | Error, // DEV-only _debugRootTask?: null | ConsoleTask, // DEV-only _debugStartTime: number, // DEV-only + _debugEndTime?: number, // DEV-only _debugIOStarted: boolean, // DEV-only _debugFindSourceMapURL?: void | FindSourceMapURLCallback, // DEV-only _debugChannel?: void | DebugChannel, // DEV-only @@ -2578,6 +2579,7 @@ function ResponseInstance( replayConsole: boolean, // DEV-only environmentName: void | string, // DEV-only debugStartTime: void | number, // DEV-only + debugEndTime: void | number, // DEV-only debugChannel: void | DebugChannel, // DEV-only ) { const chunks: Map> = new Map(); @@ -2645,6 +2647,7 @@ function ResponseInstance( // and is not considered I/O required to load the stream. setTimeout(markIOStarted.bind(this), 0); } + this._debugEndTime = debugEndTime == null ? null : debugEndTime; this._debugFindSourceMapURL = findSourceMapURL; this._debugChannel = debugChannel; this._blockedConsole = null; @@ -2688,6 +2691,7 @@ export function createResponse( replayConsole: boolean, // DEV-only environmentName: void | string, // DEV-only debugStartTime: void | number, // DEV-only + debugEndTime: void | number, // DEV-only debugChannel: void | DebugChannel, // DEV-only ): WeakResponse { return getWeakResponse( @@ -2704,6 +2708,7 @@ export function createResponse( replayConsole, environmentName, debugStartTime, + debugEndTime, debugChannel, ), ); diff --git a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js index 371f08abc9a62..ee2475287d037 100644 --- a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js @@ -53,6 +53,7 @@ export type Options = { replayConsoleLogs?: boolean, environmentName?: string, startTime?: number, + endTime?: number, }; function createDebugCallbackFromWritableStream( @@ -107,6 +108,7 @@ function createResponseFromOptions(options: void | Options) { __DEV__ && options && options.startTime != null ? options.startTime : undefined, + __DEV__ && options && options.endTime != null ? options.endTime : undefined, debugChannel, ); } diff --git a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js index 78dce936158eb..38ac43d01315b 100644 --- a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js @@ -58,6 +58,7 @@ export type Options = { replayConsoleLogs?: boolean, environmentName?: string, startTime?: number, + endTime?: number, // For the Node.js client we only support a single-direction debug channel. debugChannel?: Readable, }; @@ -116,6 +117,7 @@ function createFromNodeStream( __DEV__ && options && options.startTime != null ? options.startTime : undefined, + __DEV__ && options && options.endTime != null ? options.endTime : undefined, debugChannel, ); diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js index 0f0141e640988..b304d442046db 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js @@ -132,6 +132,7 @@ function createResponseFromOptions(options: void | Options) { __DEV__ && options && options.startTime != null ? options.startTime : undefined, + __DEV__ && options && options.endTime != null ? options.endTime : undefined, debugChannel, ); } @@ -209,6 +210,7 @@ export type Options = { replayConsoleLogs?: boolean, environmentName?: string, startTime?: number, + endTime?: number, }; export function createFromReadableStream( diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js index 5c8d1023b2373..f58f8534348e7 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js @@ -80,6 +80,7 @@ export type Options = { replayConsoleLogs?: boolean, environmentName?: string, startTime?: number, + endTime?: number, // For the Edge client we only support a single-direction debug channel. debugChannel?: {readable?: ReadableStream, ...}, }; @@ -111,6 +112,7 @@ function createResponseFromOptions(options?: Options) { __DEV__ && options && options.startTime != null ? options.startTime : undefined, + __DEV__ && options && options.endTime != null ? options.endTime : undefined, debugChannel, ); } diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js index fbc633a175321..a9b82e343fb51 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js @@ -53,6 +53,7 @@ export type Options = { replayConsoleLogs?: boolean, environmentName?: string, startTime?: number, + endTime?: number, // For the Node.js client we only support a single-direction debug channel. debugChannel?: Readable, }; @@ -107,6 +108,7 @@ export function createFromNodeStream( __DEV__ && options && options.startTime != null ? options.startTime : undefined, + __DEV__ && options && options.endTime != null ? options.endTime : undefined, debugChannel, ); diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js index b3d31bd1bbba9..0bf615001982f 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js @@ -52,6 +52,7 @@ export type Options = { replayConsoleLogs?: boolean, environmentName?: string, startTime?: number, + endTime?: number, }; function createDebugCallbackFromWritableStream( @@ -106,6 +107,7 @@ function createResponseFromOptions(options: void | Options) { __DEV__ && options && options.startTime != null ? options.startTime : undefined, + __DEV__ && options && options.endTime != null ? options.endTime : undefined, debugChannel, ); } diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js index 5517e0f73cf94..c6dd4ee94ad11 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js @@ -80,6 +80,7 @@ export type Options = { replayConsoleLogs?: boolean, environmentName?: string, startTime?: number, + endTime?: number, // For the Edge client we only support a single-direction debug channel. debugChannel?: {readable?: ReadableStream, ...}, }; @@ -113,6 +114,7 @@ function createResponseFromOptions(options: Options) { __DEV__ && options && options.startTime != null ? options.startTime : undefined, + __DEV__ && options && options.endTime != null ? options.endTime : undefined, debugChannel, ); } diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js index 6d117929df007..73d79800c9219 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js @@ -61,6 +61,7 @@ export type Options = { replayConsoleLogs?: boolean, environmentName?: string, startTime?: number, + endTime?: number, // For the Node.js client we only support a single-direction debug channel. debugChannel?: Readable, }; @@ -118,6 +119,7 @@ function createFromNodeStream( __DEV__ && options && options.startTime != null ? options.startTime : undefined, + __DEV__ && options && options.endTime != null ? options.endTime : undefined, debugChannel, ); diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js index b3d31bd1bbba9..0bf615001982f 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js @@ -52,6 +52,7 @@ export type Options = { replayConsoleLogs?: boolean, environmentName?: string, startTime?: number, + endTime?: number, }; function createDebugCallbackFromWritableStream( @@ -106,6 +107,7 @@ function createResponseFromOptions(options: void | Options) { __DEV__ && options && options.startTime != null ? options.startTime : undefined, + __DEV__ && options && options.endTime != null ? options.endTime : undefined, debugChannel, ); } diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js index bc4caac767fb8..2cf668f679d33 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js @@ -80,6 +80,7 @@ export type Options = { replayConsoleLogs?: boolean, environmentName?: string, startTime?: number, + endTime?: number, // For the Edge client we only support a single-direction debug channel. debugChannel?: {readable?: ReadableStream, ...}, }; @@ -113,6 +114,7 @@ function createResponseFromOptions(options: Options) { __DEV__ && options && options.startTime != null ? options.startTime : undefined, + __DEV__ && options && options.endTime != null ? options.endTime : undefined, debugChannel, ); } diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js index 6d117929df007..73d79800c9219 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js @@ -61,6 +61,7 @@ export type Options = { replayConsoleLogs?: boolean, environmentName?: string, startTime?: number, + endTime?: number, // For the Node.js client we only support a single-direction debug channel. debugChannel?: Readable, }; @@ -118,6 +119,7 @@ function createFromNodeStream( __DEV__ && options && options.startTime != null ? options.startTime : undefined, + __DEV__ && options && options.endTime != null ? options.endTime : undefined, debugChannel, ); From 81a7f2684cf69d35f0bf6e98c672dd9ffdd3996e Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Fri, 31 Oct 2025 22:50:42 +0100 Subject: [PATCH 5/9] Filter debug info when debug chunk resolves That's too late though. We probably need to prevent adding the debug info in the first place. --- .../react-client/src/ReactFlightClient.js | 51 +++++++++++++++++-- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 299fb67e42650..f569954b7893d 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -2804,7 +2804,39 @@ function incrementChunkDebugInfo( } } -function addAsyncInfo(chunk: SomeChunk, asyncInfo: ReactAsyncInfo): void { +function filterDebugInfo( + response: Response, + value: {_debugInfo: ReactDebugInfo, ...}, +) { + if (response._debugEndTime === null) { + // No end time was defined, so we keep all debug info entries. + return; + } + + // Remove any debug info entries that arrived after the defined end time. + const relativeEndTime = + response._debugEndTime - + // $FlowFixMe[prop-missing] + performance.timeOrigin; + const debugInfo = []; + for (let i = 0; i < value._debugInfo.length; i++) { + const info = value._debugInfo[i]; + if (typeof info.time === 'number' && info.time > relativeEndTime) { + break; + } + if (info.awaited != null && info.awaited.end > relativeEndTime) { + break; + } + debugInfo.push(info); + } + value._debugInfo = debugInfo; +} + +function finalizeDebugInfo( + response: Response, + chunk: SomeChunk, + asyncInfo: ReactAsyncInfo, +): void { const value = resolveLazy(chunk.value); if ( typeof value === 'object' && @@ -2815,8 +2847,11 @@ function addAsyncInfo(chunk: SomeChunk, asyncInfo: ReactAsyncInfo): void { value.$$typeof === REACT_LAZY_TYPE) ) { if (isArray(value._debugInfo)) { + const valueWithDebugInfo: {_debugInfo: ReactDebugInfo, ...} = + (value: any); + filterDebugInfo(response, valueWithDebugInfo); // $FlowFixMe[method-unbinding] - value._debugInfo.push(asyncInfo); + valueWithDebugInfo._debugInfo.push(asyncInfo); } else { Object.defineProperty((value: any), '_debugInfo', { configurable: false, @@ -2826,6 +2861,7 @@ function addAsyncInfo(chunk: SomeChunk, asyncInfo: ReactAsyncInfo): void { }); } } else { + filterDebugInfo(response, chunk); // $FlowFixMe[method-unbinding] chunk._debugInfo.push(asyncInfo); } @@ -2844,10 +2880,15 @@ function resolveChunkDebugInfo( // to the Promise that was waiting on the stream, or its underlying value. const asyncInfo: ReactAsyncInfo = {awaited: streamState._debugInfo}; if (chunk.status === PENDING || chunk.status === BLOCKED) { - const boundAddAsyncInfo = addAsyncInfo.bind(null, chunk, asyncInfo); - chunk.then(boundAddAsyncInfo, boundAddAsyncInfo); + const boundFinalizeDebugInfo = finalizeDebugInfo.bind( + null, + response, + chunk, + asyncInfo, + ); + chunk.then(boundFinalizeDebugInfo, boundFinalizeDebugInfo); } else { - addAsyncInfo(chunk, asyncInfo); + finalizeDebugInfo(response, chunk, asyncInfo); } } } From c11c2d75c5762725d00c586691ae94e24ae58a21 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Fri, 31 Oct 2025 23:16:25 +0100 Subject: [PATCH 6/9] Revert "Filter debug info when debug chunk resolves" This reverts commit 665c671b0b87640d1fc52c39eb4aec4f5b0c72ee. --- .../react-client/src/ReactFlightClient.js | 51 ++----------------- 1 file changed, 5 insertions(+), 46 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index f569954b7893d..299fb67e42650 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -2804,39 +2804,7 @@ function incrementChunkDebugInfo( } } -function filterDebugInfo( - response: Response, - value: {_debugInfo: ReactDebugInfo, ...}, -) { - if (response._debugEndTime === null) { - // No end time was defined, so we keep all debug info entries. - return; - } - - // Remove any debug info entries that arrived after the defined end time. - const relativeEndTime = - response._debugEndTime - - // $FlowFixMe[prop-missing] - performance.timeOrigin; - const debugInfo = []; - for (let i = 0; i < value._debugInfo.length; i++) { - const info = value._debugInfo[i]; - if (typeof info.time === 'number' && info.time > relativeEndTime) { - break; - } - if (info.awaited != null && info.awaited.end > relativeEndTime) { - break; - } - debugInfo.push(info); - } - value._debugInfo = debugInfo; -} - -function finalizeDebugInfo( - response: Response, - chunk: SomeChunk, - asyncInfo: ReactAsyncInfo, -): void { +function addAsyncInfo(chunk: SomeChunk, asyncInfo: ReactAsyncInfo): void { const value = resolveLazy(chunk.value); if ( typeof value === 'object' && @@ -2847,11 +2815,8 @@ function finalizeDebugInfo( value.$$typeof === REACT_LAZY_TYPE) ) { if (isArray(value._debugInfo)) { - const valueWithDebugInfo: {_debugInfo: ReactDebugInfo, ...} = - (value: any); - filterDebugInfo(response, valueWithDebugInfo); // $FlowFixMe[method-unbinding] - valueWithDebugInfo._debugInfo.push(asyncInfo); + value._debugInfo.push(asyncInfo); } else { Object.defineProperty((value: any), '_debugInfo', { configurable: false, @@ -2861,7 +2826,6 @@ function finalizeDebugInfo( }); } } else { - filterDebugInfo(response, chunk); // $FlowFixMe[method-unbinding] chunk._debugInfo.push(asyncInfo); } @@ -2880,15 +2844,10 @@ function resolveChunkDebugInfo( // to the Promise that was waiting on the stream, or its underlying value. const asyncInfo: ReactAsyncInfo = {awaited: streamState._debugInfo}; if (chunk.status === PENDING || chunk.status === BLOCKED) { - const boundFinalizeDebugInfo = finalizeDebugInfo.bind( - null, - response, - chunk, - asyncInfo, - ); - chunk.then(boundFinalizeDebugInfo, boundFinalizeDebugInfo); + const boundAddAsyncInfo = addAsyncInfo.bind(null, chunk, asyncInfo); + chunk.then(boundAddAsyncInfo, boundAddAsyncInfo); } else { - finalizeDebugInfo(response, chunk, asyncInfo); + addAsyncInfo(chunk, asyncInfo); } } } From b4b1ba0f245ad9c0f9774555c3238afd2ddc35d8 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sat, 1 Nov 2025 09:18:13 +0100 Subject: [PATCH 7/9] Use `endTime` in test --- .../src/__tests__/ReactFlightDOMNode-test.js | 79 ++++++++++++------- 1 file changed, 51 insertions(+), 28 deletions(-) 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 569bd561b38ea..646735a54b769 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -1197,11 +1197,20 @@ describe('ReactFlightDOMNode', () => { }); it('should use late-arriving I/O debug info to enhance component and owner stacks when aborting a prerender', async () => { + // This test is constructing a scenario where a framework might separate + // I/O into different phases, e.g. runtime I/O and dynamic I/O. The + // framework might choose to define an end time for the Flight client, + // indicating that all I/O info (or any debug info for that matter) that + // arrives after that time should be ignored. When rendering in Fizz is + // then aborted, the late-arriving debug info that's used to enhance the + // owner stack only includes I/O info up to that end time. + let resolveRuntimeData; let resolveDynamicData; - async function getCachedData() { - // Cached data resolves in microtasks. - return Promise.resolve('Hi'); + async function getRuntimeData() { + return new Promise(resolve => { + resolveRuntimeData = resolve; + }); } async function getDynamicData() { @@ -1211,12 +1220,12 @@ describe('ReactFlightDOMNode', () => { } async function Dynamic() { - const cachedData = await getCachedData(); + const runtimeData = await getRuntimeData(); const dynamicData = await getDynamicData(); return (

- {cachedData} {dynamicData} + {runtimeData} {dynamicData}

); } @@ -1239,25 +1248,30 @@ describe('ReactFlightDOMNode', () => { {filterStackFrame}, ); - const staticChunks = []; + const initialChunks = []; const dynamicChunks = []; - let isStatic = true; + let isDynamic = false; const passThrough = new Stream.PassThrough(streamOptions); stream.pipe(passThrough); - // Split chunks into static and dynamic chunks. passThrough.on('data', chunk => { - if (isStatic) { - staticChunks.push(chunk); - } else { + if (isDynamic) { dynamicChunks.push(chunk); + } else { + initialChunks.push(chunk); } }); + let endTime; + await new Promise(resolve => { setTimeout(() => { - isStatic = false; + resolveRuntimeData('Hi'); + }); + setTimeout(() => { + isDynamic = true; + endTime = performance.now() + performance.timeOrigin; resolveDynamicData('Josh'); resolve(); }); @@ -1267,15 +1281,16 @@ describe('ReactFlightDOMNode', () => { passThrough.on('end', resolve); }); - // Create a new Readable and push all static chunks immediately. + // Create a new Readable and push all initial chunks immediately. const readable = new Stream.Readable({...streamOptions, read() {}}); - for (let i = 0; i < staticChunks.length; i++) { - readable.push(staticChunks[i]); + for (let i = 0; i < initialChunks.length; i++) { + readable.push(initialChunks[i]); } const abortController = new AbortController(); - // When prerendering is aborted, push all dynamic chunks. + // When prerendering is aborted, push all dynamic chunks. They won't be + // considered for rendering, but they include debug info we want to use. abortController.signal.addEventListener( 'abort', () => { @@ -1286,12 +1301,20 @@ describe('ReactFlightDOMNode', () => { {once: true}, ); - const response = ReactServerDOMClient.createFromNodeStream(readable, { - serverConsumerManifest: { - moduleMap: null, - moduleLoading: null, + const response = ReactServerDOMClient.createFromNodeStream( + readable, + { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, }, - }); + { + // Debug info arriving after this end time will be ignored, e.g. the + // I/O info for the dynamic data. + endTime, + }, + ); function ClientRoot() { return use(response); @@ -1335,12 +1358,12 @@ describe('ReactFlightDOMNode', () => { '\n' + ' in Dynamic' + (gate(flags => flags.enableAsyncDebugInfo) - ? ' (file://ReactFlightDOMNode-test.js:1215:33)\n' + ? ' (file://ReactFlightDOMNode-test.js:1223:33)\n' : '\n') + ' in body\n' + ' in html\n' + - ' in App (file://ReactFlightDOMNode-test.js:1231:25)\n' + - ' in ClientRoot (ReactFlightDOMNode-test.js:1297:16)', + ' in App (file://ReactFlightDOMNode-test.js:1240:25)\n' + + ' in ClientRoot (ReactFlightDOMNode-test.js:1320:16)', ); } else { expect( @@ -1349,7 +1372,7 @@ describe('ReactFlightDOMNode', () => { '\n' + ' in body\n' + ' in html\n' + - ' in ClientRoot (ReactFlightDOMNode-test.js:1297:16)', + ' in ClientRoot (ReactFlightDOMNode-test.js:1320:16)', ); } @@ -1359,8 +1382,8 @@ describe('ReactFlightDOMNode', () => { normalizeCodeLocInfo(ownerStack, {preserveLocation: true}), ).toBe( '\n' + - ' in Dynamic (file://ReactFlightDOMNode-test.js:1215:33)\n' + - ' in App (file://ReactFlightDOMNode-test.js:1231:25)', + ' in Dynamic (file://ReactFlightDOMNode-test.js:1223:33)\n' + + ' in App (file://ReactFlightDOMNode-test.js:1240:25)', ); } else { expect( @@ -1368,7 +1391,7 @@ describe('ReactFlightDOMNode', () => { ).toBe( '' + '\n' + - ' in App (file://ReactFlightDOMNode-test.js:1231:25)', + ' in App (file://ReactFlightDOMNode-test.js:1240:25)', ); } } else { From 29093b50e9ac69d97c95dfa7ca1a8b7a49515566 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sat, 1 Nov 2025 09:56:12 +0100 Subject: [PATCH 8/9] Move debug info filtering to correct location --- .../react-client/src/ReactFlightClient.js | 69 +++++++++++++++---- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 299fb67e42650..cf9f6be01c685 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -501,6 +501,34 @@ function createErrorChunk( return new ReactPromise(ERRORED, null, error); } +function filterDebugInfo( + response: Response, + value: {_debugInfo: ReactDebugInfo, ...}, +) { + if (response._debugEndTime === null) { + // No end time was defined, so we keep all debug info entries. + return; + } + + // Remove any debug info entries that arrived after the defined end time. + const relativeEndTime = + response._debugEndTime - + // $FlowFixMe[prop-missing] + performance.timeOrigin; + const debugInfo = []; + for (let i = 0; i < value._debugInfo.length; i++) { + const info = value._debugInfo[i]; + if (typeof info.time === 'number' && info.time > relativeEndTime) { + break; + } + if (info.awaited != null && info.awaited.end > relativeEndTime) { + break; + } + debugInfo.push(info); + } + value._debugInfo = debugInfo; +} + function moveDebugInfoFromChunkToInnerValue( chunk: InitializedChunk | InitializedStreamChunk, value: T, @@ -535,7 +563,17 @@ function moveDebugInfoFromChunkToInnerValue( } } +function processChunkDebugInfo( + response: Response, + chunk: InitializedChunk | InitializedStreamChunk, + value: T, +): void { + filterDebugInfo(response, chunk); + moveDebugInfoFromChunkToInnerValue(chunk, value); +} + function wakeChunk( + response: Response, listeners: Array mixed)>, value: T, chunk: InitializedChunk, @@ -550,7 +588,7 @@ function wakeChunk( } if (__DEV__) { - moveDebugInfoFromChunkToInnerValue(chunk, value); + processChunkDebugInfo(response, chunk, value); } } @@ -596,13 +634,14 @@ function resolveBlockedCycle( } function wakeChunkIfInitialized( + response: Response, chunk: SomeChunk, resolveListeners: Array mixed)>, rejectListeners: null | Array mixed)>, ): void { switch (chunk.status) { case INITIALIZED: - wakeChunk(resolveListeners, chunk.value, chunk); + wakeChunk(response, resolveListeners, chunk.value, chunk); break; case BLOCKED: // It is possible that we're blocked on our own chunk if it's a cycle. @@ -630,6 +669,7 @@ function wakeChunkIfInitialized( case INITIALIZED: const initializedChunk: InitializedChunk = (chunk: any); wakeChunk( + response, resolveListeners, initializedChunk.value, initializedChunk, @@ -833,7 +873,7 @@ function resolveModelChunk( // longer be rendered or might not be the highest pri. initializeModelChunk(resolvedChunk); // The status might have changed after initialization. - wakeChunkIfInitialized(chunk, resolveListeners, rejectListeners); + wakeChunkIfInitialized(response, chunk, resolveListeners, rejectListeners); } } @@ -862,7 +902,7 @@ function resolveModuleChunk( } if (resolveListeners !== null) { initializeModuleChunk(resolvedChunk); - wakeChunkIfInitialized(chunk, resolveListeners, rejectListeners); + wakeChunkIfInitialized(response, chunk, resolveListeners, rejectListeners); } } @@ -1027,7 +1067,7 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { initializedChunk.value = value; if (__DEV__) { - moveDebugInfoFromChunkToInnerValue(initializedChunk, value); + processChunkDebugInfo(response, initializedChunk, value); } } catch (error) { const erroredChunk: ErroredChunk = (chunk: any); @@ -1586,10 +1626,10 @@ function fulfillReference( initializedChunk.value = handler.value; initializedChunk.reason = handler.reason; // Used by streaming chunks if (resolveListeners !== null) { - wakeChunk(resolveListeners, handler.value, initializedChunk); + wakeChunk(response, resolveListeners, handler.value, initializedChunk); } else { if (__DEV__) { - moveDebugInfoFromChunkToInnerValue(initializedChunk, handler.value); + processChunkDebugInfo(response, initializedChunk, handler.value); } } } @@ -1839,10 +1879,10 @@ function loadServerReference, T>( initializedChunk.status = INITIALIZED; initializedChunk.value = handler.value; if (resolveListeners !== null) { - wakeChunk(resolveListeners, handler.value, initializedChunk); + wakeChunk(response, resolveListeners, handler.value, initializedChunk); } else { if (__DEV__) { - moveDebugInfoFromChunkToInnerValue(initializedChunk, handler.value); + processChunkDebugInfo(response, initializedChunk, handler.value); } } } @@ -3080,10 +3120,10 @@ function resolveStream>( resolvedChunk.value = stream; resolvedChunk.reason = controller; if (resolveListeners !== null) { - wakeChunk(resolveListeners, chunk.value, (chunk: any)); + wakeChunk(response, resolveListeners, chunk.value, (chunk: any)); } else { if (__DEV__) { - moveDebugInfoFromChunkToInnerValue(resolvedChunk, stream); + processChunkDebugInfo(response, resolvedChunk, stream); } } } @@ -3223,7 +3263,12 @@ function startAsyncIterable( initializedChunk.status = INITIALIZED; initializedChunk.value = {done: false, value: value}; if (resolveListeners !== null) { - wakeChunkIfInitialized(chunk, resolveListeners, rejectListeners); + wakeChunkIfInitialized( + response, + chunk, + resolveListeners, + rejectListeners, + ); } } nextWriteIndex++; From bd8ae7152ca6ff36b8115c4c03e2c74d192acf68 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sat, 1 Nov 2025 10:00:12 +0100 Subject: [PATCH 9/9] Remove `response` from `InitializationReference` --- .../react-client/src/ReactFlightClient.js | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index cf9f6be01c685..2a5936be0f481 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -583,7 +583,7 @@ function wakeChunk( if (typeof listener === 'function') { listener(value); } else { - fulfillReference(listener, value, chunk); + fulfillReference(response, listener, value, chunk); } } @@ -593,6 +593,7 @@ function wakeChunk( } function rejectChunk( + response: Response, listeners: Array mixed)>, error: mixed, ): void { @@ -601,7 +602,7 @@ function rejectChunk( if (typeof listener === 'function') { listener(error); } else { - rejectReference(listener, error); + rejectReference(response, listener.handler, error); } } } @@ -655,7 +656,7 @@ function wakeChunkIfInitialized( if (cyclicHandler !== null) { // This reference points back to this chunk. We can resolve the cycle by // using the value from that handler. - fulfillReference(reference, cyclicHandler.value, chunk); + fulfillReference(response, reference, cyclicHandler.value, chunk); resolveListeners.splice(i, 1); i--; if (rejectListeners !== null) { @@ -677,7 +678,7 @@ function wakeChunkIfInitialized( return; case ERRORED: if (rejectListeners !== null) { - rejectChunk(rejectListeners, chunk.reason); + rejectChunk(response, rejectListeners, chunk.reason); } return; } @@ -707,7 +708,7 @@ function wakeChunkIfInitialized( break; case ERRORED: if (rejectListeners) { - rejectChunk(rejectListeners, chunk.reason); + rejectChunk(response, rejectListeners, chunk.reason); } break; } @@ -765,7 +766,7 @@ function triggerErrorOnChunk( erroredChunk.status = ERRORED; erroredChunk.reason = error; if (listeners !== null) { - rejectChunk(listeners, error); + rejectChunk(response, listeners, error); } } @@ -907,7 +908,6 @@ function resolveModuleChunk( } type InitializationReference = { - response: Response, // TODO: Remove Response from here and pass it through instead. handler: InitializationHandler, parentObject: Object, key: string, @@ -1046,7 +1046,7 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { if (typeof listener === 'function') { listener(value); } else { - fulfillReference(listener, value, cyclicChunk); + fulfillReference(response, listener, value, cyclicChunk); } } } @@ -1454,11 +1454,12 @@ function getChunk(response: Response, id: number): SomeChunk { } function fulfillReference( + response: Response, reference: InitializationReference, value: any, fulfilledChunk: SomeChunk, ): void { - const {response, handler, parentObject, key, map, path} = reference; + const {handler, parentObject, key, map, path} = reference; for (let i = 1; i < path.length; i++) { while ( @@ -1528,7 +1529,11 @@ function fulfillReference( return; } default: { - rejectReference(reference, referencedChunk.reason); + rejectReference( + response, + reference.handler, + referencedChunk.reason, + ); return; } } @@ -1636,11 +1641,10 @@ function fulfillReference( } function rejectReference( - reference: InitializationReference, + response: Response, + handler: InitializationHandler, error: mixed, ): void { - const {handler, response} = reference; - if (handler.errored) { // We've already errored. We could instead build up an AggregateError // but if there are multiple errors we just take the first one like @@ -1731,7 +1735,6 @@ function waitForReference( } const reference: InitializationReference = { - response, handler, parentObject, key,