[Flight] Encode ReactIOInfo as its own row type (#33390)
Stacked on #33388. This encodes the I/O entries as their own row type (`"J"`). This makes it possible to parse them directly without first parsing the debug info for each component. E.g. if you're just interested in logging the I/O without all the places it was awaited. This is not strictly necessary since the debug info is also readily available without parsing the actual trees. (That's how the Server Components Performance Track works.) However, we might want to exclude this information in profiling builds while retaining some limited form of I/O tracking. It also allows for logging side-effects that are not awaited if we wanted to.
Sebastian Markbåge committed
Jun 3, 2025 at 14:16 UTC
3fb17d16a4838e132d0d6dbb08f91b7e7da691eb
2 files changed
+145
-64
packages/react-client/src/ReactFlightClient.js
+87
-14
@@ -13,6 +13,7 @@ import type {
13
ReactComponentInfo,
14
ReactEnvironmentInfo,
15
ReactAsyncInfo,
16
+ ReactIOInfo,
17
ReactTimeInfo,
18
ReactStackTrace,
19
ReactFunctionLocation,
@@ -47,6 +48,7 @@ import {
48
enablePostpone,
49
enableProfilerTimer,
50
enableComponentPerformanceTrack,
51
+ enableAsyncDebugInfo,
52
} from 'shared/ReactFeatureFlags';
53
54
import {
@@ -672,6 +674,14 @@ function nullRefGetter() {
674
}
675
}
676
677
+function getIOInfoTaskName(ioInfo: ReactIOInfo): string {
678
+ return ''; // TODO
679
+}
680
+
681
+function getAsyncInfoTaskName(asyncInfo: ReactAsyncInfo): string {
682
+ return 'await'; // We could be smarter about this and give it a name like `then` or `Promise.all`.
683
+}
684
+
685
function getServerComponentTaskName(componentInfo: ReactComponentInfo): string {
686
return '<' + (componentInfo.name || '...') + '>';
687
}
@@ -2447,13 +2457,12 @@ function getRootTask(
2457
2458
function initializeFakeTask(
2459
response: Response,
2450
- debugInfo: ReactComponentInfo | ReactAsyncInfo,
2460
+ debugInfo: ReactComponentInfo | ReactAsyncInfo | ReactIOInfo,
2461
childEnvironmentName: string,
2462
): null | ConsoleTask {
2463
if (!supportsCreateTask) {
2464
return null;
2465
}
2456
- const componentInfo: ReactComponentInfo = (debugInfo: any); // Refined
2466
if (debugInfo.stack == null) {
2467
// If this is an error, we should've really already initialized the task.
2468
// If it's null, we can't initialize a task.
@@ -2461,16 +2470,14 @@ function initializeFakeTask(
2470
}
2471
const stack = debugInfo.stack;
2472
const env: string =
2464
- componentInfo.env == null
2465
- ? response._rootEnvironmentName
2466
- : componentInfo.env;
2473
+ debugInfo.env == null ? response._rootEnvironmentName : debugInfo.env;
2474
if (env !== childEnvironmentName) {
2475
// This is the boundary between two environments so we'll annotate the task name.
2476
// That is unusual so we don't cache it.
2477
const ownerTask =
2471
- componentInfo.owner == null
2478
+ debugInfo.owner == null
2479
? null
2473
- : initializeFakeTask(response, componentInfo.owner, env);
2480
+ : initializeFakeTask(response, debugInfo.owner, env);
2481
return buildFakeTask(
2482
response,
2483
ownerTask,
@@ -2479,20 +2486,27 @@ function initializeFakeTask(
2486
env,
2487
);
2488
} else {
2482
- const cachedEntry = componentInfo.debugTask;
2489
+ const cachedEntry = debugInfo.debugTask;
2490
if (cachedEntry !== undefined) {
2491
return cachedEntry;
2492
}
2493
const ownerTask =
2487
- componentInfo.owner == null
2494
+ debugInfo.owner == null
2495
? null
2489
- : initializeFakeTask(response, componentInfo.owner, env);
2496
+ : initializeFakeTask(response, debugInfo.owner, env);
2497
+ // Some unfortunate pattern matching to refine the type.
2498
+ const taskName =
2499
+ debugInfo.key !== undefined
2500
+ ? getServerComponentTaskName(((debugInfo: any): ReactComponentInfo))
2501
+ : debugInfo.name !== undefined
2502
+ ? getIOInfoTaskName(((debugInfo: any): ReactIOInfo))
2503
+ : getAsyncInfoTaskName(((debugInfo: any): ReactAsyncInfo));
2504
// $FlowFixMe[cannot-write]: We consider this part of initialization.
2491
- return (componentInfo.debugTask = buildFakeTask(
2505
+ return (debugInfo.debugTask = buildFakeTask(
2506
response,
2507
ownerTask,
2508
stack,
2495
- getServerComponentTaskName(componentInfo),
2509
+ taskName,
2510
env,
2511
));
2512
}
@@ -2555,7 +2569,7 @@ function fakeJSXCallSite() {
2569
2570
function initializeFakeStack(
2571
response: Response,
2558
- debugInfo: ReactComponentInfo | ReactAsyncInfo,
2572
+ debugInfo: ReactComponentInfo | ReactAsyncInfo | ReactIOInfo,
2573
): void {
2574
const cachedEntry = debugInfo.debugStack;
2575
if (cachedEntry !== undefined) {
@@ -2740,6 +2754,54 @@ function resolveConsoleEntry(
2754
);
2755
}
2756
2757
+function initializeIOInfo(response: Response, ioInfo: ReactIOInfo): void {
2758
+ const env =
2759
+ // TODO: Pass env through I/O info.
2760
+ // ioInfo.env !== undefined ? ioInfo.env :
2761
+ response._rootEnvironmentName;
2762
+ if (ioInfo.stack !== undefined) {
2763
+ initializeFakeTask(response, ioInfo, env);
2764
+ initializeFakeStack(response, ioInfo);
2765
+ }
2766
+ // TODO: Initialize owner.
2767
+ // Adjust the time to the current environment's time space.
2768
+ // $FlowFixMe[cannot-write]
2769
+ ioInfo.start += response._timeOrigin;
2770
+ // $FlowFixMe[cannot-write]
2771
+ ioInfo.end += response._timeOrigin;
2772
+}
2773
+
2774
+function resolveIOInfo(
2775
+ response: Response,
2776
+ id: number,
2777
+ model: UninitializedModel,
2778
+): void {
2779
+ const chunks = response._chunks;
2780
+ let chunk = chunks.get(id);
2781
+ if (!chunk) {
2782
+ chunk = createResolvedModelChunk(response, model);
2783
+ chunks.set(id, chunk);
2784
+ initializeModelChunk(chunk);
2785
+ } else {
2786
+ resolveModelChunk(chunk, model);
2787
+ if (chunk.status === RESOLVED_MODEL) {
2788
+ initializeModelChunk(chunk);
2789
+ }
2790
+ }
2791
+ if (chunk.status === INITIALIZED) {
2792
+ initializeIOInfo(response, chunk.value);
2793
+ } else {
2794
+ chunk.then(
2795
+ v => {
2796
+ initializeIOInfo(response, v);
2797
+ },
2798
+ e => {
2799
+ // Ignore debug info errors for now. Unnecessary noise.
2800
+ },
2801
+ );
2802
+ }
2803
+}
2804
+
2805
function mergeBuffer(
2806
buffer: Array<Uint8Array>,
2807
lastChunk: Uint8Array,
@@ -2844,7 +2906,7 @@ function flushComponentPerformance(
2906
2907
// First find the start time of the first component to know if it was running
2908
// in parallel with the previous.
2847
- const debugInfo = root._debugInfo;
2909
+ const debugInfo = __DEV__ && root._debugInfo;
2910
if (debugInfo) {
2911
for (let i = 1; i < debugInfo.length; i++) {
2912
const info = debugInfo[i];
@@ -3101,6 +3163,17 @@ function processFullStringRow(
3163
}
3164
// Fallthrough to share the error with Console entries.
3165
}
3166
+ case 74 /* "J" */: {
3167
+ if (
3168
+ enableProfilerTimer &&
3169
+ enableComponentPerformanceTrack &&
3170
+ enableAsyncDebugInfo
3171
+ ) {
3172
+ resolveIOInfo(response, id, row);
3173
+ return;
3174
+ }
3175
+ // Fallthrough to share the error with Console entries.
3176
+ }
3177
case 87 /* "W" */: {
3178
if (__DEV__) {
3179
resolveConsoleEntry(response, row);
packages/react-server/src/ReactFlightServer.js
+58
-50
@@ -1905,7 +1905,7 @@ function visitAsyncNode(
1905
return ioNode;
1906
}
1907
// Outline the IO node.
1908
- emitIOChunk(request, ioNode);
1908
+ serializeIONode(request, ioNode);
1909
// Then emit a reference to us awaiting it in the current task.
1910
request.pendingChunks++;
1911
emitDebugChunk(request, task.id, {
@@ -1942,7 +1942,7 @@ function emitAsyncSequence(
1942
// each occurrence. Right now we'll only track the first time it is invoked.
1943
awaitedNode.end = performance.now();
1944
}
1945
- emitIOChunk(request, awaitedNode);
1945
+ serializeIONode(request, awaitedNode);
1946
request.pendingChunks++;
1947
emitDebugChunk(request, task.id, {
1948
awaited: ((awaitedNode: any): ReactIOInfo), // This is deduped by this reference.
@@ -3493,80 +3493,88 @@ function outlineComponentInfo(
3493
request.writtenObjects.set(componentInfo, serializeByValueID(id));
3494
}
3495
3496
-function outlineIOInfo(request: Request, ioInfo: ReactIOInfo): void {
3496
+function emitIOInfoChunk(
3497
+ request: Request,
3498
+ id: number,
3499
+ start: number,
3500
+ end: number,
3501
+ stack: ?ReactStackTrace,
3502
+): void {
3503
if (!__DEV__) {
3504
// These errors should never make it into a build so we don't need to encode them in codes.json
3505
// eslint-disable-next-line react-internal/prod-error-codes
3506
throw new Error(
3501
- 'outlineIOInfo should never be called in production mode. This is a bug in React.',
3507
+ 'emitIOInfoChunk should never be called in production mode. This is a bug in React.',
3508
);
3509
}
3510
3505
- if (request.writtenObjects.has(ioInfo)) {
3506
- // Already written
3507
- return;
3508
- }
3509
-
3510
- // Limit the number of objects we write to prevent emitting giant props objects.
3511
let objectLimit = 10;
3512
- if (ioInfo.stack != null) {
3513
- // Ensure we have enough object limit to encode the stack trace.
3514
- objectLimit += ioInfo.stack.length;
3512
+ if (stack) {
3513
+ objectLimit += stack.length;
3514
}
3516
-
3517
- // We use the console encoding so that we can dedupe objects but don't necessarily
3518
- // use the full serialization that requires a task.
3515
const counter = {objectLimit};
3516
+ function replacer(
3517
+ this:
3518
+ | {+[key: string | number]: ReactClientValue}
3519
+ | $ReadOnlyArray<ReactClientValue>,
3520
+ parentPropertyName: string,
3521
+ value: ReactClientValue,
3522
+ ): ReactJSONValue {
3523
+ return renderConsoleValue(
3524
+ request,
3525
+ counter,
3526
+ this,
3527
+ parentPropertyName,
3528
+ value,
3529
+ );
3530
+ }
3531
3521
- // We can't serialize the ConsoleTask/Error objects so we need to omit them before serializing.
3522
- const relativeStartTimestamp = ioInfo.start - request.timeOrigin;
3523
- const relativeEndTimestamp = ioInfo.end - request.timeOrigin;
3532
+ const relativeStartTimestamp = start - request.timeOrigin;
3533
+ const relativeEndTimestamp = end - request.timeOrigin;
3534
const debugIOInfo: Omit<ReactIOInfo, 'debugTask' | 'debugStack'> = {
3535
start: relativeStartTimestamp,
3536
end: relativeEndTimestamp,
3527
- stack: ioInfo.stack,
3537
+ stack: stack,
3538
};
3529
- const id = outlineConsoleValue(request, counter, debugIOInfo);
3530
- request.writtenObjects.set(ioInfo, serializeByValueID(id));
3539
+ // $FlowFixMe[incompatible-type] stringify can return null
3540
+ const json: string = stringify(debugIOInfo, replacer);
3541
+ const row = id.toString(16) + ':J' + json + '\n';
3542
+ const processedChunk = stringToChunk(row);
3543
+ request.completedRegularChunks.push(processedChunk);
3544
}
3545
3533
-function emitIOChunk(request: Request, ioNode: IONode | PromiseNode): void {
3534
- if (!__DEV__) {
3535
- // These errors should never make it into a build so we don't need to encode them in codes.json
3536
- // eslint-disable-next-line react-internal/prod-error-codes
3537
- throw new Error(
3538
- 'outlineIOInfo should never be called in production mode. This is a bug in React.',
3539
- );
3546
+function outlineIOInfo(request: Request, ioInfo: ReactIOInfo): void {
3547
+ if (request.writtenObjects.has(ioInfo)) {
3548
+ // Already written
3549
+ return;
3550
}
3551
+ // We can't serialize the ConsoleTask/Error objects so we need to omit them before serializing.
3552
+ request.pendingChunks++;
3553
+ const id = request.nextChunkId++;
3554
+ emitIOInfoChunk(request, id, ioInfo.start, ioInfo.end, ioInfo.stack);
3555
+ request.writtenObjects.set(ioInfo, serializeByValueID(id));
3556
+}
3557
3542
- if (request.writtenObjects.has(ioNode)) {
3558
+function serializeIONode(
3559
+ request: Request,
3560
+ ioNode: IONode | PromiseNode,
3561
+): string {
3562
+ const existingRef = request.writtenObjects.get(ioNode);
3563
+ if (existingRef !== undefined) {
3564
// Already written
3544
- return;
3565
+ return existingRef;
3566
}
3567
3547
- // Limit the number of objects we write to prevent emitting giant props objects.
3548
- let objectLimit = 10;
3568
let stack = null;
3569
if (ioNode.stack !== null) {
3570
stack = filterStackTrace(request, ioNode.stack, 1);
3552
- // Ensure we have enough object limit to encode the stack trace.
3553
- objectLimit += stack.length;
3571
}
3555
-
3556
- // We use the console encoding so that we can dedupe objects but don't necessarily
3557
- // use the full serialization that requires a task.
3558
- const counter = {objectLimit};
3559
-
3560
- // We can't serialize the ConsoleTask/Error objects so we need to omit them before serializing.
3561
- const relativeStartTimestamp = ioNode.start - request.timeOrigin;
3562
- const relativeEndTimestamp = ioNode.end - request.timeOrigin;
3563
- const debugIOInfo: Omit<ReactIOInfo, 'debugTask' | 'debugStack'> = {
3564
- start: relativeStartTimestamp,
3565
- end: relativeEndTimestamp,
3566
- stack: stack,
3567
- };
3568
- const id = outlineConsoleValue(request, counter, debugIOInfo);
3569
- request.writtenObjects.set(ioNode, serializeByValueID(id));
3572
+ request.pendingChunks++;
3573
+ const id = request.nextChunkId++;
3574
+ emitIOInfoChunk(request, id, ioNode.start, ioNode.end, stack);
3575
+ const ref = serializeByValueID(id);
3576
+ request.writtenObjects.set(ioNode, ref);
3577
+ return ref;
3578
}
3579
3580
function emitTypedArrayChunk(