Skip to content

Commit 0c89b16

Browse files
authored
[Flight] Add DebugInfo for Bundler Chunks (facebook#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.
1 parent 87a45ae commit 0c89b16

20 files changed

+405
-7
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,7 @@ module.exports = {
468468
files: ['packages/react-server-dom-webpack/**/*.js'],
469469
globals: {
470470
__webpack_chunk_load__: 'readonly',
471+
__webpack_get_script_filename__: 'readonly',
471472
__webpack_require__: 'readonly',
472473
},
473474
},

packages/react-client/src/ReactFlightClient.js

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
resolveServerReference,
5656
preloadModule,
5757
requireModule,
58+
getModuleDebugInfo,
5859
dispatchHint,
5960
readPartialStringChunk,
6061
readFinalStringChunk,
@@ -790,8 +791,14 @@ function resolveModuleChunk<T>(
790791
resolvedChunk.status = RESOLVED_MODULE;
791792
resolvedChunk.value = value;
792793
if (__DEV__) {
793-
// We don't expect to have any debug info for this row.
794-
resolvedChunk._debugInfo = null;
794+
const debugInfo = getModuleDebugInfo(value);
795+
if (debugInfo !== null && resolvedChunk._debugInfo != null) {
796+
// Add to the live set if it was already initialized.
797+
// $FlowFixMe[method-unbinding]
798+
resolvedChunk._debugInfo.push.apply(resolvedChunk._debugInfo, debugInfo);
799+
} else {
800+
resolvedChunk._debugInfo = debugInfo;
801+
}
795802
}
796803
if (resolveListeners !== null) {
797804
initializeModuleChunk(resolvedChunk);
@@ -3977,7 +3984,11 @@ function flushComponentPerformance(
39773984
// Track the root most component of the result for deduping logging.
39783985
result.component = componentInfo;
39793986
isLastComponent = false;
3980-
} else if (candidateInfo.awaited) {
3987+
} else if (
3988+
candidateInfo.awaited &&
3989+
// Skip awaits on client resources since they didn't block the server component.
3990+
candidateInfo.awaited.env != null
3991+
) {
39813992
if (endTime > childrenEndTime) {
39823993
childrenEndTime = endTime;
39833994
}
@@ -4059,7 +4070,11 @@ function flushComponentPerformance(
40594070
// Track the root most component of the result for deduping logging.
40604071
result.component = componentInfo;
40614072
isLastComponent = false;
4062-
} else if (candidateInfo.awaited) {
4073+
} else if (
4074+
candidateInfo.awaited &&
4075+
// Skip awaits on client resources since they didn't block the server component.
4076+
candidateInfo.awaited.env != null
4077+
) {
40634078
// If we don't have an end time for an await, that means we aborted.
40644079
const asyncInfo: ReactAsyncInfo = candidateInfo;
40654080
const env = response._rootEnvironmentName;

packages/react-client/src/forks/ReactFlightClientConfig.custom.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const resolveClientReference = $$$config.resolveClientReference;
3535
export const resolveServerReference = $$$config.resolveServerReference;
3636
export const preloadModule = $$$config.preloadModule;
3737
export const requireModule = $$$config.requireModule;
38+
export const getModuleDebugInfo = $$$config.getModuleDebugInfo;
3839
export const dispatchHint = $$$config.dispatchHint;
3940
export const prepareDestinationForModule =
4041
$$$config.prepareDestinationForModule;

packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ export const resolveClientReference: any = null;
2424
export const resolveServerReference: any = null;
2525
export const preloadModule: any = null;
2626
export const requireModule: any = null;
27+
export const getModuleDebugInfo: any = null;
2728
export const prepareDestinationForModule: any = null;
2829
export const usedWithSSR = true;

packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const resolveClientReference: any = null;
2424
export const resolveServerReference: any = null;
2525
export const preloadModule: any = null;
2626
export const requireModule: any = null;
27+
export const getModuleDebugInfo: any = null;
2728
export const dispatchHint: any = null;
2829
export const prepareDestinationForModule: any = null;
2930
export const usedWithSSR = true;

packages/react-client/src/forks/ReactFlightClientConfig.markup.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ export function requireModule<T>(metadata: ClientReference<T>): T {
6262
);
6363
}
6464

65+
export function getModuleDebugInfo<T>(metadata: ClientReference<T>): null {
66+
throw new Error(
67+
'renderToHTML should not have emitted Client References. This is a bug in React.',
68+
);
69+
}
70+
6571
export const usedWithSSR = true;
6672

6773
type HintCode = string;

packages/react-server-dom-esm/src/client/ReactFlightClientConfigBundlerESM.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import type {
1111
Thenable,
1212
FulfilledThenable,
1313
RejectedThenable,
14+
ReactDebugInfo,
15+
ReactIOInfo,
16+
ReactAsyncInfo,
1417
} from 'shared/ReactTypes';
18+
1519
import type {ModuleLoading} from 'react-client/src/ReactFlightClientConfig';
1620

1721
export type ServerConsumerModuleMap = string; // Module root path
@@ -118,3 +122,93 @@ export function requireModule<T>(metadata: ClientReference<T>): T {
118122
}
119123
return moduleExports[metadata.name];
120124
}
125+
126+
// We cache ReactIOInfo across requests so that inner refreshes can dedupe with outer.
127+
const moduleIOInfoCache: Map<string, ReactIOInfo> = __DEV__
128+
? new Map()
129+
: (null: any);
130+
131+
export function getModuleDebugInfo<T>(
132+
metadata: ClientReference<T>,
133+
): null | ReactDebugInfo {
134+
if (!__DEV__) {
135+
return null;
136+
}
137+
const filename = metadata.specifier;
138+
let ioInfo = moduleIOInfoCache.get(filename);
139+
if (ioInfo === undefined) {
140+
let href;
141+
try {
142+
// $FlowFixMe
143+
href = new URL(filename, document.baseURI).href;
144+
} catch (_) {
145+
href = filename;
146+
}
147+
let start = -1;
148+
let end = -1;
149+
let byteSize = 0;
150+
// $FlowFixMe[method-unbinding]
151+
if (typeof performance.getEntriesByType === 'function') {
152+
// We may be able to collect the start and end time of this resource from Performance Observer.
153+
const resourceEntries = performance.getEntriesByType('resource');
154+
for (let i = 0; i < resourceEntries.length; i++) {
155+
const resourceEntry = resourceEntries[i];
156+
if (resourceEntry.name === href) {
157+
start = resourceEntry.startTime;
158+
end = start + resourceEntry.duration;
159+
// $FlowFixMe[prop-missing]
160+
byteSize = (resourceEntry.transferSize: any) || 0;
161+
}
162+
}
163+
}
164+
const value = Promise.resolve(href);
165+
// $FlowFixMe
166+
value.status = 'fulfilled';
167+
// Is there some more useful representation for the chunk?
168+
// $FlowFixMe
169+
value.value = href;
170+
// Create a fake stack frame that points to the beginning of the chunk. This is
171+
// probably not source mapped so will link to the compiled source rather than
172+
// any individual file that goes into the chunks.
173+
const fakeStack = new Error('react-stack-top-frame');
174+
if (fakeStack.stack.startsWith('Error: react-stack-top-frame')) {
175+
// Looks like V8
176+
fakeStack.stack =
177+
'Error: react-stack-top-frame\n' +
178+
// Add two frames since we always trim one off the top.
179+
' at Client Component Bundle (' +
180+
href +
181+
':1:1)\n' +
182+
' at Client Component Bundle (' +
183+
href +
184+
':1:1)';
185+
} else {
186+
// Looks like Firefox or Safari.
187+
// Add two frames since we always trim one off the top.
188+
fakeStack.stack =
189+
'Client Component Bundle@' +
190+
href +
191+
':1:1\n' +
192+
'Client Component Bundle@' +
193+
href +
194+
':1:1';
195+
}
196+
ioInfo = ({
197+
name: 'script',
198+
start: start,
199+
end: end,
200+
value: value,
201+
debugStack: fakeStack,
202+
}: ReactIOInfo);
203+
if (byteSize > 0) {
204+
// $FlowFixMe[cannot-write]
205+
ioInfo.byteSize = byteSize;
206+
}
207+
moduleIOInfoCache.set(filename, ioInfo);
208+
}
209+
// We could dedupe the async info too but conceptually each request is its own await.
210+
const asyncInfo: ReactAsyncInfo = {
211+
awaited: ioInfo,
212+
};
213+
return [asyncInfo];
214+
}

packages/react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @flow
88
*/
99

10-
import type {Thenable} from 'shared/ReactTypes';
10+
import type {Thenable, ReactDebugInfo} from 'shared/ReactTypes';
1111

1212
import type {ImportMetadata} from '../shared/ReactFlightImportMetadata';
1313

@@ -80,3 +80,10 @@ export function requireModule<T>(metadata: ClientReference<T>): T {
8080
const moduleExports = parcelRequire(metadata[ID]);
8181
return moduleExports[metadata[NAME]];
8282
}
83+
84+
export function getModuleDebugInfo<T>(
85+
metadata: ClientReference<T>,
86+
): null | ReactDebugInfo {
87+
// TODO
88+
return null;
89+
}

packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
Thenable,
1212
FulfilledThenable,
1313
RejectedThenable,
14+
ReactDebugInfo,
1415
} from 'shared/ReactTypes';
1516

1617
import type {
@@ -28,7 +29,10 @@ import {
2829

2930
import {prepareDestinationWithChunks} from 'react-client/src/ReactFlightClientConfig';
3031

31-
import {loadChunk} from 'react-client/src/ReactFlightClientConfig';
32+
import {
33+
loadChunk,
34+
addChunkDebugInfo,
35+
} from 'react-client/src/ReactFlightClientConfig';
3236

3337
export type ServerConsumerModuleMap = null | {
3438
[clientId: string]: {
@@ -231,3 +235,19 @@ export function requireModule<T>(metadata: ClientReference<T>): T {
231235
}
232236
return moduleExports[metadata[NAME]];
233237
}
238+
239+
export function getModuleDebugInfo<T>(
240+
metadata: ClientReference<T>,
241+
): null | ReactDebugInfo {
242+
if (!__DEV__) {
243+
return null;
244+
}
245+
const chunks = metadata[CHUNKS];
246+
const debugInfo: ReactDebugInfo = [];
247+
let i = 0;
248+
while (i < chunks.length) {
249+
const chunkFilename = chunks[i++];
250+
addChunkDebugInfo(debugInfo, chunkFilename);
251+
}
252+
return debugInfo;
253+
}

packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackBrowser.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,102 @@
77
* @flow
88
*/
99

10+
import type {
11+
ReactDebugInfo,
12+
ReactIOInfo,
13+
ReactAsyncInfo,
14+
} from 'shared/ReactTypes';
15+
1016
export function loadChunk(filename: string): Promise<mixed> {
1117
return __turbopack_load_by_url__(filename);
1218
}
19+
20+
// We cache ReactIOInfo across requests so that inner refreshes can dedupe with outer.
21+
const chunkIOInfoCache: Map<string, ReactIOInfo> = __DEV__
22+
? new Map()
23+
: (null: any);
24+
25+
export function addChunkDebugInfo(
26+
target: ReactDebugInfo,
27+
filename: string,
28+
): void {
29+
if (!__DEV__) {
30+
return;
31+
}
32+
let ioInfo = chunkIOInfoCache.get(filename);
33+
if (ioInfo === undefined) {
34+
let href;
35+
try {
36+
// $FlowFixMe
37+
href = new URL(filename, document.baseURI).href;
38+
} catch (_) {
39+
href = filename;
40+
}
41+
let start = -1;
42+
let end = -1;
43+
let byteSize = 0;
44+
// $FlowFixMe[method-unbinding]
45+
if (typeof performance.getEntriesByType === 'function') {
46+
// We may be able to collect the start and end time of this resource from Performance Observer.
47+
const resourceEntries = performance.getEntriesByType('resource');
48+
for (let i = 0; i < resourceEntries.length; i++) {
49+
const resourceEntry = resourceEntries[i];
50+
if (resourceEntry.name === href) {
51+
start = resourceEntry.startTime;
52+
end = start + resourceEntry.duration;
53+
// $FlowFixMe[prop-missing]
54+
byteSize = (resourceEntry.transferSize: any) || 0;
55+
}
56+
}
57+
}
58+
const value = Promise.resolve(href);
59+
// $FlowFixMe
60+
value.status = 'fulfilled';
61+
// Is there some more useful representation for the chunk?
62+
// $FlowFixMe
63+
value.value = href;
64+
// Create a fake stack frame that points to the beginning of the chunk. This is
65+
// probably not source mapped so will link to the compiled source rather than
66+
// any individual file that goes into the chunks.
67+
const fakeStack = new Error('react-stack-top-frame');
68+
if (fakeStack.stack.startsWith('Error: react-stack-top-frame')) {
69+
// Looks like V8
70+
fakeStack.stack =
71+
'Error: react-stack-top-frame\n' +
72+
// Add two frames since we always trim one off the top.
73+
' at Client Component Bundle (' +
74+
href +
75+
':1:1)\n' +
76+
' at Client Component Bundle (' +
77+
href +
78+
':1:1)';
79+
} else {
80+
// Looks like Firefox or Safari.
81+
// Add two frames since we always trim one off the top.
82+
fakeStack.stack =
83+
'Client Component Bundle@' +
84+
href +
85+
':1:1\n' +
86+
'Client Component Bundle@' +
87+
href +
88+
':1:1';
89+
}
90+
ioInfo = ({
91+
name: 'script',
92+
start: start,
93+
end: end,
94+
value: value,
95+
debugStack: fakeStack,
96+
}: ReactIOInfo);
97+
if (byteSize > 0) {
98+
// $FlowFixMe[cannot-write]
99+
ioInfo.byteSize = byteSize;
100+
}
101+
chunkIOInfoCache.set(filename, ioInfo);
102+
}
103+
// We could dedupe the async info too but conceptually each request is its own await.
104+
const asyncInfo: ReactAsyncInfo = {
105+
awaited: ioInfo,
106+
};
107+
target.push(asyncInfo);
108+
}

0 commit comments

Comments
 (0)