@samitouri / QOS-React / commits / e67b4fe22e

[Flight] Emit Partial Debug Info if we have any at the point of aborting a render (#33632)

When we abort a render we don't really have much information about the task that was aborted. Because before a Promise resolves there's no indication about would have resolved it. In particular we don't know which I/O would've ultimately called resolve(). However, we can at least emit any information we do have at the point where we emit it. At the least the stack of the top most Promise. Currently we synchronously flush at the end of an `abort()` but we should ideally schedule the flush in a macrotask and emit this debug information right before that. That way we would give an opportunity for any `cacheSignal()` abort to trigger rejections all the way up and those rejections informs the awaited stack. --------- Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>

Sebastian Markbåge committed Jun 24, 2025 at 16:36 UTC e67b4fe22e0c3c267303ee6737aec1db48055022
1 file changed +79 -2
packages/react-server/src/ReactFlightServer.js
+79 -2
@@ -75,6 +75,7 @@ import type {
75 AsyncSequence,
76 IONode,
77 PromiseNode,
78 + UnresolvedPromiseNode,
79 } from './ReactFlightAsyncSequence';
80
81 import {
@@ -734,6 +735,12 @@ function serializeDebugThenable(
735 }
736 }
737
738 + if (request.status === ABORTING) {
739 + // Ensure that we have time to emit the halt chunk if we're sync aborting.
740 + emitDebugHaltChunk(request, id);
741 + return ref;
742 + }
743 +
744 let cancelled = false;
745
746 thenable.then(
@@ -804,7 +811,7 @@ function serializeThenable(
811 ): number {
812 const newTask = createTask(
813 request,
807 - null,
814 + (thenable: any), // will be replaced by the value before we retry. used for debug info.
815 task.keyPath, // the server component sequence continues through Promise-as-a-child.
816 task.implicitSlot,
817 request.abortableTasks,
@@ -3869,7 +3876,7 @@ function outlineIOInfo(request: Request, ioInfo: ReactIOInfo): void {
3876
3877 function serializeIONode(
3878 request: Request,
3872 - ioNode: IONode | PromiseNode,
3879 + ioNode: IONode | PromiseNode | UnresolvedPromiseNode,
3880 promiseRef: null | WeakRef<Promise<mixed>>,
3881 ): string {
3882 const existingRef = request.writtenDebugObjects.get(ioNode);
@@ -4679,6 +4686,74 @@ function forwardDebugInfoFromCurrentContext(
4686 }
4687 }
4688
4689 +function forwardDebugInfoFromAbortedTask(request: Request, task: Task): void {
4690 + // If a task is aborted, we can still include as much debug info as we can from the
4691 + // value that we have so far.
4692 + const model: any = task.model;
4693 + if (typeof model !== 'object' || model === null) {
4694 + return;
4695 + }
4696 + let debugInfo: ?ReactDebugInfo;
4697 + if (__DEV__) {
4698 + // If this came from Flight, forward any debug info into this new row.
4699 + debugInfo = model._debugInfo;
4700 + if (debugInfo) {
4701 + forwardDebugInfo(request, task, debugInfo);
4702 + }
4703 + }
4704 + if (
4705 + enableProfilerTimer &&
4706 + enableComponentPerformanceTrack &&
4707 + enableAsyncDebugInfo
4708 + ) {
4709 + let thenable: null | Thenable<any> = null;
4710 + if (typeof model.then === 'function') {
4711 + thenable = (model: any);
4712 + } else if (model.$$typeof === REACT_LAZY_TYPE) {
4713 + const payload = model._payload;
4714 + const init = model._init;
4715 + try {
4716 + init(payload);
4717 + } catch (x) {
4718 + if (
4719 + typeof x === 'object' &&
4720 + x !== null &&
4721 + typeof x.then === 'function'
4722 + ) {
4723 + thenable = (x: any);
4724 + }
4725 + }
4726 + }
4727 + if (thenable !== null) {
4728 + const sequence = getAsyncSequenceFromPromise(thenable);
4729 + if (sequence !== null) {
4730 + let node = sequence;
4731 + while (node.tag === UNRESOLVED_AWAIT_NODE && node.awaited !== null) {
4732 + // See if any of the dependencies are resolved yet.
4733 + node = node.awaited;
4734 + }
4735 + if (node.tag === UNRESOLVED_PROMISE_NODE) {
4736 + // We don't know what Promise will eventually end up resolving this Promise and if it
4737 + // was I/O at all. However, we assume that it was some kind of I/O since it didn't
4738 + // complete in time before aborting.
4739 + // The best we can do is try to emit the stack of where this Promise was created.
4740 + serializeIONode(request, node, null);
4741 + request.pendingChunks++;
4742 + const env = (0, request.environmentName)();
4743 + const asyncInfo: ReactAsyncInfo = {
4744 + awaited: ((node: any): ReactIOInfo), // This is deduped by this reference.
4745 + env: env,
4746 + };
4747 + emitDebugChunk(request, task.id, asyncInfo);
4748 + markOperationEndTime(request, task, performance.now());
4749 + } else {
4750 + emitAsyncSequence(request, task, sequence, debugInfo, null, null);
4751 + }
4752 + }
4753 + }
4754 + }
4755 +}
4756 +
4757 function emitTimingChunk(
4758 request: Request,
4759 id: number,
@@ -5028,6 +5103,7 @@ function abortTask(task: Task, request: Request, errorId: number): void {
5103 return;
5104 }
5105 task.status = ABORTED;
5106 + forwardDebugInfoFromAbortedTask(request, task);
5107 // Track when we aborted this task as its end time.
5108 if (enableProfilerTimer && enableComponentPerformanceTrack) {
5109 if (task.timed) {
@@ -5047,6 +5123,7 @@ function haltTask(task: Task, request: Request): void {
5123 return;
5124 }
5125 task.status = ABORTED;
5126 + forwardDebugInfoFromAbortedTask(request, task);
5127 // We don't actually emit anything for this task id because we are intentionally
5128 // leaving the reference unfulfilled.
5129 request.pendingChunks--;