diff --git a/fixtures/dom/src/components/fixtures/error-handling/index.js b/fixtures/dom/src/components/fixtures/error-handling/index.js index b25a56fbf78..8dcb8e1dc5e 100644 --- a/fixtures/dom/src/components/fixtures/error-handling/index.js +++ b/fixtures/dom/src/components/fixtures/error-handling/index.js @@ -7,9 +7,21 @@ const ReactDOM = window.ReactDOM; function BadRender(props) { props.doThrow(); } + +class BadDidMount extends React.Component { + componentDidMount() { + this.props.doThrow(); + } + + render() { + return null; + } +} + class ErrorBoundary extends React.Component { static defaultProps = { buttonText: 'Trigger error', + badChildType: BadRender, }; state = { shouldThrow: false, @@ -33,7 +45,8 @@ class ErrorBoundary extends React.Component { } } if (this.state.shouldThrow) { - return ; + const BadChild = this.props.badChildType; + return ; } return ; } @@ -84,6 +97,112 @@ class TriggerErrorAndCatch extends React.Component { } } +function silenceWindowError(event) { + event.preventDefault(); +} + +class SilenceErrors extends React.Component { + state = { + silenceErrors: false, + }; + componentDidMount() { + if (this.state.silenceErrors) { + window.addEventListener('error', silenceWindowError); + } + } + componentDidUpdate(prevProps, prevState) { + if (!prevState.silenceErrors && this.state.silenceErrors) { + window.addEventListener('error', silenceWindowError); + } else if (prevState.silenceErrors && !this.state.silenceErrors) { + window.removeEventListener('error', silenceWindowError); + } + } + componentWillUnmount() { + if (this.state.silenceErrors) { + window.removeEventListener('error', silenceWindowError); + } + } + render() { + return ( +
+ + {this.state.silenceErrors && ( +
+ {this.props.children} +
+
+ + Don't forget to uncheck "Silence errors" when you're done with + this test! + +
+ )} +
+ ); + } +} + +class SilenceRecoverableError extends React.Component { + render() { + return ( + + { + throw new Error('Silenced error (render phase)'); + }} + /> + { + throw new Error('Silenced error (commit phase)'); + }} + /> + + ); + } +} + +class TrySilenceFatalError extends React.Component { + container = document.createElement('div'); + + triggerErrorAndCatch = () => { + try { + ReactDOM.flushSync(() => { + ReactDOM.render( + { + throw new Error('Caught error'); + }} + />, + this.container + ); + }); + } catch (e) {} + }; + + render() { + return ( + + + + ); + } +} + export default class ErrorHandlingTestCases extends React.Component { render() { return ( @@ -103,6 +222,12 @@ export default class ErrorHandlingTestCases extends React.Component { the BadRender component. After resuming, the "Trigger error" button should be replaced with "Captured an error: Oops!" Clicking reset should reset the test case. +
+
+ In the console, you should see two messages: the actual error + ("Oops") printed natively by the browser with its JavaScript stack, + and our addendum ("The above error occurred in BadRender component") + with a React component stack. { @@ -155,10 +280,44 @@ export default class ErrorHandlingTestCases extends React.Component { Open the console. "Uncaught Error: Caught error" should have been - logged by the browser. + logged by the browser. You should also see our addendum ("The above + error..."). + + +
  • Check the "Silence errors" checkbox below
  • +
  • Click the "Throw (render phase)" button
  • +
  • Click the "Throw (commit phase)" button
  • +
  • Uncheck the "Silence errors" checkbox
  • +
    + + Open the console. You shouldn't see any messages in the + console: neither the browser error, nor our "The above error" + addendum, from either of the buttons. The buttons themselves should + get replaced by two labels: "Captured an error: Silenced error + (render phase)" and "Captured an error: Silenced error (commit + phase)". + + +
    + + +
  • Check the "Silence errors" checkbox below
  • +
  • Click the "Throw fatal error" button
  • +
  • Uncheck the "Silence errors" checkbox
  • +
    + + Open the console. "Error: Caught error" should have been logged by + React. You should also see our addendum ("The above error..."). + + +
    ); } diff --git a/fixtures/dom/src/style.css b/fixtures/dom/src/style.css index c316bb3f37e..a8e1391e1d8 100644 --- a/fixtures/dom/src/style.css +++ b/fixtures/dom/src/style.css @@ -158,7 +158,7 @@ li { } .test-case__body { - padding: 0 15px; + padding: 10px; } .test-case__desc { diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 42aa93a3568..f126796b0bf 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -112,17 +112,13 @@ export function logError(boundary: Fiber, errorInfo: CapturedValue) { try { logCapturedError(capturedError); } catch (e) { - // Prevent cycle if logCapturedError() throws. - // A cycle may still occur if logCapturedError renders a component that throws. - const suppressLogging = e && e.suppressReactErrorLogging; - if (!suppressLogging) { - // Rethrow it from a clean stack because this function is assumed to never throw. - // We can't safely call console.error() here because it could *also* throw if overridden. - // https://github.com/facebook/react/issues/13188 - setTimeout(() => { - throw e; - }); - } + // This method must not throw, or React internal state will get messed up. + // If console.error is overridden, or logCapturedError() shows a dialog that throws, + // we want to report this error outside of the normal stack as a last resort. + // https://github.com/facebook/react/issues/13188 + setTimeout(() => { + throw e; + }); } } diff --git a/packages/react-reconciler/src/ReactFiberErrorLogger.js b/packages/react-reconciler/src/ReactFiberErrorLogger.js index a5a4f756abc..81b1c7366d2 100644 --- a/packages/react-reconciler/src/ReactFiberErrorLogger.js +++ b/packages/react-reconciler/src/ReactFiberErrorLogger.js @@ -35,6 +35,25 @@ export function logCapturedError(capturedError: CapturedError): void { willRetry, } = capturedError; + // Browsers support silencing uncaught errors by calling + // `preventDefault()` in window `error` handler. + // We record this information as an expando on the error. + if (error != null && error._suppressLogging) { + if (errorBoundaryFound && willRetry) { + // The error is recoverable and was silenced. + // Ignore it and don't print the stack addendum. + // This is handy for testing error boundaries without noise. + return; + } + // The error is fatal. Since the silencing might have + // been accidental, we'll surface it anyway. + // However, the browser would have silenced the original error + // so we'll print it first, and then print the stack addendum. + console.error(error); + // For a more detailed description of this block, see: + // https://github.com/facebook/react/pull/13384 + } + const componentNameMessage = componentName ? `The above error occurred in the <${componentName}> component:` : 'The above error occurred in one of your React components:'; diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index bca85e65a6f..1231e7073f2 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -305,7 +305,19 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { isReplayingFailedUnitOfWork = false; originalReplayError = null; if (hasCaughtError()) { - clearCaughtError(); + const replayError = clearCaughtError(); + if (replayError != null && thrownValue != null) { + try { + // Reading the expando property is intentionally + // inside `try` because it might be a getter or Proxy. + if (replayError._suppressLogging) { + // Also suppress logging for the original error. + (thrownValue: any)._suppressLogging = true; + } + } catch (inner) { + // Ignore. + } + } } else { // If the begin phase did not fail the second time, set this pointer // back to the original value. diff --git a/packages/shared/invokeGuardedCallback.js b/packages/shared/invokeGuardedCallback.js index fa6ee451c91..c0cce27cc4f 100644 --- a/packages/shared/invokeGuardedCallback.js +++ b/packages/shared/invokeGuardedCallback.js @@ -132,6 +132,18 @@ if (__DEV__) { if (error === null && event.colno === 0 && event.lineno === 0) { isCrossOriginError = true; } + if (event.defaultPrevented) { + // Some other error handler has prevented default. + // Browsers silence the error report if this happens. + // We'll remember this to later decide whether to log it or not. + if (error != null && typeof error === 'object') { + try { + error._suppressLogging = true; + } catch (inner) { + // Ignore. + } + } + } } // Create a fake event type.