[Flight] Add DebugInfo for Bundler Chunks (#34226)

This adds a "suspended by" row for each chunk that is referenced from a
client reference. So when you select a client component, you can see
what bundles will block that client component when loading on the
client.

This is only done in the browser build since if we added it on the
server, it would show up as a blocking resource and while it's possible
we expect that a typical server request won't block on loading JS.

<img width="664" height="486" alt="Screenshot 2025-08-17 at 3 45 14 PM"
src="https://github.com/user-attachments/assets/b1f83445-2a4e-4470-9a20-7cd215ab0482"
/>

<img width="745" height="678" alt="Screenshot 2025-08-17 at 3 46 58 PM"
src="https://github.com/user-attachments/assets/3558eae1-cf34-4e11-9d0e-02ec076356a4"
/>

Currently this is only included if it ends up wrapped in a lazy like in
the typical type position of a Client Component, but there's a general
issue that maybe hard references need to transfer their debug info to
the parent which can transfer it to the Fiber.
This commit is contained in:
Sebastian Markbåge 2025-08-18 11:34:00 -04:00 committed by GitHub
parent 87a45ae37f
commit 0c89b160f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 405 additions and 7 deletions

View File

@ -468,6 +468,7 @@ module.exports = {
files: ['packages/react-server-dom-webpack/**/*.js'],
globals: {
__webpack_chunk_load__: 'readonly',
__webpack_get_script_filename__: 'readonly',
__webpack_require__: 'readonly',
},
},

View File

@ -55,6 +55,7 @@ import {
resolveServerReference,
preloadModule,
requireModule,
getModuleDebugInfo,
dispatchHint,
readPartialStringChunk,
readFinalStringChunk,
@ -790,8 +791,14 @@ function resolveModuleChunk<T>(
resolvedChunk.status = RESOLVED_MODULE;
resolvedChunk.value = value;
if (__DEV__) {
// We don't expect to have any debug info for this row.
resolvedChunk._debugInfo = null;
const debugInfo = getModuleDebugInfo(value);
if (debugInfo !== null && resolvedChunk._debugInfo != null) {
// Add to the live set if it was already initialized.
// $FlowFixMe[method-unbinding]
resolvedChunk._debugInfo.push.apply(resolvedChunk._debugInfo, debugInfo);
} else {
resolvedChunk._debugInfo = debugInfo;
}
}
if (resolveListeners !== null) {
initializeModuleChunk(resolvedChunk);
@ -3977,7 +3984,11 @@ function flushComponentPerformance(
// Track the root most component of the result for deduping logging.
result.component = componentInfo;
isLastComponent = false;
} else if (candidateInfo.awaited) {
} else if (
candidateInfo.awaited &&
// Skip awaits on client resources since they didn't block the server component.
candidateInfo.awaited.env != null
) {
if (endTime > childrenEndTime) {
childrenEndTime = endTime;
}
@ -4059,7 +4070,11 @@ function flushComponentPerformance(
// Track the root most component of the result for deduping logging.
result.component = componentInfo;
isLastComponent = false;
} else if (candidateInfo.awaited) {
} else if (
candidateInfo.awaited &&
// Skip awaits on client resources since they didn't block the server component.
candidateInfo.awaited.env != null
) {
// If we don't have an end time for an await, that means we aborted.
const asyncInfo: ReactAsyncInfo = candidateInfo;
const env = response._rootEnvironmentName;

View File

@ -35,6 +35,7 @@ export const resolveClientReference = $$$config.resolveClientReference;
export const resolveServerReference = $$$config.resolveServerReference;
export const preloadModule = $$$config.preloadModule;
export const requireModule = $$$config.requireModule;
export const getModuleDebugInfo = $$$config.getModuleDebugInfo;
export const dispatchHint = $$$config.dispatchHint;
export const prepareDestinationForModule =
$$$config.prepareDestinationForModule;

View File

@ -24,5 +24,6 @@ export const resolveClientReference: any = null;
export const resolveServerReference: any = null;
export const preloadModule: any = null;
export const requireModule: any = null;
export const getModuleDebugInfo: any = null;
export const prepareDestinationForModule: any = null;
export const usedWithSSR = true;

View File

@ -24,6 +24,7 @@ export const resolveClientReference: any = null;
export const resolveServerReference: any = null;
export const preloadModule: any = null;
export const requireModule: any = null;
export const getModuleDebugInfo: any = null;
export const dispatchHint: any = null;
export const prepareDestinationForModule: any = null;
export const usedWithSSR = true;

View File

@ -62,6 +62,12 @@ export function requireModule<T>(metadata: ClientReference<T>): T {
);
}
export function getModuleDebugInfo<T>(metadata: ClientReference<T>): null {
throw new Error(
'renderToHTML should not have emitted Client References. This is a bug in React.',
);
}
export const usedWithSSR = true;
type HintCode = string;

View File

@ -11,7 +11,11 @@ import type {
Thenable,
FulfilledThenable,
RejectedThenable,
ReactDebugInfo,
ReactIOInfo,
ReactAsyncInfo,
} from 'shared/ReactTypes';
import type {ModuleLoading} from 'react-client/src/ReactFlightClientConfig';
export type ServerConsumerModuleMap = string; // Module root path
@ -118,3 +122,93 @@ export function requireModule<T>(metadata: ClientReference<T>): T {
}
return moduleExports[metadata.name];
}
// We cache ReactIOInfo across requests so that inner refreshes can dedupe with outer.
const moduleIOInfoCache: Map<string, ReactIOInfo> = __DEV__
? new Map()
: (null: any);
export function getModuleDebugInfo<T>(
metadata: ClientReference<T>,
): null | ReactDebugInfo {
if (!__DEV__) {
return null;
}
const filename = metadata.specifier;
let ioInfo = moduleIOInfoCache.get(filename);
if (ioInfo === undefined) {
let href;
try {
// $FlowFixMe
href = new URL(filename, document.baseURI).href;
} catch (_) {
href = filename;
}
let start = -1;
let end = -1;
let byteSize = 0;
// $FlowFixMe[method-unbinding]
if (typeof performance.getEntriesByType === 'function') {
// We may be able to collect the start and end time of this resource from Performance Observer.
const resourceEntries = performance.getEntriesByType('resource');
for (let i = 0; i < resourceEntries.length; i++) {
const resourceEntry = resourceEntries[i];
if (resourceEntry.name === href) {
start = resourceEntry.startTime;
end = start + resourceEntry.duration;
// $FlowFixMe[prop-missing]
byteSize = (resourceEntry.transferSize: any) || 0;
}
}
}
const value = Promise.resolve(href);
// $FlowFixMe
value.status = 'fulfilled';
// Is there some more useful representation for the chunk?
// $FlowFixMe
value.value = href;
// Create a fake stack frame that points to the beginning of the chunk. This is
// probably not source mapped so will link to the compiled source rather than
// any individual file that goes into the chunks.
const fakeStack = new Error('react-stack-top-frame');
if (fakeStack.stack.startsWith('Error: react-stack-top-frame')) {
// Looks like V8
fakeStack.stack =
'Error: react-stack-top-frame\n' +
// Add two frames since we always trim one off the top.
' at Client Component Bundle (' +
href +
':1:1)\n' +
' at Client Component Bundle (' +
href +
':1:1)';
} else {
// Looks like Firefox or Safari.
// Add two frames since we always trim one off the top.
fakeStack.stack =
'Client Component Bundle@' +
href +
':1:1\n' +
'Client Component Bundle@' +
href +
':1:1';
}
ioInfo = ({
name: 'script',
start: start,
end: end,
value: value,
debugStack: fakeStack,
}: ReactIOInfo);
if (byteSize > 0) {
// $FlowFixMe[cannot-write]
ioInfo.byteSize = byteSize;
}
moduleIOInfoCache.set(filename, ioInfo);
}
// We could dedupe the async info too but conceptually each request is its own await.
const asyncInfo: ReactAsyncInfo = {
awaited: ioInfo,
};
return [asyncInfo];
}

View File

@ -7,7 +7,7 @@
* @flow
*/
import type {Thenable} from 'shared/ReactTypes';
import type {Thenable, ReactDebugInfo} from 'shared/ReactTypes';
import type {ImportMetadata} from '../shared/ReactFlightImportMetadata';
@ -80,3 +80,10 @@ export function requireModule<T>(metadata: ClientReference<T>): T {
const moduleExports = parcelRequire(metadata[ID]);
return moduleExports[metadata[NAME]];
}
export function getModuleDebugInfo<T>(
metadata: ClientReference<T>,
): null | ReactDebugInfo {
// TODO
return null;
}

View File

@ -11,6 +11,7 @@ import type {
Thenable,
FulfilledThenable,
RejectedThenable,
ReactDebugInfo,
} from 'shared/ReactTypes';
import type {
@ -28,7 +29,10 @@ import {
import {prepareDestinationWithChunks} from 'react-client/src/ReactFlightClientConfig';
import {loadChunk} from 'react-client/src/ReactFlightClientConfig';
import {
loadChunk,
addChunkDebugInfo,
} from 'react-client/src/ReactFlightClientConfig';
export type ServerConsumerModuleMap = null | {
[clientId: string]: {
@ -231,3 +235,19 @@ export function requireModule<T>(metadata: ClientReference<T>): T {
}
return moduleExports[metadata[NAME]];
}
export function getModuleDebugInfo<T>(
metadata: ClientReference<T>,
): null | ReactDebugInfo {
if (!__DEV__) {
return null;
}
const chunks = metadata[CHUNKS];
const debugInfo: ReactDebugInfo = [];
let i = 0;
while (i < chunks.length) {
const chunkFilename = chunks[i++];
addChunkDebugInfo(debugInfo, chunkFilename);
}
return debugInfo;
}

View File

@ -7,6 +7,102 @@
* @flow
*/
import type {
ReactDebugInfo,
ReactIOInfo,
ReactAsyncInfo,
} from 'shared/ReactTypes';
export function loadChunk(filename: string): Promise<mixed> {
return __turbopack_load_by_url__(filename);
}
// We cache ReactIOInfo across requests so that inner refreshes can dedupe with outer.
const chunkIOInfoCache: Map<string, ReactIOInfo> = __DEV__
? new Map()
: (null: any);
export function addChunkDebugInfo(
target: ReactDebugInfo,
filename: string,
): void {
if (!__DEV__) {
return;
}
let ioInfo = chunkIOInfoCache.get(filename);
if (ioInfo === undefined) {
let href;
try {
// $FlowFixMe
href = new URL(filename, document.baseURI).href;
} catch (_) {
href = filename;
}
let start = -1;
let end = -1;
let byteSize = 0;
// $FlowFixMe[method-unbinding]
if (typeof performance.getEntriesByType === 'function') {
// We may be able to collect the start and end time of this resource from Performance Observer.
const resourceEntries = performance.getEntriesByType('resource');
for (let i = 0; i < resourceEntries.length; i++) {
const resourceEntry = resourceEntries[i];
if (resourceEntry.name === href) {
start = resourceEntry.startTime;
end = start + resourceEntry.duration;
// $FlowFixMe[prop-missing]
byteSize = (resourceEntry.transferSize: any) || 0;
}
}
}
const value = Promise.resolve(href);
// $FlowFixMe
value.status = 'fulfilled';
// Is there some more useful representation for the chunk?
// $FlowFixMe
value.value = href;
// Create a fake stack frame that points to the beginning of the chunk. This is
// probably not source mapped so will link to the compiled source rather than
// any individual file that goes into the chunks.
const fakeStack = new Error('react-stack-top-frame');
if (fakeStack.stack.startsWith('Error: react-stack-top-frame')) {
// Looks like V8
fakeStack.stack =
'Error: react-stack-top-frame\n' +
// Add two frames since we always trim one off the top.
' at Client Component Bundle (' +
href +
':1:1)\n' +
' at Client Component Bundle (' +
href +
':1:1)';
} else {
// Looks like Firefox or Safari.
// Add two frames since we always trim one off the top.
fakeStack.stack =
'Client Component Bundle@' +
href +
':1:1\n' +
'Client Component Bundle@' +
href +
':1:1';
}
ioInfo = ({
name: 'script',
start: start,
end: end,
value: value,
debugStack: fakeStack,
}: ReactIOInfo);
if (byteSize > 0) {
// $FlowFixMe[cannot-write]
ioInfo.byteSize = byteSize;
}
chunkIOInfoCache.set(filename, ioInfo);
}
// We could dedupe the async info too but conceptually each request is its own await.
const asyncInfo: ReactAsyncInfo = {
awaited: ioInfo,
};
target.push(asyncInfo);
}

View File

@ -7,6 +7,16 @@
* @flow
*/
import type {ReactDebugInfo} from 'shared/ReactTypes';
export function loadChunk(filename: string): Promise<mixed> {
return __turbopack_load_by_url__(filename);
}
export function addChunkDebugInfo(
target: ReactDebugInfo,
filename: string,
): void {
// We don't emit any debug info on the server since we assume the loading
// of the bundle is insignificant on the server.
}

View File

@ -27,6 +27,9 @@ global.__webpack_require__ = function (id) {
}
return webpackClientModules[id] || webpackServerModules[id];
};
global.__webpack_get_script_filename__ = function (id) {
return id;
};
const previousCompile = Module.prototype._compile;

View File

@ -160,3 +160,9 @@ export function requireModule<T>(metadata: ClientReference<T>): T {
}
return moduleExports[metadata.name];
}
export function getModuleDebugInfo<T>(metadata: ClientReference<T>): null {
// We don't emit any debug info on the server since we assume the loading
// of the bundle is insignificant on the server.
return null;
}

View File

@ -11,6 +11,7 @@ import type {
Thenable,
FulfilledThenable,
RejectedThenable,
ReactDebugInfo,
} from 'shared/ReactTypes';
import type {
@ -28,7 +29,10 @@ import {
import {prepareDestinationWithChunks} from 'react-client/src/ReactFlightClientConfig';
import {loadChunk} from 'react-client/src/ReactFlightClientConfig';
import {
loadChunk,
addChunkDebugInfo,
} from 'react-client/src/ReactFlightClientConfig';
export type ServerConsumerModuleMap = null | {
[clientId: string]: {
@ -251,3 +255,20 @@ export function requireModule<T>(metadata: ClientReference<T>): T {
}
return moduleExports[metadata[NAME]];
}
export function getModuleDebugInfo<T>(
metadata: ClientReference<T>,
): null | ReactDebugInfo {
if (!__DEV__) {
return null;
}
const chunks = metadata[CHUNKS];
const debugInfo: ReactDebugInfo = [];
let i = 0;
while (i < chunks.length) {
const chunkId = chunks[i++];
const chunkFilename = chunks[i++];
addChunkDebugInfo(debugInfo, chunkId, chunkFilename);
}
return debugInfo;
}

View File

@ -7,6 +7,12 @@
* @flow
*/
import type {
ReactDebugInfo,
ReactIOInfo,
ReactAsyncInfo,
} from 'shared/ReactTypes';
const chunkMap: Map<string, string> = new Map();
/**
@ -26,3 +32,98 @@ export function loadChunk(chunkId: string, filename: string): Promise<mixed> {
chunkMap.set(chunkId, filename);
return __webpack_chunk_load__(chunkId);
}
// We cache ReactIOInfo across requests so that inner refreshes can dedupe with outer.
const chunkIOInfoCache: Map<string, ReactIOInfo> = __DEV__
? new Map()
: (null: any);
export function addChunkDebugInfo(
target: ReactDebugInfo,
chunkId: string,
filename: string,
): void {
if (!__DEV__) {
return;
}
let ioInfo = chunkIOInfoCache.get(chunkId);
if (ioInfo === undefined) {
const scriptFilename = __webpack_get_script_filename__(chunkId);
let href;
try {
// $FlowFixMe
href = new URL(scriptFilename, document.baseURI).href;
} catch (_) {
href = scriptFilename;
}
let start = -1;
let end = -1;
let byteSize = 0;
// $FlowFixMe[method-unbinding]
if (typeof performance.getEntriesByType === 'function') {
// We may be able to collect the start and end time of this resource from Performance Observer.
const resourceEntries = performance.getEntriesByType('resource');
for (let i = 0; i < resourceEntries.length; i++) {
const resourceEntry = resourceEntries[i];
if (resourceEntry.name === href) {
start = resourceEntry.startTime;
end = start + resourceEntry.duration;
// $FlowFixMe[prop-missing]
byteSize = (resourceEntry.transferSize: any) || 0;
}
}
}
const value = Promise.resolve(href);
// $FlowFixMe
value.status = 'fulfilled';
// $FlowFixMe
value.value = {
chunkId: chunkId,
href: href,
// Is there some more useful representation for the chunk?
};
// Create a fake stack frame that points to the beginning of the chunk. This is
// probably not source mapped so will link to the compiled source rather than
// any individual file that goes into the chunks.
const fakeStack = new Error('react-stack-top-frame');
if (fakeStack.stack.startsWith('Error: react-stack-top-frame')) {
// Looks like V8
fakeStack.stack =
'Error: react-stack-top-frame\n' +
// Add two frames since we always trim one off the top.
' at Client Component Bundle (' +
href +
':1:1)\n' +
' at Client Component Bundle (' +
href +
':1:1)';
} else {
// Looks like Firefox or Safari.
// Add two frames since we always trim one off the top.
fakeStack.stack =
'Client Component Bundle@' +
href +
':1:1\n' +
'Client Component Bundle@' +
href +
':1:1';
}
ioInfo = ({
name: 'script',
start: start,
end: end,
value: value,
debugStack: fakeStack,
}: ReactIOInfo);
if (byteSize > 0) {
// $FlowFixMe[cannot-write]
ioInfo.byteSize = byteSize;
}
chunkIOInfoCache.set(chunkId, ioInfo);
}
// We could dedupe the async info too but conceptually each request is its own await.
const asyncInfo: ReactAsyncInfo = {
awaited: ioInfo,
};
target.push(asyncInfo);
}

View File

@ -7,6 +7,17 @@
* @flow
*/
import type {ReactDebugInfo} from 'shared/ReactTypes';
export function loadChunk(chunkId: string, filename: string): Promise<mixed> {
return __webpack_chunk_load__(chunkId);
}
export function addChunkDebugInfo(
target: ReactDebugInfo,
chunkId: string,
filename: string,
): void {
// We don't emit any debug info on the server since we assume the loading
// of the bundle is insignificant on the server.
}

View File

@ -146,6 +146,7 @@ declare module 'EventListener' {
}
declare function __webpack_chunk_load__(id: string): Promise<mixed>;
declare function __webpack_get_script_filename__(id: string): string;
declare const __webpack_require__: ((id: string) => any) & {
u: string => string,
};

View File

@ -62,6 +62,7 @@ module.exports = {
// Flight Webpack
__webpack_chunk_load__: 'readonly',
__webpack_get_script_filename__: 'readonly',
__webpack_require__: 'readonly',
// Flight Turbopack

View File

@ -59,6 +59,7 @@ module.exports = {
// Flight Webpack
__webpack_chunk_load__: 'readonly',
__webpack_get_script_filename__: 'readonly',
__webpack_require__: 'readonly',
// Flight Turbopack

View File

@ -62,6 +62,7 @@ module.exports = {
// Flight Webpack
__webpack_chunk_load__: 'readonly',
__webpack_get_script_filename__: 'readonly',
__webpack_require__: 'readonly',
// Flight Turbopack