From 1d7eaf4654e00986da348f3e77876ece3494e7b6 Mon Sep 17 00:00:00 2001 From: Janka Uryga Date: Sat, 10 Jan 2026 00:09:14 +0100 Subject: [PATCH 1/4] fix and tests --- .../src/__tests__/ReactFlight-test.js | 18 +- .../src/__tests__/ReactFlightDOMNode-test.js | 757 +++++++++++++++++- .../react-server/src/ReactFlightServer.js | 66 +- 3 files changed, 817 insertions(+), 24 deletions(-) diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index f62ec321501..5add7be35c4 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -26,7 +26,7 @@ function normalizeCodeLocInfo(str) { if (dot !== -1) { name = name.slice(dot + 1); } - return ' in ' + name + (/\d/.test(m) ? ' (at **)' : ''); + return ' in ' + name + (/:\d+:\d+/.test(m) ? ' (at **)' : ''); }) ); } @@ -2782,7 +2782,7 @@ describe('ReactFlight', () => { transport: expect.arrayContaining([]), }, }, - {time: gate(flags => flags.enableAsyncDebugInfo) ? 53 : 21}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 46 : 21}, ] : undefined, ); @@ -2792,7 +2792,7 @@ describe('ReactFlight', () => { expect(getDebugInfo(await thirdPartyChildren[0])).toEqual( __DEV__ ? [ - {time: gate(flags => flags.enableAsyncDebugInfo) ? 54 : 22}, // Clamped to the start + {time: gate(flags => flags.enableAsyncDebugInfo) ? 47 : 22}, // Clamped to the start { name: 'ThirdPartyComponent', env: 'third-party', @@ -2800,15 +2800,15 @@ describe('ReactFlight', () => { stack: ' in Object. (at **)', props: {}, }, - {time: gate(flags => flags.enableAsyncDebugInfo) ? 54 : 22}, - {time: gate(flags => flags.enableAsyncDebugInfo) ? 55 : 23}, // This last one is when the promise resolved into the first party. + {time: gate(flags => flags.enableAsyncDebugInfo) ? 47 : 22}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 48 : 23}, // This last one is when the promise resolved into the first party. ] : undefined, ); expect(getDebugInfo(thirdPartyChildren[1])).toEqual( __DEV__ ? [ - {time: gate(flags => flags.enableAsyncDebugInfo) ? 54 : 22}, // Clamped to the start + {time: gate(flags => flags.enableAsyncDebugInfo) ? 47 : 22}, // Clamped to the start { name: 'ThirdPartyLazyComponent', env: 'third-party', @@ -2816,14 +2816,14 @@ describe('ReactFlight', () => { stack: ' in myLazy (at **)\n in lazyInitializer (at **)', props: {}, }, - {time: gate(flags => flags.enableAsyncDebugInfo) ? 54 : 22}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 47 : 22}, ] : undefined, ); expect(getDebugInfo(thirdPartyChildren[2])).toEqual( __DEV__ ? [ - {time: gate(flags => flags.enableAsyncDebugInfo) ? 54 : 22}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 47 : 22}, { name: 'ThirdPartyFragmentComponent', env: 'third-party', @@ -2831,7 +2831,7 @@ describe('ReactFlight', () => { stack: ' in Object. (at **)', props: {}, }, - {time: gate(flags => flags.enableAsyncDebugInfo) ? 54 : 22}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 47 : 22}, ] : undefined, ); 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 3fb34e0ba41..e725978b0ea 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -97,7 +97,7 @@ describe('ReactFlightDOMNode', () => { return ( ' in ' + name + - (/\d/.test(m) + (/:\d+:\d+/.test(m) ? preserveLocation ? ' ' + location.replace(__filename, relativeFilename) : ' (at **)' @@ -108,6 +108,27 @@ describe('ReactFlightDOMNode', () => { ); } + /** Apply `filterStackFrame` to a parent or owner stack string. */ + function filterCodeLocInfo(str: string) { + const result = []; + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const line of str.split('\n')) { + if (line) { + const match = + line.match(/^[ ]+at (.*?) \((.*?)\)$/) ?? + line.match(/^[ ]+in (.*?) \(at (.*?)\)$/); + if (match) { + const [, functionName, fileName] = match; + if (!filterStackFrame(fileName, functionName)) { + continue; + } + } + } + result.push(line); + } + return result.join('\n'); + } + /** * Removes all stackframes not pointing into this file */ @@ -955,10 +976,10 @@ describe('ReactFlightDOMNode', () => { // The concrete location may change as this test is updated. // Just make sure they still point at React.use(p2) (gate(flags => flags.enableAsyncDebugInfo) - ? '\n at SharedComponent (./ReactFlightDOMNode-test.js:813:7)' + ? '\n at SharedComponent (./ReactFlightDOMNode-test.js:834:7)' : '') + - '\n at ServerComponent (file://./ReactFlightDOMNode-test.js:835:26)' + - '\n at App (file://./ReactFlightDOMNode-test.js:852:25)', + '\n at ServerComponent (file://./ReactFlightDOMNode-test.js:856:26)' + + '\n at App (file://./ReactFlightDOMNode-test.js:873:25)', ); } else { expect(ownerStack).toBeNull(); @@ -1545,12 +1566,12 @@ describe('ReactFlightDOMNode', () => { '\n' + ' in Dynamic' + (gate(flags => flags.enableAsyncDebugInfo) - ? ' (file://ReactFlightDOMNode-test.js:1419:27)\n' + ? ' (file://ReactFlightDOMNode-test.js:1440:27)\n' : '\n') + ' in body\n' + ' in html\n' + - ' in App (file://ReactFlightDOMNode-test.js:1432:25)\n' + - ' in ClientRoot (ReactFlightDOMNode-test.js:1507:16)', + ' in App (file://ReactFlightDOMNode-test.js:1453:25)\n' + + ' in ClientRoot (ReactFlightDOMNode-test.js:1528:16)', ); } else { expect( @@ -1559,7 +1580,7 @@ describe('ReactFlightDOMNode', () => { '\n' + ' in body\n' + ' in html\n' + - ' in ClientRoot (ReactFlightDOMNode-test.js:1507:16)', + ' in ClientRoot (ReactFlightDOMNode-test.js:1528:16)', ); } @@ -1569,8 +1590,8 @@ describe('ReactFlightDOMNode', () => { normalizeCodeLocInfo(ownerStack, {preserveLocation: true}), ).toBe( '\n' + - ' in Dynamic (file://ReactFlightDOMNode-test.js:1419:27)\n' + - ' in App (file://ReactFlightDOMNode-test.js:1432:25)', + ' in Dynamic (file://ReactFlightDOMNode-test.js:1440:27)\n' + + ' in App (file://ReactFlightDOMNode-test.js:1453:25)', ); } else { expect( @@ -1578,12 +1599,726 @@ describe('ReactFlightDOMNode', () => { ).toBe( '' + '\n' + - ' in App (file://ReactFlightDOMNode-test.js:1432:25)', + ' in App (file://ReactFlightDOMNode-test.js:1453:25)', ); } } else { expect(ownerStack).toBeNull(); } }); + + function createReadableWithLateRelease(initialChunks, lateChunks, signal) { + // Create a new Readable and push all initial chunks immediately. + const readable = new Stream.Readable({...streamOptions, read() {}}); + for (let i = 0; i < initialChunks.length; i++) { + readable.push(initialChunks[i]); + } + + // When prerendering is aborted, push all dynamic chunks. They won't be + // considered for rendering, but they include debug info we want to use. + signal.addEventListener( + 'abort', + () => { + for (let i = 0; i < lateChunks.length; i++) { + readable.push(lateChunks[i]); + } + setImmediate(() => { + readable.push(null); + }); + }, + {once: true}, + ); + + return readable; + } + + async function reencodeFlightStream( + staticChunks, + dynamicChunks, + serverConsumerManifest, + ) { + let staticEndTime = -1; + const chunks = { + static: [], + dynamic: [], + }; + await new Promise(async resolve => { + const renderStageController = new AbortController(); + + const serverStream = createReadableWithLateRelease( + staticChunks, + dynamicChunks, + renderStageController.signal, + ); + const decoded = await ReactServerDOMClient.createFromNodeStream( + serverStream, + serverConsumerManifest, + { + // We're re-encoding the whole stream, so we don't want to filter out any debug info. + endTime: undefined, + }, + ); + + setTimeout(async () => { + const stream = ReactServerDOMServer.renderToPipeableStream( + decoded, + webpackMap, + {filterStackFrame}, + ); + + const passThrough = new Stream.PassThrough(streamOptions); + + passThrough.on('data', chunk => { + if (!renderStageController.signal.aborted) { + chunks.static.push(chunk); + } else { + chunks.dynamic.push(chunk); + } + }); + passThrough.on('end', resolve); + + stream.pipe(passThrough); + }); + + setTimeout(() => { + staticEndTime = performance.now() + performance.timeOrigin; + renderStageController.abort(); + }); + }); + + return {chunks, staticEndTime}; + } + + // @gate __DEV__ + it('can preserve debug info when decoding and re-encoding a stream', async () => { + let resolveDynamicData; + + function getDynamicData() { + return new Promise(resolve => { + resolveDynamicData = resolve; + }); + } + + async function Dynamic() { + const data = await getDynamicData(); + return ReactServer.createElement('p', null, data); + } + + function App() { + return ReactServer.createElement( + 'html', + null, + ReactServer.createElement( + 'body', + null, + ReactServer.createElement( + ReactServer.Suspense, + {fallback: 'Loading...'}, + // TODO: having a wrapper
here seems load-bearing. + // ReactServer.createElement(ReactServer.createElement(Dynamic)), + ReactServer.createElement( + 'section', + null, + ReactServer.createElement(Dynamic), + ), + ), + ), + ); + } + + const resolveDynamic = () => { + resolveDynamicData('Hi Janka'); + }; + + // 1. Render , dividing the output into static and dynamic content. + + let isStatic = true; + const chunks1 = { + static: [], + dynamic: [], + }; + + await new Promise(resolve => { + setTimeout(async () => { + const stream = ReactServerDOMServer.renderToPipeableStream( + ReactServer.createElement(App), + webpackMap, + { + filterStackFrame, + environmentName() { + return isStatic ? 'Prerender' : 'Server'; + }, + }, + ); + + const passThrough = new Stream.PassThrough(streamOptions); + + passThrough.on('data', chunk => { + if (isStatic) { + chunks1.static.push(chunk); + } else { + chunks1.dynamic.push(chunk); + } + }); + passThrough.on('end', resolve); + + stream.pipe(passThrough); + }); + setTimeout(() => { + isStatic = false; + resolveDynamic(); + }); + }); + + //=============================================== + // 2. Decode the stream from the previous step and render it again. + // This should preserve existing debug info. + + const serverConsumerManifest = { + moduleMap: null, + moduleLoading: null, + }; + + const {chunks: chunks2, staticEndTime: reencodeStaticEndTime} = + await reencodeFlightStream( + chunks1.static, + chunks1.dynamic, + serverConsumerManifest, + ); + + //=============================================== + // 3. SSR the stream from the previous step and abort it after the static stage + // (which should trigger `onError` for each "hole" that hasn't resolved yet) + + function ClientRoot({response}) { + return use(response); + } + + let ssrStream; + let ownerStack; + let componentStack; + + await new Promise(async (resolve, reject) => { + const renderController = new AbortController(); + + const serverStream = createReadableWithLateRelease( + chunks2.static, + chunks2.dynamic, + renderController.signal, + ); + + const decodedPromise = ReactServerDOMClient.createFromNodeStream( + serverStream, + serverConsumerManifest, + { + endTime: reencodeStaticEndTime, + }, + ); + + setTimeout(() => { + ssrStream = ReactDOMServer.renderToPipeableStream( + React.createElement(ClientRoot, {response: decodedPromise}), + { + onError(err, errorInfo) { + componentStack = errorInfo.componentStack; + ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + return null; + }, + }, + ); + + renderController.signal.addEventListener( + 'abort', + () => { + const {reason} = renderController.signal; + ssrStream.abort(reason); + }, + { + once: true, + }, + ); + }); + + setTimeout(() => { + renderController.abort(new Error('ssr-abort')); + resolve(); + }); + }); + + const result = await readResult(ssrStream); + + expect(normalizeCodeLocInfo(componentStack)).toBe( + '\n' + + // TODO: IO info is getting omitted when reencoding + // ' in Dynamic (at **)\n' + + ' in Dynamic\n' + + ' in section\n' + + ' in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in App (at **)\n' + + ' in ClientRoot (at **)', + ); + expect(normalizeCodeLocInfo(ownerStack)).toBe( + '\n' + + // TODO: IO info is getting omitted when reencoding + // ' in Dynamic (at **)\n' + + ' in App (at **)', + ); + + expect(result).toContain( + 'Switched to client rendering because the server rendering aborted due to:\n\n' + + 'ssr-abort', + ); + }); + + // @gate __DEV__ + it('can preserve debug info when decoding and re-encoding a stream with two components blocked on the same IO', async () => { + let resolveDynamicData; + + // Used in two components, so dedupe with React.cache so we resolve them both + const getDynamicData = ReactServer.cache(function getDynamicData() { + return new Promise(resolve => { + resolveDynamicData = resolve; + }); + }); + + async function Dynamic1() { + const data = await getDynamicData(); + return ReactServer.createElement('p', null, data); + } + async function Dynamic2() { + const data = await getDynamicData(); + return ReactServer.createElement('p', null, data); + } + + function App() { + return ReactServer.createElement( + 'html', + null, + ReactServer.createElement( + 'body', + null, + ReactServer.createElement( + ReactServer.Suspense, + {fallback: 'Loading...'}, + // TODO: having a wrapper
here seems load-bearing. + // ReactServer.createElement(ReactServer.createElement(Dynamic)), + ReactServer.createElement( + 'section', + null, + ReactServer.createElement(Dynamic1), + ), + ), + ReactServer.createElement( + ReactServer.Suspense, + {fallback: 'Loading...'}, + // TODO: having a wrapper
here seems load-bearing. + // ReactServer.createElement(ReactServer.createElement(Dynamic)), + ReactServer.createElement( + 'section', + null, + ReactServer.createElement(Dynamic2), + ), + ), + ), + ); + } + + const resolveDynamic = () => { + resolveDynamicData('Hi Janka'); + }; + + // 1. Render , dividing the output into static and dynamic content. + + let startTime = -1; + let isStatic = true; + const chunks1 = { + static: [], + dynamic: [], + }; + + await new Promise(resolve => { + setTimeout(async () => { + startTime = performance.now() + performance.timeOrigin; + const stream = ReactServerDOMServer.renderToPipeableStream( + ReactServer.createElement(App), + webpackMap, + { + filterStackFrame, + startTime, + environmentName() { + return isStatic ? 'Prerender' : 'Server'; + }, + }, + ); + + const passThrough = new Stream.PassThrough(streamOptions); + + passThrough.on('data', chunk => { + if (isStatic) { + chunks1.static.push(chunk); + } else { + chunks1.dynamic.push(chunk); + } + }); + passThrough.on('end', resolve); + + stream.pipe(passThrough); + }); + setTimeout(() => { + isStatic = false; + resolveDynamic(); + }); + }); + + //=============================================== + // 2. Decode the stream from the previous step and render it again. + // This should preserve existing debug info. + + const serverConsumerManifest = { + moduleMap: null, + moduleLoading: null, + }; + + const {chunks: chunks2, staticEndTime: reencodeStaticEndTime} = + await reencodeFlightStream( + chunks1.static, + chunks1.dynamic, + startTime, + serverConsumerManifest, + ); + + //=============================================== + // 3. SSR the stream from the previous step and abort it after the static stage + // (which should trigger `onError` for each "hole" that hasn't resolved yet) + + function ClientRoot({response}) { + return use(response); + } + + let ssrStream; + const errorLocations: Array<{ + componentStack: string, + ownerStack: string | null, + }> = []; + + await new Promise(async (resolve, reject) => { + const renderController = new AbortController(); + + const serverStream = createReadableWithLateRelease( + chunks2.static, + chunks2.dynamic, + renderController.signal, + ); + + const decodedPromise = ReactServerDOMClient.createFromNodeStream( + serverStream, + serverConsumerManifest, + { + endTime: reencodeStaticEndTime, + }, + ); + + setTimeout(() => { + ssrStream = ReactDOMServer.renderToPipeableStream( + React.createElement(ClientRoot, {response: decodedPromise}), + { + onError(err, errorInfo) { + const componentStack = errorInfo.componentStack; + const ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + errorLocations.push({componentStack, ownerStack}); + return null; + }, + }, + ); + + renderController.signal.addEventListener( + 'abort', + () => { + const {reason} = renderController.signal; + ssrStream.abort(reason); + }, + { + once: true, + }, + ); + }); + + setTimeout(() => { + renderController.abort(new Error('ssr-abort')); + resolve(); + }); + }); + + const result = await readResult(ssrStream); + + expect(errorLocations).toHaveLength(2); + expect(normalizeCodeLocInfo(errorLocations[0].componentStack)).toBe( + '\n' + + // TODO: IO info is getting omitted when reencoding + // ' in Dynamic1 (at **)\n' + + ' in Dynamic1\n' + + ' in section\n' + + ' in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in App (at **)\n' + + ' in ClientRoot (at **)', + ); + expect(normalizeCodeLocInfo(errorLocations[0].ownerStack)).toBe( + '\n' + + // TODO: IO info is getting omitted when reencoding + // ' in Dynamic1 (at **)\n' + + ' in App (at **)', + ); + + expect(normalizeCodeLocInfo(errorLocations[1].componentStack)).toBe( + '\n' + + // TODO: IO info is getting omitted when reencoding + // ' in Dynamic2 (at **)\n' + + ' in Dynamic2\n' + + ' in section\n' + + ' in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in App (at **)\n' + + ' in ClientRoot (at **)', + ); + expect(normalizeCodeLocInfo(errorLocations[1].ownerStack)).toBe( + '\n' + + // TODO: IO info is getting omitted when reencoding + // ' in Dynamic2 (at **)\n' + + ' in App (at **)', + ); + + expect(result).toContain( + 'Switched to client rendering because the server rendering aborted due to:\n\n' + + 'ssr-abort', + ); + }); + + // @gate __DEV__ + it('can preserve debug info for promises when decoding and re-encoding a stream', async () => { + function ClientComponent({promise}) { + return use(promise); + } + + const ClientComponentInBrowser = clientExports( + ClientComponent, + 123, + 'path/to/chunk.js', + ); + + // When de-coding a flight stream before re-encoding it, + // we need client references to remain client references, + // so we map browser chunks back to the client reference proxies they came from. + const ClientComponentInServer = clientExports(ClientComponentInBrowser); + const serverConsumerManifestForFlight = { + moduleMap: { + [webpackMap[ClientComponentInBrowser.$$id].id]: { + '*': webpackMap[ClientComponentInServer.$$id], + }, + }, + moduleLoading: webpackModuleLoading, + }; + + const ClientComponentInSSR = clientExports(ClientComponent); + const serverConsumerManifestForSSR = { + moduleMap: { + [webpackMap[ClientComponentInBrowser.$$id].id]: { + '*': webpackMap[ClientComponentInSSR.$$id], + }, + }, + moduleLoading: webpackModuleLoading, + }; + + let resolveDynamicData; + + function getDynamicData() { + return new Promise(resolve => { + resolveDynamicData = resolve; + }); + } + + function App() { + const promise = getDynamicData(); + return ReactServer.createElement( + 'html', + null, + ReactServer.createElement( + 'body', + null, + ReactServer.createElement( + ReactServer.Suspense, + {fallback: 'Loading...'}, + ReactServer.createElement( + ServerParent, + null, + ReactServer.createElement(ClientComponentInBrowser, {promise}), + ), + ), + ), + ); + } + + function ServerParent({children}) { + return ReactServer.createElement('p', null, children); + } + + const resolveDynamic = () => { + resolveDynamicData('Hi Janka'); + }; + + // 1. Render , dividing the output into static and dynamic content. + + let isStatic = true; + const chunks1 = { + static: [], + dynamic: [], + }; + + await new Promise(resolve => { + setTimeout(async () => { + const stream = ReactServerDOMServer.renderToPipeableStream( + ReactServer.createElement(App), + webpackMap, + { + filterStackFrame, + environmentName() { + return isStatic ? 'Prerender' : 'Server'; + }, + }, + ); + + const passThrough = new Stream.PassThrough(streamOptions); + + passThrough.on('data', chunk => { + if (isStatic) { + chunks1.static.push(chunk); + } else { + chunks1.dynamic.push(chunk); + } + }); + passThrough.on('end', resolve); + + stream.pipe(passThrough); + }); + setTimeout(() => { + isStatic = false; + resolveDynamic(); + }); + }); + + //=============================================== + // 2. Decode the stream from the previous step and render it again. + // This should preserve existing debug info. + + const {chunks: chunks2, staticEndTime: reencodeStaticEndTime} = + await reencodeFlightStream( + chunks1.static, + chunks1.dynamic, + serverConsumerManifestForFlight, + ); + + //=============================================== + // 3. SSR the stream from the previous step and abort it after the static stage + // (which should trigger `onError` for each "hole" that hasn't resolved yet) + + function ClientRoot({response}) { + return use(response); + } + + let ssrStream; + let ownerStack; + let componentStack; + + await new Promise(async (resolve, reject) => { + const renderController = new AbortController(); + + const serverStream = createReadableWithLateRelease( + chunks2.static, + chunks2.dynamic, + renderController.signal, + ); + + const decodedPromise = ReactServerDOMClient.createFromNodeStream( + serverStream, + serverConsumerManifestForSSR, + { + endTime: reencodeStaticEndTime, + }, + ); + + setTimeout(() => { + ssrStream = ReactDOMServer.renderToPipeableStream( + React.createElement(ClientRoot, {response: decodedPromise}), + { + onError(err, errorInfo) { + componentStack = errorInfo.componentStack; + ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + return null; + }, + }, + ); + + renderController.signal.addEventListener( + 'abort', + () => { + const {reason} = renderController.signal; + ssrStream.abort(reason); + }, + { + once: true, + }, + ); + }); + + setTimeout(() => { + renderController.abort(new Error('ssr-abort')); + resolve(); + }); + }); + + const result = await readResult(ssrStream); + + expect(normalizeCodeLocInfo(componentStack)).toBe( + '\n' + + ' in ClientComponent (at **)\n' + + ' in p\n' + + // TODO: why no precise location? + // ' in ServerParent (at **)\n' + + ' in ServerParent\n' + + ' in Suspense\n' + + ' in body\n' + + ' in html\n' + + ' in App (at **)\n' + + ' in ClientRoot (at **)', + ); + expect( + normalizeCodeLocInfo( + // NOTE: the owner stack has react internals like `trackUsedThenable` in it. + // we filter those out using `filterStackFrame`. + filterCodeLocInfo(ownerStack), + ), + ).toBe( + '\n' + + (gate(flags => flags.enableAsyncDebugInfo) + ? ' in ClientComponent (at **)\n' + : '') + + ' in App (at **)', + ); + + expect(result).toContain( + 'Switched to client rendering because the server rendering aborted due to:\n\n' + + 'ssr-abort', + ); + }); }); }); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index f31fa45f7a7..c6a0b0d87e8 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -614,6 +614,7 @@ export type Request = { didWarnForKey: null | WeakSet, writtenDebugObjects: WeakMap, deferredDebugObjects: null | DeferredDebugStore, + earlyDebugInfoEntries: null | WeakSet, }; const { @@ -738,6 +739,7 @@ function RequestInstance( existing: new Map(), } : null; + this.earlyDebugInfoEntries = null; } let timeOrigin: number; @@ -2357,6 +2359,7 @@ function visitAsyncNodeImpl( >, cutOff: number, ): void | null | PromiseNode | IONode { + // TODO: add a way to preserve IO nodes from before the request started if (node.end >= 0 && node.end <= request.timeOrigin) { // This was already resolved when we started this render. It must have been either something // that's part of a start up sequence or externally cached data. We exclude that information. @@ -3523,7 +3526,13 @@ function renderModelDestructive( return outlineTask(request, task); } else { // Forward any debug info we have the first time we see it. - forwardDebugInfo(request, task, debugInfo); + // If this element came from a lazy chunk, then the Flight Client transferred + // the lazy chunk's debug info onto the inner element in `initializeElement`. + // We might have already written some of that debug info out into the stream + // (before the lazy resolved), so we shouldn't do it again. + // The consumer of this stream will once again transfer the debug info from + // the lazy chunk onto the element itself, thus recombining them into one array. + forwardDebugInfoOnce(request, task, debugInfo); } } } @@ -3602,7 +3611,23 @@ function renderModelDestructive( const lazy: LazyComponent = (value: any); let resolvedModel; + if (__DEV__) { + // Check if we already have some debug info before initializing. + // If we do, we want to emit it as soon as possible, without waiting for initialization. + const debugInfo: ?ReactDebugInfo = lazy._debugInfo; + if (debugInfo) { + // If this came from Flight, forward any debug info into this new row. + if (!canEmitDebugInfo) { + // We don't have a chunk to assign debug info. We need to outline this + // component to assign it an ID. + return outlineTask(request, task); + } else { + // Forward any debug info we have the first time we see it. + forwardDebugInfoOnce(request, task, debugInfo); + } + } + resolvedModel = callLazyInitInDEV(lazy); } else { const payload = lazy._payload; @@ -3616,6 +3641,8 @@ function renderModelDestructive( // eslint-disable-next-line no-throw-literal throw null; } + + // Check for new debug info that may have arrived after initializing. if (__DEV__) { const debugInfo: ?ReactDebugInfo = lazy._debugInfo; if (debugInfo) { @@ -3626,9 +3653,7 @@ function renderModelDestructive( return outlineTask(request, task); } else { // Forward any debug info we have the first time we see it. - // We do this after init so that we have received all the debug info - // from the server by the time we emit it. - forwardDebugInfo(request, task, debugInfo); + forwardDebugInfoOnce(request, task, debugInfo); } } } @@ -5325,14 +5350,46 @@ function emitTimeOriginChunk(request: Request, timeOrigin: number): void { request.completedDebugChunks.push(processedChunk); } +function forwardDebugInfoOnce( + request: Request, + task: Task, + debugInfo: ReactDebugInfo, +) { + // Track which items from this array have already been forwarded, + // and don't emit them again. Note that elements can sometimes move + // from one array to another, e.g. when a lazy chunk's debug info is + // transferred to the element it resolves to in `initializeElement`. + let earlyDebugInfoEntries = request.earlyDebugInfoEntries; + if (!earlyDebugInfoEntries) { + earlyDebugInfoEntries = request.earlyDebugInfoEntries = new WeakSet(); + } + forwardDebugInfoImpl(request, task, debugInfo, earlyDebugInfoEntries); +} + function forwardDebugInfo( request: Request, task: Task, debugInfo: ReactDebugInfo, +) { + forwardDebugInfoImpl(request, task, debugInfo, null); +} + +function forwardDebugInfoImpl( + request: Request, + task: Task, + debugInfo: ReactDebugInfo, + seenInfos: WeakSet | null, ) { const id = task.id; for (let i = 0; i < debugInfo.length; i++) { const info = debugInfo[i]; + if (seenInfos !== null) { + if (seenInfos.has(info)) { + continue; + } + seenInfos.add(info); + } + if (typeof info.time === 'number') { // When forwarding time we need to ensure to convert it to the time space of the payload. // We clamp the time to the starting render of the current component. It's as if it took @@ -5349,6 +5406,7 @@ function forwardDebugInfo( emitDebugChunk(request, id, info); } else if (info.awaited) { const ioInfo = info.awaited; + // TODO: add a way to preserve IO nodes from before the request started if (ioInfo.end <= request.timeOrigin) { // This was already resolved when we started this render. It must have been some // externally cached data. We exclude that information but we keep components and From 650670cc59eaa54f53795de1a9e6ec4cfb76753b Mon Sep 17 00:00:00 2001 From: Janka Uryga Date: Fri, 30 Jan 2026 00:51:30 +0100 Subject: [PATCH 2/4] track emit progress per debugInfo array --- .../react-client/src/ReactFlightClient.js | 1 + .../react-server/src/ReactFlightServer.js | 100 ++++++++++++------ 2 files changed, 69 insertions(+), 32 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 4dc316de366..612306d824f 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -536,6 +536,7 @@ function moveDebugInfoFromChunkToInnerValue( // value instead. This can be a React element, an array, or an uninitialized // Lazy. const resolvedValue = resolveLazy(value); + // NOTE: Keep this condition in sync with `copyDebugInfoProgressToResolvedValue` from `ReactFlightServer`. if ( typeof resolvedValue === 'object' && resolvedValue !== null && diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index c6a0b0d87e8..0c41893aa1a 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -614,7 +614,7 @@ export type Request = { didWarnForKey: null | WeakSet, writtenDebugObjects: WeakMap, deferredDebugObjects: null | DeferredDebugStore, - earlyDebugInfoEntries: null | WeakSet, + partialDebugInfoProgress: null | WeakMap, }; const { @@ -739,7 +739,7 @@ function RequestInstance( existing: new Map(), } : null; - this.earlyDebugInfoEntries = null; + this.forwardedDebugInfos = null; } let timeOrigin: number; @@ -3532,7 +3532,7 @@ function renderModelDestructive( // (before the lazy resolved), so we shouldn't do it again. // The consumer of this stream will once again transfer the debug info from // the lazy chunk onto the element itself, thus recombining them into one array. - forwardDebugInfoOnce(request, task, debugInfo); + forwardDebugInfoProgressive(request, task, debugInfo); } } } @@ -3624,7 +3624,7 @@ function renderModelDestructive( return outlineTask(request, task); } else { // Forward any debug info we have the first time we see it. - forwardDebugInfoOnce(request, task, debugInfo); + forwardDebugInfoProgressive(request, task, debugInfo); } } @@ -3646,14 +3646,21 @@ function renderModelDestructive( if (__DEV__) { const debugInfo: ?ReactDebugInfo = lazy._debugInfo; if (debugInfo) { - // If this came from Flight, forward any debug info into this new row. - if (!canEmitDebugInfo) { - // We don't have a chunk to assign debug info. We need to outline this - // component to assign it an ID. - return outlineTask(request, task); - } else { - // Forward any debug info we have the first time we see it. - forwardDebugInfoOnce(request, task, debugInfo); + // We don't need to outlineTask -- if we needed outlining, we would've done it above. + const progress = forwardDebugInfoProgressive( + request, + task, + debugInfo, + ); + // The debug info array may have been moved onto the resolved value + // by `moveDebugInfoFromChunkToInnerValue`. + // If it was, we have to make sure we skip the elements we've already emitted. + if (progress > 0 && debugInfo.length === 0) { + copyDebugInfoProgressToResolvedValue( + request, + progress, + resolvedModel, + ); } } } @@ -5350,20 +5357,55 @@ function emitTimeOriginChunk(request: Request, timeOrigin: number): void { request.completedDebugChunks.push(processedChunk); } -function forwardDebugInfoOnce( +function copyDebugInfoProgressToResolvedValue( + request: Request, + index: number, + resolvedValue: ReactClientValue, +) { + const partialDebugInfoProgress = request.partialDebugInfoProgress; + // Defensive check. If we're here, this should be initialized. + if (!partialDebugInfoProgress) return; + + if ( + // NOTE: Keep this condition in sync with `moveDebugInfoFromChunkToInnerValue` from `ReactFlightClient`. + typeof resolvedValue === 'object' && + resolvedValue !== null && + (isArray(resolvedValue) || + typeof (resolvedValue: any)[ASYNC_ITERATOR] === 'function' || + resolvedValue.$$typeof === REACT_ELEMENT_TYPE || + resolvedValue.$$typeof === REACT_LAZY_TYPE) + ) { + const debugInfo = (resolvedValue: any)._debugInfo; + // Defensive check. If the outer lazy had debug info, then the resolved value should have it too. + if (isArray(debugInfo)) { + partialDebugInfoProgress.set(debugInfo, index); + } + } +} + +function forwardDebugInfoProgressive( request: Request, task: Task, debugInfo: ReactDebugInfo, -) { - // Track which items from this array have already been forwarded, - // and don't emit them again. Note that elements can sometimes move - // from one array to another, e.g. when a lazy chunk's debug info is - // transferred to the element it resolves to in `initializeElement`. - let earlyDebugInfoEntries = request.earlyDebugInfoEntries; - if (!earlyDebugInfoEntries) { - earlyDebugInfoEntries = request.earlyDebugInfoEntries = new WeakSet(); +): number { + // Track how many items from this array have already been forwarded. + // If new ones get appended later, we won't emit them again. + let partialDebugInfoProgress = request.partialDebugInfoProgress; + if (!partialDebugInfoProgress) { + partialDebugInfoProgress = request.partialDebugInfoProgress = new WeakMap(); + } + + const startIndex = partialDebugInfoProgress.get(debugInfo) || 0; + if (startIndex >= debugInfo.length) { + // Nothing new to emit. Note that the length might be less than what we have saved + // if `moveDebugInfoFromChunkToInnerValue` emptied the array. + return startIndex; } - forwardDebugInfoImpl(request, task, debugInfo, earlyDebugInfoEntries); + + forwardDebugInfoFromIndex(request, task, debugInfo, startIndex); + const newIndex = debugInfo.length; + partialDebugInfoProgress.set(debugInfo, newIndex); + return newIndex; } function forwardDebugInfo( @@ -5371,24 +5413,18 @@ function forwardDebugInfo( task: Task, debugInfo: ReactDebugInfo, ) { - forwardDebugInfoImpl(request, task, debugInfo, null); + forwardDebugInfoFromIndex(request, task, debugInfo, 0); } -function forwardDebugInfoImpl( +function forwardDebugInfoFromIndex( request: Request, task: Task, debugInfo: ReactDebugInfo, - seenInfos: WeakSet | null, + startIndex: number, ) { const id = task.id; - for (let i = 0; i < debugInfo.length; i++) { + for (let i = startIndex; i < debugInfo.length; i++) { const info = debugInfo[i]; - if (seenInfos !== null) { - if (seenInfos.has(info)) { - continue; - } - seenInfos.add(info); - } if (typeof info.time === 'number') { // When forwarding time we need to ensure to convert it to the time space of the payload. From 45504792ffe78a8ed6cd80235f4e92dd89fbd5ba Mon Sep 17 00:00:00 2001 From: Janka Uryga Date: Fri, 30 Jan 2026 19:28:51 +0100 Subject: [PATCH 3/4] fix outlining --- .../react-server/src/ReactFlightServer.js | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 0c41893aa1a..71a7891c7ca 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -3646,21 +3646,27 @@ function renderModelDestructive( if (__DEV__) { const debugInfo: ?ReactDebugInfo = lazy._debugInfo; if (debugInfo) { - // We don't need to outlineTask -- if we needed outlining, we would've done it above. - const progress = forwardDebugInfoProgressive( - request, - task, - debugInfo, - ); - // The debug info array may have been moved onto the resolved value - // by `moveDebugInfoFromChunkToInnerValue`. - // If it was, we have to make sure we skip the elements we've already emitted. - if (progress > 0 && debugInfo.length === 0) { - copyDebugInfoProgressToResolvedValue( + // If this came from Flight, forward any debug info into this new row. + if (!canEmitDebugInfo) { + // We don't have a chunk to assign debug info. We need to outline this + // component to assign it an ID. + return outlineTask(request, task); + } else { + const progress = forwardDebugInfoProgressive( request, - progress, - resolvedModel, + task, + debugInfo, ); + // The debug info array may have been moved onto the resolved value + // by `moveDebugInfoFromChunkToInnerValue`. + // If it was, we have to make sure we skip the elements we've already emitted. + if (progress > 0 && debugInfo.length === 0) { + copyDebugInfoProgressToResolvedValue( + request, + progress, + resolvedModel, + ); + } } } } From 58ebb49dea82d782cc2bdc4bcdebd60978fcaa8f Mon Sep 17 00:00:00 2001 From: Janka Uryga Date: Thu, 29 Jan 2026 21:19:57 +0100 Subject: [PATCH 4/4] allow overriding request.timeOrigin via options.startTime --- .../react-markup/src/ReactMarkupServer.js | 4 ++ .../src/ReactNoopFlightServer.js | 2 + .../src/server/ReactFlightDOMServerNode.js | 5 ++ .../src/server/ReactFlightDOMServerBrowser.js | 6 ++ .../src/server/ReactFlightDOMServerEdge.js | 5 ++ .../src/server/ReactFlightDOMServerNode.js | 7 +++ .../src/server/ReactFlightDOMServerBrowser.js | 4 ++ .../src/server/ReactFlightDOMServerEdge.js | 4 ++ .../src/server/ReactFlightDOMServerNode.js | 7 +++ .../src/server/ReactFlightDOMServerNode.js | 7 +++ .../src/__tests__/ReactFlightDOMNode-test.js | 58 ++++++++++++++----- .../src/server/ReactFlightDOMServerBrowser.js | 5 ++ .../src/server/ReactFlightDOMServerEdge.js | 5 ++ .../src/server/ReactFlightDOMServerNode.js | 7 +++ .../react-server/src/ReactFlightServer.js | 15 ++++- 15 files changed, 124 insertions(+), 17 deletions(-) diff --git a/packages/react-markup/src/ReactMarkupServer.js b/packages/react-markup/src/ReactMarkupServer.js index 5d22f1a4c94..82510b94ca1 100644 --- a/packages/react-markup/src/ReactMarkupServer.js +++ b/packages/react-markup/src/ReactMarkupServer.js @@ -11,6 +11,8 @@ import type {ReactNodeList} from 'shared/ReactTypes'; import type {LazyComponent} from 'react/src/ReactLazy'; import type {ErrorInfo} from 'react-server/src/ReactFizzServer'; +import {enableProfilerTimer} from 'shared/ReactFeatureFlags'; + import ReactVersion from 'shared/ReactVersion'; import ReactSharedInternalsServer from 'react-server/src/ReactSharedInternalsServer'; @@ -68,6 +70,7 @@ type MarkupOptions = { identifierPrefix?: string, signal?: AbortSignal, onError?: (error: mixed, errorInfo: ErrorInfo) => ?string, + startTime?: number, }; function noServerCallOrFormAction() { @@ -184,6 +187,7 @@ export function experimental_renderToHTML( handleFlightError, options ? options.identifierPrefix : undefined, undefined, + enableProfilerTimer && options ? options.startTime : undefined, 'Markup', undefined, false, diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index 469e5465f30..52c3507644d 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -73,6 +73,7 @@ type Options = { signal?: AbortSignal, debugChannel?: {onMessage?: (message: string) => void}, onError?: (error: mixed) => void, + startTime?: number, }; function render(model: ReactClientValue, options?: Options): Destination { @@ -84,6 +85,7 @@ function render(model: ReactClientValue, options?: Options): Destination { options ? options.onError : undefined, options ? options.identifierPrefix : undefined, undefined, + __PROFILE__ && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, __DEV__ && options && options.debugChannel !== undefined, diff --git a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js index 8e0799fb020..85e0417b510 100644 --- a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js @@ -17,6 +17,7 @@ import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; import type {Busboy} from 'busboy'; import type {Writable} from 'stream'; import type {Thenable} from 'shared/ReactTypes'; +import {enableProfilerTimer} from 'shared/ReactFeatureFlags'; import type {Duplex} from 'stream'; @@ -146,6 +147,7 @@ type Options = { onError?: (error: mixed) => void, identifierPrefix?: string, temporaryReferences?: TemporaryReferenceSet, + startTime?: number, }; type PipeableStream = { @@ -183,6 +185,7 @@ function renderToPipeableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, debugChannel !== undefined, @@ -272,6 +275,7 @@ type PrerenderOptions = { identifierPrefix?: string, temporaryReferences?: TemporaryReferenceSet, signal?: AbortSignal, + startTime?: number, }; type StaticResult = { @@ -303,6 +307,7 @@ function prerenderToNodeStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, false, diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerBrowser.js index bdaadd66684..3a145af2d75 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerBrowser.js @@ -12,6 +12,9 @@ import type { ReactClientValue, } from 'react-server/src/ReactFlightServer'; import type {ReactFormState, Thenable} from 'shared/ReactTypes'; + +import {enableProfilerTimer} from 'shared/ReactFeatureFlags'; + import { preloadModule, requireModule, @@ -67,6 +70,7 @@ type Options = { signal?: AbortSignal, temporaryReferences?: TemporaryReferenceSet, onError?: (error: mixed) => void, + startTime?: number, }; function startReadingFromDebugChannelReadableStream( @@ -128,6 +132,7 @@ export function renderToReadableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, debugChannelReadable !== undefined, @@ -215,6 +220,7 @@ export function prerender( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, false, diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js index 83150996ae6..c7c69d090b4 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js @@ -12,6 +12,8 @@ import type { ReactClientValue, } from 'react-server/src/ReactFlightServer'; import type {ReactFormState, Thenable} from 'shared/ReactTypes'; +import {enableProfilerTimer} from 'shared/ReactFeatureFlags'; + import { preloadModule, requireModule, @@ -72,6 +74,7 @@ type Options = { signal?: AbortSignal, temporaryReferences?: TemporaryReferenceSet, onError?: (error: mixed) => void, + startTime?: number, }; function startReadingFromDebugChannelReadableStream( @@ -133,6 +136,7 @@ export function renderToReadableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, debugChannelReadable !== undefined, @@ -220,6 +224,7 @@ export function prerender( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, false, diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js index c5903c41ed4..9681c4f80bb 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js @@ -15,6 +15,7 @@ import type {Destination} from 'react-server/src/ReactServerStreamConfigNode'; import type {Busboy} from 'busboy'; import type {Writable} from 'stream'; import type {ReactFormState, Thenable} from 'shared/ReactTypes'; +import {enableProfilerTimer} from 'shared/ReactFeatureFlags'; import type { ServerManifest, ServerReferenceId, @@ -159,6 +160,7 @@ type Options = { onError?: (error: mixed) => void, identifierPrefix?: string, temporaryReferences?: TemporaryReferenceSet, + startTime?: number, }; type PipeableStream = { @@ -195,6 +197,7 @@ export function renderToPipeableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, debugChannel !== undefined, @@ -352,6 +355,7 @@ export function renderToReadableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, debugChannelReadable !== undefined, @@ -434,6 +438,7 @@ type PrerenderOptions = { identifierPrefix?: string, temporaryReferences?: TemporaryReferenceSet, signal?: AbortSignal, + startTime?: number, }; type StaticResult = { @@ -464,6 +469,7 @@ export function prerenderToNodeStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, false, @@ -526,6 +532,7 @@ export function prerender( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, false, diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js index 9388e5790f1..c6e19991899 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js @@ -12,6 +12,7 @@ import type { ReactClientValue, } from 'react-server/src/ReactFlightServer'; import type {Thenable} from 'shared/ReactTypes'; +import {enableProfilerTimer} from 'shared/ReactFeatureFlags'; import type {ClientManifest} from './ReactFlightServerConfigTurbopackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; @@ -64,6 +65,7 @@ type Options = { signal?: AbortSignal, temporaryReferences?: TemporaryReferenceSet, onError?: (error: mixed) => void, + startTime?: number, }; function startReadingFromDebugChannelReadableStream( @@ -126,6 +128,7 @@ function renderToReadableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, debugChannelReadable !== undefined, @@ -214,6 +217,7 @@ function prerender( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, false, diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js index f6a8fcc9abc..039809b2da5 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js @@ -12,6 +12,7 @@ import type { ReactClientValue, } from 'react-server/src/ReactFlightServer'; import type {Thenable} from 'shared/ReactTypes'; +import {enableProfilerTimer} from 'shared/ReactFeatureFlags'; import type {ClientManifest} from './ReactFlightServerConfigTurbopackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; @@ -69,6 +70,7 @@ type Options = { signal?: AbortSignal, temporaryReferences?: TemporaryReferenceSet, onError?: (error: mixed) => void, + startTime?: number, }; function startReadingFromDebugChannelReadableStream( @@ -131,6 +133,7 @@ function renderToReadableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, debugChannelReadable !== undefined, @@ -219,6 +222,7 @@ function prerender( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, false, diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index 74d379f53a0..e489e48a90a 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -17,6 +17,7 @@ import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; import type {Busboy} from 'busboy'; import type {Writable} from 'stream'; import type {Thenable} from 'shared/ReactTypes'; +import {enableProfilerTimer} from 'shared/ReactFeatureFlags'; import type {Duplex} from 'stream'; @@ -152,6 +153,7 @@ type Options = { onError?: (error: mixed) => void, identifierPrefix?: string, temporaryReferences?: TemporaryReferenceSet, + startTime?: number, }; type PipeableStream = { @@ -189,6 +191,7 @@ function renderToPipeableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, debugChannel !== undefined, @@ -347,6 +350,7 @@ function renderToReadableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, debugChannelReadable !== undefined, @@ -429,6 +433,7 @@ type PrerenderOptions = { identifierPrefix?: string, temporaryReferences?: TemporaryReferenceSet, signal?: AbortSignal, + startTime?: number, }; type StaticResult = { @@ -460,6 +465,7 @@ function prerenderToNodeStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, false, @@ -523,6 +529,7 @@ function prerender( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, false, diff --git a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js index 9a75c20395b..7b32cefd0a4 100644 --- a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js @@ -17,6 +17,7 @@ import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; import type {Busboy} from 'busboy'; import type {Writable} from 'stream'; import type {Thenable} from 'shared/ReactTypes'; +import {enableProfilerTimer} from 'shared/ReactFeatureFlags'; import type {Duplex} from 'stream'; @@ -152,6 +153,7 @@ type Options = { onError?: (error: mixed) => void, identifierPrefix?: string, temporaryReferences?: TemporaryReferenceSet, + startTime?: number, }; type PipeableStream = { @@ -189,6 +191,7 @@ function renderToPipeableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, debugChannelReadable !== undefined, @@ -347,6 +350,7 @@ function renderToReadableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, debugChannelReadable !== undefined, @@ -429,6 +433,7 @@ type PrerenderOptions = { identifierPrefix?: string, temporaryReferences?: TemporaryReferenceSet, signal?: AbortSignal, + startTime?: number, }; type StaticResult = { @@ -460,6 +465,7 @@ function prerenderToNodeStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, false, @@ -523,6 +529,7 @@ function prerender( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, false, 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 e725978b0ea..0f9ff680cef 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -1635,6 +1635,7 @@ describe('ReactFlightDOMNode', () => { async function reencodeFlightStream( staticChunks, dynamicChunks, + startTime, serverConsumerManifest, ) { let staticEndTime = -1; @@ -1663,7 +1664,11 @@ describe('ReactFlightDOMNode', () => { const stream = ReactServerDOMServer.renderToPipeableStream( decoded, webpackMap, - {filterStackFrame}, + { + filterStackFrame, + // Pass in the original render's startTime to avoid omitting its IO info. + startTime, + }, ); const passThrough = new Stream.PassThrough(streamOptions); @@ -1732,6 +1737,8 @@ describe('ReactFlightDOMNode', () => { // 1. Render , dividing the output into static and dynamic content. + let startTime = -1; + let isStatic = true; const chunks1 = { static: [], @@ -1740,11 +1747,14 @@ describe('ReactFlightDOMNode', () => { await new Promise(resolve => { setTimeout(async () => { + startTime = performance.now() + performance.timeOrigin; + const stream = ReactServerDOMServer.renderToPipeableStream( ReactServer.createElement(App), webpackMap, { filterStackFrame, + startTime, environmentName() { return isStatic ? 'Prerender' : 'Server'; }, @@ -1783,6 +1793,7 @@ describe('ReactFlightDOMNode', () => { await reencodeFlightStream( chunks1.static, chunks1.dynamic, + startTime, serverConsumerManifest, ); @@ -1851,9 +1862,11 @@ describe('ReactFlightDOMNode', () => { expect(normalizeCodeLocInfo(componentStack)).toBe( '\n' + - // TODO: IO info is getting omitted when reencoding - // ' in Dynamic (at **)\n' + - ' in Dynamic\n' + + gate(flags => + flags.enableAsyncDebugInfo + ? ' in Dynamic (at **)\n' + : ' in Dynamic\n', + ) + ' in section\n' + ' in Suspense\n' + ' in body\n' + @@ -1863,8 +1876,9 @@ describe('ReactFlightDOMNode', () => { ); expect(normalizeCodeLocInfo(ownerStack)).toBe( '\n' + - // TODO: IO info is getting omitted when reencoding - // ' in Dynamic (at **)\n' + + gate(flags => + flags.enableAsyncDebugInfo ? ' in Dynamic (at **)\n' : '', + ) + ' in App (at **)', ); @@ -2060,9 +2074,11 @@ describe('ReactFlightDOMNode', () => { expect(errorLocations).toHaveLength(2); expect(normalizeCodeLocInfo(errorLocations[0].componentStack)).toBe( '\n' + - // TODO: IO info is getting omitted when reencoding - // ' in Dynamic1 (at **)\n' + - ' in Dynamic1\n' + + gate(flags => + flags.enableAsyncDebugInfo + ? ' in Dynamic1 (at **)\n' + : ' in Dynamic1\n', + ) + ' in section\n' + ' in Suspense\n' + ' in body\n' + @@ -2072,16 +2088,19 @@ describe('ReactFlightDOMNode', () => { ); expect(normalizeCodeLocInfo(errorLocations[0].ownerStack)).toBe( '\n' + - // TODO: IO info is getting omitted when reencoding - // ' in Dynamic1 (at **)\n' + + gate(flags => + flags.enableAsyncDebugInfo ? ' in Dynamic1 (at **)\n' : '', + ) + ' in App (at **)', ); expect(normalizeCodeLocInfo(errorLocations[1].componentStack)).toBe( '\n' + - // TODO: IO info is getting omitted when reencoding - // ' in Dynamic2 (at **)\n' + - ' in Dynamic2\n' + + gate(flags => + flags.enableAsyncDebugInfo + ? ' in Dynamic2 (at **)\n' + : ' in Dynamic2\n', + ) + ' in section\n' + ' in Suspense\n' + ' in body\n' + @@ -2091,8 +2110,9 @@ describe('ReactFlightDOMNode', () => { ); expect(normalizeCodeLocInfo(errorLocations[1].ownerStack)).toBe( '\n' + - // TODO: IO info is getting omitted when reencoding - // ' in Dynamic2 (at **)\n' + + gate(flags => + flags.enableAsyncDebugInfo ? ' in Dynamic2 (at **)\n' : '', + ) + ' in App (at **)', ); @@ -2176,6 +2196,8 @@ describe('ReactFlightDOMNode', () => { // 1. Render , dividing the output into static and dynamic content. + let startTime = -1; + let isStatic = true; const chunks1 = { static: [], @@ -2184,11 +2206,14 @@ describe('ReactFlightDOMNode', () => { await new Promise(resolve => { setTimeout(async () => { + startTime = performance.now() + performance.timeOrigin; + const stream = ReactServerDOMServer.renderToPipeableStream( ReactServer.createElement(App), webpackMap, { filterStackFrame, + startTime, environmentName() { return isStatic ? 'Prerender' : 'Server'; }, @@ -2222,6 +2247,7 @@ describe('ReactFlightDOMNode', () => { await reencodeFlightStream( chunks1.static, chunks1.dynamic, + startTime, serverConsumerManifestForFlight, ); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js index 1c417ff6bda..2d81fb374bf 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js @@ -15,6 +15,8 @@ import type {Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerConfigWebpackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; +import {enableProfilerTimer} from 'shared/ReactFeatureFlags'; + import { createRequest, createPrerenderRequest, @@ -64,6 +66,7 @@ type Options = { signal?: AbortSignal, temporaryReferences?: TemporaryReferenceSet, onError?: (error: mixed) => void, + startTime?: number, }; function startReadingFromDebugChannelReadableStream( @@ -126,6 +129,7 @@ function renderToReadableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, debugChannelReadable !== undefined, @@ -214,6 +218,7 @@ function prerender( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, false, diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js index 77067754bc5..7b7ffe2c48d 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js @@ -15,6 +15,8 @@ import type {Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerConfigWebpackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; +import {enableProfilerTimer} from 'shared/ReactFeatureFlags'; + import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -69,6 +71,7 @@ type Options = { signal?: AbortSignal, temporaryReferences?: TemporaryReferenceSet, onError?: (error: mixed) => void, + startTime?: number, }; function startReadingFromDebugChannelReadableStream( @@ -131,6 +134,7 @@ function renderToReadableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, debugChannelReadable !== undefined, @@ -219,6 +223,7 @@ function prerender( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, false, diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index 888d0139144..1bae54ab339 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -17,6 +17,7 @@ import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; import type {Busboy} from 'busboy'; import type {Writable} from 'stream'; import type {Thenable} from 'shared/ReactTypes'; +import {enableProfilerTimer} from 'shared/ReactFeatureFlags'; import type {Duplex} from 'stream'; @@ -152,6 +153,7 @@ type Options = { onError?: (error: mixed) => void, identifierPrefix?: string, temporaryReferences?: TemporaryReferenceSet, + startTime?: number, }; type PipeableStream = { @@ -189,6 +191,7 @@ function renderToPipeableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, debugChannelReadable !== undefined, @@ -347,6 +350,7 @@ function renderToReadableStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, debugChannelReadable !== undefined, @@ -429,6 +433,7 @@ type PrerenderOptions = { identifierPrefix?: string, temporaryReferences?: TemporaryReferenceSet, signal?: AbortSignal, + startTime?: number, }; type StaticResult = { @@ -460,6 +465,7 @@ function prerenderToNodeStream( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, false, @@ -523,6 +529,7 @@ function prerender( options ? options.onError : undefined, options ? options.identifierPrefix : undefined, options ? options.temporaryReferences : undefined, + enableProfilerTimer && options ? options.startTime : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, false, diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 71a7891c7ca..dda3dca340a 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -661,6 +661,7 @@ function RequestInstance( onFatalError: (error: mixed) => void, identifierPrefix?: string, temporaryReferences: void | TemporaryReferenceSet, + debugStartTime: void | number, // Profiling-only environmentName: void | string | (() => string), // DEV-only filterStackFrame: void | ((url: string, functionName: string) => boolean), // DEV-only keepDebugAlive: boolean, // DEV-only @@ -753,7 +754,15 @@ function RequestInstance( // This avoids leaking unnecessary information like how long the server has // been running and allows for more compact representation of each timestamp. // The time origin is stored as an offset in the time space of this environment. - timeOrigin = this.timeOrigin = performance.now(); + if (typeof debugStartTime === 'number') { + // We expect `startTime` to be an absolute timestamp, so relativize it to match the other case. + timeOrigin = this.timeOrigin = + debugStartTime - + // $FlowFixMe[prop-missing] + performance.timeOrigin; + } else { + timeOrigin = this.timeOrigin = performance.now(); + } emitTimeOriginChunk( this, timeOrigin + @@ -786,6 +795,7 @@ export function createRequest( onError: void | ((error: mixed) => ?string), identifierPrefix: void | string, temporaryReferences: void | TemporaryReferenceSet, + debugStartTime: void | number, // Profiling-only environmentName: void | string | (() => string), // DEV-only filterStackFrame: void | ((url: string, functionName: string) => boolean), // DEV-only keepDebugAlive: boolean, // DEV-only @@ -804,6 +814,7 @@ export function createRequest( noop, identifierPrefix, temporaryReferences, + debugStartTime, environmentName, filterStackFrame, keepDebugAlive, @@ -818,6 +829,7 @@ export function createPrerenderRequest( onError: void | ((error: mixed) => ?string), identifierPrefix: void | string, temporaryReferences: void | TemporaryReferenceSet, + debugStartTime: void | number, // Profiling-only environmentName: void | string | (() => string), // DEV-only filterStackFrame: void | ((url: string, functionName: string) => boolean), // DEV-only keepDebugAlive: boolean, // DEV-only @@ -836,6 +848,7 @@ export function createPrerenderRequest( onFatalError, identifierPrefix, temporaryReferences, + debugStartTime, environmentName, filterStackFrame, keepDebugAlive,