[Flight][Fiber] Encode owner in the error payload in dev and use it as the Error's Task (#34460)

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"
/>
This commit is contained in:
Sebastian Markbåge 2025-09-12 11:55:07 -04:00 committed by GitHub
parent 1a27af3607
commit 20e5431747
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 114 additions and 27 deletions

View File

@ -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;

View File

@ -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) {

View File

@ -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));
}

View File

@ -228,6 +228,7 @@ export type ReactErrorInfoDev = {
+message: string,
+stack: ReactStackTrace,
+env: string,
+owner?: null | string,
};
export type ReactErrorInfo = ReactErrorInfoProd | ReactErrorInfoDev;