Set the current fiber to the source of the error during error reporting (#29044)

This lets us expose the component stack to the error reporting that
happens here as `console.error` patching. Now if you just call
`console.error` in the error handlers it'll get the component stack
added to the end by React DevTools.

However, unfortunately this happens a little too late so the Fiber will
be disconnected with its `.return` pointer set to null already. So it'll
be too late to extract a parent component stack from but you can at
least get the stack from source to error boundary. To work around this I
manually add the parent component stack in our default handlers when
owner stacks are off. We could potentially fix this but you can also
just include it yourself if you're calling `console.error` and it's not
a problem for owner stacks.

This is not a problem for owner stacks because we'll still have those
and so for those just calling `console.error` just works. However, the
main feature is that by letting React add them, we can switch to using
native error stacks when available.
This commit is contained in:
Sebastian Markbåge 2024-05-23 12:39:52 -04:00 committed by GitHub
parent 2e3e6a9b1c
commit 2e540e22b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 147 additions and 86 deletions

View File

@ -2586,16 +2586,14 @@ describe('TreeListContext', () => {
utils.act(() => TestRenderer.create(<Contexts />));
expect(store).toMatchInlineSnapshot(`
1, 0
[root]
<ErrorBoundary>
<ErrorBoundary>
`);
selectNextErrorOrWarning();
expect(state).toMatchInlineSnapshot(`
1, 0
[root]
<ErrorBoundary>
<ErrorBoundary>
`);
utils.act(() => unmount());
@ -2648,16 +2646,14 @@ describe('TreeListContext', () => {
utils.act(() => TestRenderer.create(<Contexts />));
expect(store).toMatchInlineSnapshot(`
1, 0
[root]
<ErrorBoundary>
<ErrorBoundary>
`);
selectNextErrorOrWarning();
expect(state).toMatchInlineSnapshot(`
1, 0
[root]
<ErrorBoundary>
<ErrorBoundary>
`);
utils.act(() => unmount());
@ -2705,18 +2701,18 @@ describe('TreeListContext', () => {
utils.act(() => TestRenderer.create(<Contexts />));
expect(store).toMatchInlineSnapshot(`
2, 0
1, 0
[root]
<ErrorBoundary>
<ErrorBoundary>
<Child>
`);
selectNextErrorOrWarning();
expect(state).toMatchInlineSnapshot(`
2, 0
1, 0
[root]
<ErrorBoundary>
<Child>
<ErrorBoundary>
<Child>
`);
});
});

View File

@ -142,8 +142,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
// Addendum by React:
expect.stringContaining('%s'),
expect.stringContaining('An error occurred in the <Foo> component'),
expect.stringContaining('Foo'),
expect.stringContaining('Consider adding an error boundary'),
expect.stringContaining('Foo'),
],
]);
} else {
@ -207,8 +207,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
expect.stringContaining(
'The above error occurred in the <Foo> component',
),
expect.stringContaining('Foo'),
expect.stringContaining('ErrorBoundary'),
expect.stringContaining('Foo'),
],
]);
} else {
@ -273,8 +273,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
// Addendum by React:
expect.stringContaining('%s'),
expect.stringContaining('An error occurred in the <Foo> component'),
expect.stringContaining('Foo'),
expect.stringContaining('Consider adding an error boundary'),
expect.stringContaining('Foo'),
],
]);
} else {
@ -343,8 +343,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
expect.stringContaining(
'The above error occurred in the <Foo> component',
),
expect.stringContaining('Foo'),
expect.stringContaining('ErrorBoundary'),
expect.stringContaining('Foo'),
],
]);
} else {
@ -409,8 +409,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
// Addendum by React:
expect.stringContaining('%s'),
expect.stringContaining('An error occurred in the <Foo> component'),
expect.stringContaining('Foo'),
expect.stringContaining('Consider adding an error boundary'),
expect.stringContaining('Foo'),
],
]);
} else {
@ -477,8 +477,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
expect.stringContaining(
'The above error occurred in the <Foo> component',
),
expect.stringContaining('Foo'),
expect.stringContaining('ErrorBoundary'),
expect.stringContaining('Foo'),
],
]);
} else {

View File

@ -161,8 +161,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
expect.stringContaining('%s'),
// Addendum by React:
expect.stringContaining('An error occurred in the <Foo> component'),
expect.stringContaining('Foo'),
expect.stringContaining('Consider adding an error boundary'),
expect.stringContaining('Foo'),
],
]);
@ -238,8 +238,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
expect.stringContaining(
'The above error occurred in the <Foo> component',
),
expect.stringContaining('Foo'),
expect.stringContaining('ErrorBoundary'),
expect.stringContaining('Foo'),
],
]);
} else {
@ -307,11 +307,9 @@ describe('ReactDOMConsoleErrorReporting', () => {
expect.stringContaining('%s'),
// Addendum by React:
expect.stringContaining(
'An error occurred in the <Foo> component:',
),
expect.stringContaining('Foo'),
expect.stringContaining('An error occurred in the <Foo> component'),
expect.stringContaining('Consider adding an error boundary'),
expect.stringContaining('Foo'),
],
]);
@ -391,8 +389,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
expect.stringContaining(
'The above error occurred in the <Foo> component',
),
expect.stringContaining('Foo'),
expect.stringContaining('ErrorBoundary'),
expect.stringContaining('Foo'),
],
]);
} else {
@ -461,8 +459,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
// Addendum by React:
expect.stringContaining('An error occurred in the <Foo> component'),
expect.stringContaining('Foo'),
expect.stringContaining('Consider adding an error boundary'),
expect.stringContaining('Foo'),
],
]);
@ -541,8 +539,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
expect.stringContaining(
'The above error occurred in the <Foo> component',
),
expect.stringContaining('Foo'),
expect.stringContaining('ErrorBoundary'),
expect.stringContaining('Foo'),
],
]);
} else {

View File

@ -727,7 +727,7 @@ describe('ReactErrorBoundaries', () => {
if (__DEV__) {
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error.mock.calls[0][2]).toContain(
'The above error occurred in the <BrokenRender> component:',
'The above error occurred in the <BrokenRender> component',
);
}

View File

@ -705,7 +705,7 @@ describe('ReactLegacyErrorBoundaries', () => {
'ReactDOM.render has not been supported since React 18',
);
expect(console.error.mock.calls[1][2]).toContain(
'The above error occurred in the <BrokenRender> component:',
'The above error occurred in the <BrokenRender> component',
);
}

View File

@ -18,6 +18,8 @@ import reportGlobalError from 'shared/reportGlobalError';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import {enableOwnerStacks} from 'shared/ReactFeatureFlags';
// Side-channel since I'm not sure we want to make this part of the public API
let componentName: null | string = null;
let errorBoundaryName: null | string = null;
@ -34,20 +36,36 @@ export function defaultOnUncaughtError(
// So we add those into a separate console.warn.
reportGlobalError(error);
if (__DEV__) {
const componentStack =
errorInfo.componentStack != null ? errorInfo.componentStack : '';
const componentNameMessage = componentName
? `An error occurred in the <${componentName}> component:`
: 'An error occurred in one of your React components:';
? `An error occurred in the <${componentName}> component.`
: 'An error occurred in one of your React components.';
console['warn'](
'%s\n%s\n\n%s',
componentNameMessage,
componentStack || '',
const errorBoundaryMessage =
'Consider adding an error boundary to your tree to customize error handling behavior.\n' +
'Visit https://react.dev/link/error-boundaries to learn more about error boundaries.',
);
'Visit https://react.dev/link/error-boundaries to learn more about error boundaries.';
if (enableOwnerStacks) {
console.warn(
'%s\n\n%s\n',
componentNameMessage,
errorBoundaryMessage,
// We let our console.error wrapper add the component stack to the end.
);
} else {
// The current Fiber is disconnected at this point which means that console printing
// cannot add a component stack since it terminates at the deletion node. This is not
// a problem for owner stacks which are not disconnected but for the parent component
// stacks we need to use the snapshot we've previously extracted.
const componentStack =
errorInfo.componentStack != null ? errorInfo.componentStack : '';
// Don't transform to our wrapper
console['warn'](
'%s\n\n%s\n%s',
componentNameMessage,
errorBoundaryMessage,
componentStack,
);
}
}
}
@ -63,31 +81,47 @@ export function defaultOnCaughtError(
// Caught by error boundary
if (__DEV__) {
const componentStack =
errorInfo.componentStack != null ? errorInfo.componentStack : '';
const componentNameMessage = componentName
? `The above error occurred in the <${componentName}> component:`
: 'The above error occurred in one of your React components:';
? `The above error occurred in the <${componentName}> component.`
: 'The above error occurred in one of your React components.';
// In development, we provide our own message which includes the component stack
// in addition to the error.
// Don't transform to our wrapper
console['error'](
'%o\n\n%s\n%s\n\n%s',
error,
componentNameMessage,
componentStack,
const recreateMessage =
`React will try to recreate this component tree from scratch ` +
`using the error boundary you provided, ${
errorBoundaryName || 'Anonymous'
}.`,
);
`using the error boundary you provided, ${
errorBoundaryName || 'Anonymous'
}.`;
if (enableOwnerStacks) {
console.error(
'%o\n\n%s\n\n%s\n',
error,
componentNameMessage,
recreateMessage,
// We let our consoleWithStackDev wrapper add the component stack to the end.
);
} else {
// The current Fiber is disconnected at this point which means that console printing
// cannot add a component stack since it terminates at the deletion node. This is not
// a problem for owner stacks which are not disconnected but for the parent component
// stacks we need to use the snapshot we've previously extracted.
const componentStack =
errorInfo.componentStack != null ? errorInfo.componentStack : '';
// Don't transform to our wrapper
console['error'](
'%o\n\n%s\n\n%s\n%s',
error,
componentNameMessage,
recreateMessage,
componentStack,
);
}
} else {
// In production, we print the error directly.
// This will include the message, the JS stack, and anything the browser wants to show.
// We pass the error object instead of custom message so that the browser displays the error natively.
console['error'](error); // Don't transform to our wrapper
console['error'](error); // Don't transform to our wrapper, however, React DevTools can still add a stack.
}
}

View File

@ -87,6 +87,10 @@ import {
import {ConcurrentRoot} from './ReactRootTags';
import {noopSuspenseyCommitThenable} from './ReactFiberThenable';
import {REACT_POSTPONE_TYPE} from 'shared/ReactSymbols';
import {
setCurrentDebugFiberInDEV,
getCurrentFiber as getCurrentDebugFiberInDEV,
} from './ReactCurrentFiber';
function createRootErrorUpdate(
root: FiberRoot,
@ -100,7 +104,10 @@ function createRootErrorUpdate(
// being called "element".
update.payload = {element: null};
update.callback = () => {
const prevFiber = getCurrentDebugFiberInDEV(); // should just be the root
setCurrentDebugFiberInDEV(errorInfo.source);
logUncaughtError(root, errorInfo);
setCurrentDebugFiberInDEV(prevFiber);
};
return update;
}
@ -127,7 +134,10 @@ function initializeClassErrorUpdate(
if (__DEV__) {
markFailedErrorBoundaryForHotReloading(fiber);
}
const prevFiber = getCurrentDebugFiberInDEV(); // should be the error boundary
setCurrentDebugFiberInDEV(errorInfo.source);
logCaughtError(root, fiber, errorInfo);
setCurrentDebugFiberInDEV(prevFiber);
};
}
@ -138,7 +148,10 @@ function initializeClassErrorUpdate(
if (__DEV__) {
markFailedErrorBoundaryForHotReloading(fiber);
}
const prevFiber = getCurrentDebugFiberInDEV(); // should be the error boundary
setCurrentDebugFiberInDEV(errorInfo.source);
logCaughtError(root, fiber, errorInfo);
setCurrentDebugFiberInDEV(prevFiber);
if (typeof getDerivedStateFromError !== 'function') {
// To preserve the preexisting retry behavior of error boundaries,
// we keep track of which ones already failed during this batch.

View File

@ -3038,7 +3038,9 @@ function commitRootImpl(
for (let i = 0; i < recoverableErrors.length; i++) {
const recoverableError = recoverableErrors[i];
const errorInfo = makeErrorInfo(recoverableError.stack);
setCurrentDebugFiberInDEV(recoverableError.source);
onRecoverableError(recoverableError.value, errorInfo);
resetCurrentDebugFiberInDEV();
}
}

View File

@ -1512,7 +1512,7 @@ describe('ReactIncrementalErrorHandling', () => {
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error.mock.calls[0][1]).toBe(notAnError);
expect(console.error.mock.calls[0][2]).toContain(
'The above error occurred in the <BadRender> component:',
'The above error occurred in the <BadRender> component',
);
} else {
expect(console.error).toHaveBeenCalledTimes(1);

View File

@ -85,7 +85,11 @@ describe('ReactIncrementalErrorLogging', () => {
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('%s'),
expect.stringContaining(
'An error occurred in the <ErrorThrowingComponent> component:',
'An error occurred in the <ErrorThrowingComponent> component.',
),
expect.stringContaining(
'Consider adding an error boundary to your tree ' +
'to customize error handling behavior.',
),
expect.stringMatching(
new RegExp(
@ -94,10 +98,6 @@ describe('ReactIncrementalErrorLogging', () => {
'\\s+(in|at) div(.*)',
),
),
expect.stringContaining(
'Consider adding an error boundary to your tree ' +
'to customize error handling behavior.',
),
);
}
});
@ -131,7 +131,11 @@ describe('ReactIncrementalErrorLogging', () => {
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('%s'),
expect.stringContaining(
'An error occurred in the <ErrorThrowingComponent> component:',
'An error occurred in the <ErrorThrowingComponent> component.',
),
expect.stringContaining(
'Consider adding an error boundary to your tree ' +
'to customize error handling behavior.',
),
expect.stringMatching(
new RegExp(
@ -140,10 +144,6 @@ describe('ReactIncrementalErrorLogging', () => {
'\\s+(in|at) div(.*)',
),
),
expect.stringContaining(
'Consider adding an error boundary to your tree ' +
'to customize error handling behavior.',
),
);
}
});
@ -189,7 +189,11 @@ describe('ReactIncrementalErrorLogging', () => {
message: 'render error',
}),
expect.stringContaining(
'The above error occurred in the <ErrorThrowingComponent> component:',
'The above error occurred in the <ErrorThrowingComponent> component.',
),
expect.stringContaining(
'React will try to recreate this component tree from scratch ' +
'using the error boundary you provided, ErrorBoundary.',
),
expect.stringMatching(
new RegExp(
@ -199,10 +203,6 @@ describe('ReactIncrementalErrorLogging', () => {
'\\s+(in|at) div(.*)',
),
),
expect.stringContaining(
'React will try to recreate this component tree from scratch ' +
'using the error boundary you provided, ErrorBoundary.',
),
);
} else {
expect(logCapturedErrorCalls[0]).toEqual(
@ -270,17 +270,21 @@ describe('ReactIncrementalErrorLogging', () => {
message: 'oops',
}),
expect.stringContaining(
'The above error occurred in the <Foo> component:',
),
expect.stringMatching(
new RegExp(
'\\s+(in|at) Foo (.*)\n' + '\\s+(in|at) ErrorBoundary(.*)',
),
'The above error occurred in the <Foo> component.',
),
expect.stringContaining(
'React will try to recreate this component tree from scratch ' +
'using the error boundary you provided, ErrorBoundary.',
),
expect.stringMatching(
gate(flag => flag.enableOwnerStacks)
? // With owner stacks the return path is cut off but in this case
// this is also what the owner stack looks like.
new RegExp('\\s+(in|at) Foo (.*)')
: new RegExp(
'\\s+(in|at) Foo (.*)\n' + '\\s+(in|at) ErrorBoundary(.*)',
),
),
);
} else {
expect(console.error).toHaveBeenCalledWith(

View File

@ -40,19 +40,30 @@ function printWarning(level, format, args) {
// When changing this logic, you might want to also
// update consoleWithStackDev.www.js as well.
if (__DEV__) {
const isErrorLogger =
format === '%s\n\n%s\n' || format === '%o\n\n%s\n\n%s\n';
const stack = ReactSharedInternals.getStackAddendum();
if (stack !== '') {
format += '%s';
args = args.concat([stack]);
}
// eslint-disable-next-line react-internal/safe-string-coercion
const argsWithFormat = args.map(item => String(item));
// Careful: RN currently depends on this prefix
argsWithFormat.unshift('Warning: ' + format);
if (isErrorLogger) {
// Don't prefix our default logging formatting in ReactFiberErrorLoggger.
// Don't toString the arguments.
args.unshift(format);
} else {
// TODO: Remove this prefix and stop toStringing in the wrapper and
// instead do it at each callsite as needed.
// Careful: RN currently depends on this prefix
// eslint-disable-next-line react-internal/safe-string-coercion
args = args.map(item => String(item));
args.unshift('Warning: ' + format);
}
// We intentionally don't use spread (or .apply) directly because it
// breaks IE9: https://github.com/facebook/react/issues/13610
// eslint-disable-next-line react-internal/no-production-logging
Function.prototype.apply.call(console[level], console, argsWithFormat);
Function.prototype.apply.call(console[level], console, args);
}
}

View File

@ -73,7 +73,10 @@ module.exports = {
);
return;
}
if (format.length < 10 || /^[s\W]*$/.test(format)) {
if (
(format.length < 10 || /^[s\W]*$/.test(format)) &&
format !== '%s\n\n%s\n'
) {
context.report(
node,
'The {{name}} format should be able to uniquely identify this ' +
@ -83,7 +86,7 @@ module.exports = {
return;
}
// count the number of formatting substitutions, plus the first two args
const expectedNArgs = (format.match(/%s/g) || []).length + 1;
const expectedNArgs = (format.match(/%[so]/g) || []).length + 1;
if (node.arguments.length !== expectedNArgs) {
context.report(
node,