@samitouri / QOS-React / commits / 3fb17d16a4

[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(