Repository: facebook/react
Pull request: [Flight][Fiber] Encode owner in the error payload in dev and use it as the Error's Task

When we report an error we typically log the owner stack of the thing that caught the error. Similarly we restore the `console.createTask` scope of the catching component when we call `reportError` or `console.error`.

We also have a special case if something throws during reconciliation which uses the Server Component task as far as we got before we threw.

https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactChildFiber.js#L1952-L1960

Chrome has since fixed it (on our request) that the Error constructor snapshots the Task at the time the constructor was created and logs that in `reportError`. This is a good thing since it means we get a coherent stack. Unfortunately, it means that the fake Errors that we create in Flight Client gets a snapshot of the task where they were created so when they're reported in the console they get the root Task instead of the Task of the handler of the error.

Ideally we'd transfer the Task from the server and restore it. However, since we don't instrument the Error object to snapshot the owner and we can't read the native Task (if it's even enabled on the server) we don't actually have a correct snapshot to transfer for a Server Component Error. However, we can use the parent's task for where the error was observed by Flight Server and then encode that as a pseudo owner of the Error.

Then we use this owner as the Task which the Error is created within. Now the client snapshots that Task which is reported by `reportError` so now we have an async stack for Server Component errors again. (Note that this owner may differ from the one observed by `captureOwnerStack` which gets the nearest Server Component from where it was caught. We could attach the owner to the Error object and use that owner when calling `onCaughtError`/`onUncaughtError`).

Before:

<img width="911" height="57" alt="Screenshot 2025-09-10 at 10 57 54 AM" src="https://github.com/user-attachments/assets/0446ef96-fad9-4e17-8a9a-d89c334233ec" />

After:

<img width="910" height="128" alt="Screenshot 2025-09-10 at 11 06 20 AM" src="https://github.com/user-attachments/assets/b30e5892-cf40-4246-a588-0f309575439b" />

Similarly, there are Errors and warnings created by ChildFiber itself. Those execute in the scope of the general render of the parent Fiber. They used to get the scope of the nearest client component parent (e.g. div in this case) but that's the parent of the Server Component. It would be too expensive to run every level of reconciliation in its own task optimistically, so this does it only when we know that we'll throw or log an error that needs this context. Unfortunately this doesn't cover user space errors (such as if an iterable errors).

Before:

<img width="903" height="298" alt="Screenshot 2025-09-10 at 11 31 55 AM" src="https://github.com/user-attachments/assets/cffc94da-8c14-4d6e-9a5b-bf0833b8b762" />

After:

<img width="1216" height="252" alt="Screenshot 2025-09-10 at 11 50 54 AM" src="https://github.com/user-attachments/assets/f85f93cf-ab73-4046-af3d-dd93b73b3552" />

<img width="412" height="115" alt="Screenshot 2025-09-10 at 11 52 46 AM" src="https://github.com/user-attachments/assets/a76cef7b-b162-4ecf-9b0a-68bf34afc239" />


Changed files:
filename: packages/react-client/src/ReactFlightClient.js
patch: @@ -3181,11 +3181,27 @@ function resolveErrorDev(
         'An error occurred in the Server Components render but no message was provided',
     ),
   );
-  const rootTask = getRootTask(response, env);
-  if (rootTask != null) {
-    error = rootTask.run(callStack);
+
+  let ownerTask: null | ConsoleTask = null;
+  if (errorInfo.owner != null) {
+    const ownerRef = errorInfo.owner.slice(1);
+    // TODO: This is not resilient to the owner loading later in an Error like a debug channel.
+    // The whole error serialization should probably go through the regular model at least for DEV.
+    const owner = getOutlinedModel(response, ownerRef, {}, '', createModel);
+    if (owner !== null) {
+      ownerTask = initializeFakeTask(response, owner);
+    }
+  }
+
+  if (ownerTask === null) {
+    const rootTask = getRootTask(response, env);
+    if (rootTask != null) {
+      error = rootTask.run(callStack);
+    } else {
+      error = callStack();
+    }
   } else {
-    error = callStack();
+    error = ownerTask.run(callStack);
   }
 
   (error: any).name = name;

filename: packages/react-reconciler/src/ReactChildFiber.js
patch: @@ -13,6 +13,7 @@ import type {
   Thenable,
   ReactContext,
   ReactDebugInfo,
+  ReactComponentInfo,
   SuspenseListRevealOrder,
 } from 'shared/ReactTypes';
 import type {Fiber} from './ReactInternalTypes';
@@ -101,6 +102,25 @@ function pushDebugInfo(
   return previousDebugInfo;
 }
 
+function getCurrentDebugTask(): null | ConsoleTask {
+  // Get the debug task of the parent Server Component if there is one.
+  if (__DEV__) {
+    const debugInfo = currentDebugInfo;
+    if (debugInfo != null) {
+      for (let i = debugInfo.length - 1; i >= 0; i--) {
+        if (debugInfo[i].name != null) {
+          const componentInfo: ReactComponentInfo = debugInfo[i];
+          const debugTask: ?ConsoleTask = componentInfo.debugTask;
+          if (debugTask != null) {
+            return debugTask;
+          }
+        }
+      }
+    }
+  }
+  return null;
+}
+
 let didWarnAboutMaps;
 let didWarnAboutGenerators;
 let ownerHasKeyUseWarning;
@@ -274,7 +294,7 @@ function coerceRef(workInProgress: Fiber, element: ReactElement): void {
   workInProgress.ref = refProp !== undefined ? refProp : null;
 }
 
-function throwOnInvalidObjectType(returnFiber: Fiber, newChild: Object) {
+function throwOnInvalidObjectTypeImpl(returnFiber: Fiber, newChild: Object) {
   if (newChild.$$typeof === REACT_LEGACY_ELEMENT_TYPE) {
     throw new Error(
       'A React Element from an older version of React was rendered. ' +
@@ -299,7 +319,18 @@ function throwOnInvalidObjectType(returnFiber: Fiber, newChild: Object) {
   );
 }
 
-function warnOnFunctionType(returnFiber: Fiber, invalidChild: Function) {
+function throwOnInvalidObjectType(returnFiber: Fiber, newChild: Object) {
+  const debugTask = getCurrentDebugTask();
+  if (__DEV__ && debugTask !== null) {
+    debugTask.run(
+      throwOnInvalidObjectTypeImpl.bind(null, returnFiber, newChild),
+    );
+  } else {
+    throwOnInvalidObjectTypeImpl(returnFiber, newChild);
+  }
+}
+
+function warnOnFunctionTypeImpl(returnFiber: Fiber, invalidChild: Function) {
   if (__DEV__) {
     const parentName = getComponentNameFromFiber(returnFiber) || 'Component';
 
@@ -336,7 +367,16 @@ function warnOnFunctionType(returnFiber: Fiber, invalidChild: Function) {
   }
 }
 
-function warnOnSymbolType(returnFiber: Fiber, invalidChild: symbol) {
+function warnOnFunctionType(returnFiber: Fiber, invalidChild: Function) {
+  const debugTask = getCurrentDebugTask();
+  if (__DEV__ && debugTask !== null) {
+    debugTask.run(warnOnFunctionTypeImpl.bind(null, returnFiber, invalidChild));
+  } else {
+    warnOnFunctionTypeImpl(returnFiber, invalidChild);
+  }
+}
+
+function warnOnSymbolTypeImpl(returnFiber: Fiber, invalidChild: symbol) {
   if (__DEV__) {
     const parentName = getComponentNameFromFiber(returnFiber) || 'Component';
 
@@ -364,6 +404,15 @@ function warnOnSymbolType(returnFiber: Fiber, invalidChild: symbol) {
   }
 }
 
+function warnOnSymbolType(returnFiber: Fiber, invalidChild: symbol) {
+  const debugTask = getCurrentDebugTask();
+  if (__DEV__ && debugTask !== null) {
+    debugTask.run(warnOnSymbolTypeImpl.bind(null, returnFiber, invalidChild));
+  } else {
+    warnOnSymbolTypeImpl(returnFiber, invalidChild);
+  }
+}
+
 type ChildReconciler = (
   returnFiber: Fiber,
   currentFirstChild: Fiber | null,
@@ -1941,12 +1990,14 @@ function createChildReconciler(
       throwFiber.return = returnFiber;
       if (__DEV__) {
         const debugInfo = (throwFiber._debugInfo = currentDebugInfo);
-        // Conceptually the error's owner/task should ideally be captured when the
-        // Error constructor is called but neither console.createTask does this,
-        // nor do we override them to capture our `owner`. So instead, we use the
-        // nearest parent as the owner/task of the error. This is usually the same
-        // thing when it's thrown from the same async component but not if you await
-        // a promise started from a different component/task.
+        // Conceptually the error's owner should ideally be captured when the
+        // Error constructor is called but we don't override them to capture our
+        // `owner`. So instead, we use the nearest parent as the owner/task of the
+        // error. This is usually the same thing when it's thrown from the same
+        // async component but not if you await a promise started from a different
+        // component/task.
+        // In newer Chrome, Error constructor does capture the Task which is what
+        // is logged by reportError. In that case this debugTask isn't used.
         throwFiber._debugOwner = returnFiber._debugOwner;
         throwFiber._debugTask = returnFiber._debugTask;
         if (debugInfo != null) {

filename: packages/react-server/src/ReactFlightServer.js
patch: @@ -864,7 +864,7 @@ function serializeDebugThenable(
       const x = thenable.reason;
       // We don't log these errors since they didn't actually throw into Flight.
       const digest = '';
-      emitErrorChunk(request, id, digest, x, true);
+      emitErrorChunk(request, id, digest, x, true, null);
       return ref;
     }
   }
@@ -916,7 +916,7 @@ function serializeDebugThenable(
       }
       // We don't log these errors since they didn't actually throw into Flight.
       const digest = '';
-      emitErrorChunk(request, id, digest, reason, true);
+      emitErrorChunk(request, id, digest, reason, true, null);
       enqueueFlush(request);
     },
   );
@@ -964,7 +964,7 @@ function emitRequestedDebugThenable(
       }
       // We don't log these errors since they didn't actually throw into Flight.
       const digest = '';
-      emitErrorChunk(request, id, digest, reason, true);
+      emitErrorChunk(request, id, digest, reason, true, null);
       enqueueFlush(request);
     },
   );
@@ -2764,7 +2764,7 @@ function serializeClientReference(
     request.pendingChunks++;
     const errorId = request.nextChunkId++;
     const digest = logRecoverableError(request, x, null);
-    emitErrorChunk(request, errorId, digest, x, false);
+    emitErrorChunk(request, errorId, digest, x, false, null);
     return serializeByValueID(errorId);
   }
 }
@@ -2813,7 +2813,7 @@ function serializeDebugClientReference(
     request.pendingDebugChunks++;
     const errorId = request.nextChunkId++;
     const digest = logRecoverableError(request, x, null);
-    emitErrorChunk(request, errorId, digest, x, true);
+    emitErrorChunk(request, errorId, digest, x, true, null);
     return serializeByValueID(errorId);
   }
 }
@@ -3054,7 +3054,7 @@ function serializeDebugBlob(request: Request, blob: Blob): string {
   }
   function error(reason: mixed) {
     const digest = '';
-    emitErrorChunk(request, id, digest, reason, true);
+    emitErrorChunk(request, id, digest, reason, true, null);
     enqueueFlush(request);
     // $FlowFixMe should be able to pass mixed
     reader.cancel(reason).then(noop, noop);
@@ -3254,7 +3254,14 @@ function renderModel(
       emitPostponeChunk(request, errorId, postponeInstance);
     } else {
       const digest = logRecoverableError(request, x, task);
-      emitErrorChunk(request, errorId, digest, x, false);
+      emitErrorChunk(
+        request,
+        errorId,
+        digest,
+        x,
+        false,
+        __DEV__ ? task.debugOwner : null,
+      );
     }
     if (wasReactNode) {
       // We'll replace this element with a lazy reference that throws on the client
@@ -4072,7 +4079,8 @@ function emitErrorChunk(
   id: number,
   digest: string,
   error: mixed,
-  debug: boolean,
+  debug: boolean, // DEV-only
+  owner: ?ReactComponentInfo, // DEV-only
 ): void {
   let errorInfo: ReactErrorInfo;
   if (__DEV__) {
@@ -4104,7 +4112,9 @@ function emitErrorChunk(
       message = 'An error occurred but serializing the error message failed.';
       stack = [];
     }
-    errorInfo = {digest, name, message, stack, env};
+    const ownerRef =
+      owner == null ? null : outlineComponentInfo(request, owner);
+    errorInfo = {digest, name, message, stack, env, owner: ownerRef};
   } else {
     errorInfo = {digest};
   }
@@ -4204,7 +4214,7 @@ function emitDebugChunk(
 function outlineComponentInfo(
   request: Request,
   componentInfo: ReactComponentInfo,
-): void {
+): string {
   if (!__DEV__) {
     // These errors should never make it into a build so we don't need to encode them in codes.json
     // eslint-disable-next-line react-internal/prod-error-codes
@@ -4213,9 +4223,10 @@ function outlineComponentInfo(
     );
   }
 
-  if (request.writtenDebugObjects.has(componentInfo)) {
+  const existingRef = request.writtenDebugObjects.get(componentInfo);
+  if (existingRef !== undefined) {
     // Already written
-    return;
+    return existingRef;
   }
 
   if (componentInfo.owner != null) {
@@ -4270,6 +4281,7 @@ function outlineComponentInfo(
   request.writtenDebugObjects.set(componentInfo, ref);
   // We also store this in the main dedupe set so that it can be referenced by inline React Elements.
   request.writtenObjects.set(componentInfo, ref);
+  return ref;
 }
 
 function emitIOInfoChunk(
@@ -5465,7 +5477,14 @@ function erroredTask(request: Request, task: Task, error: mixed): void {
     emitPostponeChunk(request, task.id, postponeInstance);
   } else {
     const digest = logRecoverableError(request, error, task);
-    emitErrorChunk(request, task.id, digest, error, false);
+    emitErrorChunk(
+      request,
+      task.id,
+      digest,
+      error,
+      false,
+      __DEV__ ? task.debugOwner : null,
+    );
   }
   request.abortableTasks.delete(task);
   callOnAllReadyIfReady(request);
@@ -6040,7 +6059,7 @@ export function abort(request: Request, reason: mixed): void {
         const errorId = request.nextChunkId++;
         request.fatalError = errorId;
         request.pendingChunks++;
-        emitErrorChunk(request, errorId, digest, error, false);
+        emitErrorChunk(request, errorId, digest, error, false, null);
         abortableTasks.forEach(task => abortTask(task, request, errorId));
         scheduleWork(() => finishAbort(request, abortableTasks, errorId));
       }

filename: packages/shared/ReactTypes.js
patch: @@ -228,6 +228,7 @@ export type ReactErrorInfoDev = {
   +message: string,
   +stack: ReactStackTrace,
   +env: string,
+  +owner?: null | string,
 };
 
 export type ReactErrorInfo = ReactErrorInfoProd | ReactErrorInfoDev;