diff --git a/packages/react-dom/src/client/ReactDOMFB.js b/packages/react-dom/src/client/ReactDOMFB.js index 8b846cf4a91..f215da5530e 100644 --- a/packages/react-dom/src/client/ReactDOMFB.js +++ b/packages/react-dom/src/client/ReactDOMFB.js @@ -9,7 +9,6 @@ import * as ReactFiberTreeReflection from 'react-reconciler/reflection'; import * as ReactInstanceMap from 'shared/ReactInstanceMap'; -import ReactErrorUtils from 'shared/ReactErrorUtils'; import {addUserTimingListener} from 'shared/ReactFeatureFlags'; import ReactDOM from './ReactDOM'; @@ -22,7 +21,6 @@ Object.assign( { // These are real internal dependencies that are trickier to remove: ReactBrowserEventEmitter, - ReactErrorUtils, ReactFiberTreeReflection, ReactDOMComponentTree, ReactInstanceMap, diff --git a/packages/shared/ReactErrorUtils.js b/packages/shared/ReactErrorUtils.js index cbbfc81652b..5e13a7b9747 100644 --- a/packages/shared/ReactErrorUtils.js +++ b/packages/shared/ReactErrorUtils.js @@ -8,6 +8,7 @@ */ import invariant from 'fbjs/lib/invariant'; +import invokeGuardedCallback from './invokeGuardedCallback'; const ReactErrorUtils = { // Used by Fiber to simulate a try-catch. @@ -18,16 +19,6 @@ const ReactErrorUtils = { _rethrowError: (null: mixed), _hasRethrowError: (false: boolean), - injection: { - injectErrorUtils(injectedErrorUtils: Object) { - invariant( - typeof injectedErrorUtils.invokeGuardedCallback === 'function', - 'Injected invokeGuardedCallback() must be a function.', - ); - invokeGuardedCallback = injectedErrorUtils.invokeGuardedCallback; - }, - }, - /** * Call a function while guarding against errors that happens within it. * Returns an error if it throws, otherwise null. @@ -114,168 +105,6 @@ const ReactErrorUtils = { }, }; -let invokeGuardedCallback = function(name, func, context, a, b, c, d, e, f) { - ReactErrorUtils._hasCaughtError = false; - ReactErrorUtils._caughtError = null; - const funcArgs = Array.prototype.slice.call(arguments, 3); - try { - func.apply(context, funcArgs); - } catch (error) { - ReactErrorUtils._caughtError = error; - ReactErrorUtils._hasCaughtError = true; - } -}; - -if (__DEV__) { - // In DEV mode, we swap out invokeGuardedCallback for a special version - // that plays more nicely with the browser's DevTools. The idea is to preserve - // "Pause on exceptions" behavior. Because React wraps all user-provided - // functions in invokeGuardedCallback, and the production version of - // invokeGuardedCallback uses a try-catch, all user exceptions are treated - // like caught exceptions, and the DevTools won't pause unless the developer - // takes the extra step of enabling pause on caught exceptions. This is - // untintuitive, though, because even though React has caught the error, from - // the developer's perspective, the error is uncaught. - // - // To preserve the expected "Pause on exceptions" behavior, we don't use a - // try-catch in DEV. Instead, we synchronously dispatch a fake event to a fake - // DOM node, and call the user-provided callback from inside an event handler - // for that fake event. If the callback throws, the error is "captured" using - // a global event handler. But because the error happens in a different - // event loop context, it does not interrupt the normal program flow. - // Effectively, this gives us try-catch behavior without actually using - // try-catch. Neat! - - // Check that the browser supports the APIs we need to implement our special - // DEV version of invokeGuardedCallback - if ( - typeof window !== 'undefined' && - typeof window.dispatchEvent === 'function' && - typeof document !== 'undefined' && - typeof document.createEvent === 'function' - ) { - const fakeNode = document.createElement('react'); - - const invokeGuardedCallbackDev = function( - name, - func, - context, - a, - b, - c, - d, - e, - f, - ) { - // If document doesn't exist we know for sure we will crash in this method - // when we call document.createEvent(). However this can cause confusing - // errors: https://github.com/facebookincubator/create-react-app/issues/3482 - // So we preemptively throw with a better message instead. - invariant( - typeof document !== 'undefined', - 'The `document` global was defined when React was initialized, but is not ' + - 'defined anymore. This can happen in a test environment if a component ' + - 'schedules an update from an asynchronous callback, but the test has already ' + - 'finished running. To solve this, you can either unmount the component at ' + - 'the end of your test (and ensure that any asynchronous operations get ' + - 'canceled in `componentWillUnmount`), or you can change the test itself ' + - 'to be asynchronous.', - ); - const evt = document.createEvent('Event'); - - // Keeps track of whether the user-provided callback threw an error. We - // set this to true at the beginning, then set it to false right after - // calling the function. If the function errors, `didError` will never be - // set to false. This strategy works even if the browser is flaky and - // fails to call our global error handler, because it doesn't rely on - // the error event at all. - let didError = true; - - // Create an event handler for our fake event. We will synchronously - // dispatch our fake event using `dispatchEvent`. Inside the handler, we - // call the user-provided callback. - const funcArgs = Array.prototype.slice.call(arguments, 3); - function callCallback() { - // We immediately remove the callback from event listeners so that - // nested `invokeGuardedCallback` calls do not clash. Otherwise, a - // nested call would trigger the fake event handlers of any call higher - // in the stack. - fakeNode.removeEventListener(evtType, callCallback, false); - func.apply(context, funcArgs); - didError = false; - } - - // Create a global error event handler. We use this to capture the value - // that was thrown. It's possible that this error handler will fire more - // than once; for example, if non-React code also calls `dispatchEvent` - // and a handler for that event throws. We should be resilient to most of - // those cases. Even if our error event handler fires more than once, the - // last error event is always used. If the callback actually does error, - // we know that the last error event is the correct one, because it's not - // possible for anything else to have happened in between our callback - // erroring and the code that follows the `dispatchEvent` call below. If - // the callback doesn't error, but the error event was fired, we know to - // ignore it because `didError` will be false, as described above. - let error; - // Use this to track whether the error event is ever called. - let didSetError = false; - let isCrossOriginError = false; - - function onError(event) { - error = event.error; - didSetError = true; - if (error === null && event.colno === 0 && event.lineno === 0) { - isCrossOriginError = true; - } - } - - // Create a fake event type. - const evtType = `react-${name ? name : 'invokeguardedcallback'}`; - - // Attach our event handlers - window.addEventListener('error', onError); - fakeNode.addEventListener(evtType, callCallback, false); - - // Synchronously dispatch our fake event. If the user-provided function - // errors, it will trigger our global error handler. - evt.initEvent(evtType, false, false); - fakeNode.dispatchEvent(evt); - - if (didError) { - if (!didSetError) { - // The callback errored, but the error event never fired. - error = new Error( - 'An error was thrown inside one of your components, but React ' + - "doesn't know what it was. This is likely due to browser " + - 'flakiness. React does its best to preserve the "Pause on ' + - 'exceptions" behavior of the DevTools, which requires some ' + - "DEV-mode only tricks. It's possible that these don't work in " + - 'your browser. Try triggering the error in production mode, ' + - 'or switching to a modern browser. If you suspect that this is ' + - 'actually an issue with React, please file an issue.', - ); - } else if (isCrossOriginError) { - error = new Error( - "A cross-origin error was thrown. React doesn't have access to " + - 'the actual error object in development. ' + - 'See https://fb.me/react-crossorigin-error for more information.', - ); - } - ReactErrorUtils._hasCaughtError = true; - ReactErrorUtils._caughtError = error; - } else { - ReactErrorUtils._hasCaughtError = false; - ReactErrorUtils._caughtError = null; - } - - // Remove our event listeners - window.removeEventListener('error', onError); - }; - - invokeGuardedCallback = invokeGuardedCallbackDev; - } -} - let rethrowCaughtError = function() { if (ReactErrorUtils._hasRethrowError) { const error = ReactErrorUtils._rethrowError; diff --git a/packages/shared/__tests__/ReactErrorUtils-test.internal.js b/packages/shared/__tests__/ReactErrorUtils-test.internal.js index 30cb6d824aa..9a064e4312a 100644 --- a/packages/shared/__tests__/ReactErrorUtils-test.internal.js +++ b/packages/shared/__tests__/ReactErrorUtils-test.internal.js @@ -176,30 +176,37 @@ describe('ReactErrorUtils', () => { it(`can be shimmed`, () => { const ops = []; - // Override the original invokeGuardedCallback - ReactErrorUtils.injection.injectErrorUtils({ - invokeGuardedCallback(name, func, context, a) { - ops.push(a); - try { - func.call(context, a); - } catch (error) { - this._hasCaughtError = true; - this._caughtError = error; - } - }, - }); - - var err = new Error('foo'); - var callback = function() { - throw err; - }; - ReactErrorUtils.invokeGuardedCallbackAndCatchFirstError( - 'foo', - callback, - null, - 'somearg', + jest.resetModules(); + jest.mock( + 'shared/invokeGuardedCallback', + () => + function invokeGuardedCallback(name, func, context, a) { + ops.push(a); + try { + func.call(context, a); + } catch (error) { + this._hasCaughtError = true; + this._caughtError = error; + } + }, ); - expect(() => ReactErrorUtils.rethrowCaughtError()).toThrow(err); - expect(ops).toEqual(['somearg']); + ReactErrorUtils = require('shared/ReactErrorUtils').default; + + try { + var err = new Error('foo'); + var callback = function() { + throw err; + }; + ReactErrorUtils.invokeGuardedCallbackAndCatchFirstError( + 'foo', + callback, + null, + 'somearg', + ); + expect(() => ReactErrorUtils.rethrowCaughtError()).toThrow(err); + expect(ops).toEqual(['somearg']); + } finally { + jest.unmock('shared/invokeGuardedCallback'); + } }); }); diff --git a/packages/shared/forks/invokeGuardedCallback.www.js b/packages/shared/forks/invokeGuardedCallback.www.js new file mode 100644 index 00000000000..3cbe0d6536c --- /dev/null +++ b/packages/shared/forks/invokeGuardedCallback.www.js @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import invariant from 'fbjs/lib/invariant'; + +const invokeGuardedCallback = require('ReactFbErrorUtils') + .invokeGuardedCallback; +invariant( + typeof invokeGuardedCallback === 'function', + 'Expected ReactFbErrorUtils.invokeGuardedCallback to be a function.', +); + +export default invokeGuardedCallback; diff --git a/packages/shared/invokeGuardedCallback.js b/packages/shared/invokeGuardedCallback.js new file mode 100644 index 00000000000..0abe767fdf0 --- /dev/null +++ b/packages/shared/invokeGuardedCallback.js @@ -0,0 +1,184 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import invariant from 'fbjs/lib/invariant'; + +let invokeGuardedCallback = function( + name: string | null, + func: (a: A, b: B, c: C, d: D, e: E, f: F) => void, + context: Context, + a: A, + b: B, + c: C, + d: D, + e: E, + f: F, +) { + this._hasCaughtError = false; + this._caughtError = null; + const funcArgs = Array.prototype.slice.call(arguments, 3); + try { + func.apply(context, funcArgs); + } catch (error) { + this._caughtError = error; + this._hasCaughtError = true; + } +}; + +if (__DEV__) { + // In DEV mode, we swap out invokeGuardedCallback for a special version + // that plays more nicely with the browser's DevTools. The idea is to preserve + // "Pause on exceptions" behavior. Because React wraps all user-provided + // functions in invokeGuardedCallback, and the production version of + // invokeGuardedCallback uses a try-catch, all user exceptions are treated + // like caught exceptions, and the DevTools won't pause unless the developer + // takes the extra step of enabling pause on caught exceptions. This is + // untintuitive, though, because even though React has caught the error, from + // the developer's perspective, the error is uncaught. + // + // To preserve the expected "Pause on exceptions" behavior, we don't use a + // try-catch in DEV. Instead, we synchronously dispatch a fake event to a fake + // DOM node, and call the user-provided callback from inside an event handler + // for that fake event. If the callback throws, the error is "captured" using + // a global event handler. But because the error happens in a different + // event loop context, it does not interrupt the normal program flow. + // Effectively, this gives us try-catch behavior without actually using + // try-catch. Neat! + + // Check that the browser supports the APIs we need to implement our special + // DEV version of invokeGuardedCallback + if ( + typeof window !== 'undefined' && + typeof window.dispatchEvent === 'function' && + typeof document !== 'undefined' && + typeof document.createEvent === 'function' + ) { + const fakeNode = document.createElement('react'); + + const invokeGuardedCallbackDev = function( + name: string | null, + func: (a: A, b: B, c: C, d: D, e: E, f: F) => void, + context: Context, + a: A, + b: B, + c: C, + d: D, + e: E, + f: F, + ) { + // If document doesn't exist we know for sure we will crash in this method + // when we call document.createEvent(). However this can cause confusing + // errors: https://github.com/facebookincubator/create-react-app/issues/3482 + // So we preemptively throw with a better message instead. + invariant( + typeof document !== 'undefined', + 'The `document` global was defined when React was initialized, but is not ' + + 'defined anymore. This can happen in a test environment if a component ' + + 'schedules an update from an asynchronous callback, but the test has already ' + + 'finished running. To solve this, you can either unmount the component at ' + + 'the end of your test (and ensure that any asynchronous operations get ' + + 'canceled in `componentWillUnmount`), or you can change the test itself ' + + 'to be asynchronous.', + ); + const evt = document.createEvent('Event'); + + // Keeps track of whether the user-provided callback threw an error. We + // set this to true at the beginning, then set it to false right after + // calling the function. If the function errors, `didError` will never be + // set to false. This strategy works even if the browser is flaky and + // fails to call our global error handler, because it doesn't rely on + // the error event at all. + let didError = true; + + // Create an event handler for our fake event. We will synchronously + // dispatch our fake event using `dispatchEvent`. Inside the handler, we + // call the user-provided callback. + const funcArgs = Array.prototype.slice.call(arguments, 3); + function callCallback() { + // We immediately remove the callback from event listeners so that + // nested `invokeGuardedCallback` calls do not clash. Otherwise, a + // nested call would trigger the fake event handlers of any call higher + // in the stack. + fakeNode.removeEventListener(evtType, callCallback, false); + func.apply(context, funcArgs); + didError = false; + } + + // Create a global error event handler. We use this to capture the value + // that was thrown. It's possible that this error handler will fire more + // than once; for example, if non-React code also calls `dispatchEvent` + // and a handler for that event throws. We should be resilient to most of + // those cases. Even if our error event handler fires more than once, the + // last error event is always used. If the callback actually does error, + // we know that the last error event is the correct one, because it's not + // possible for anything else to have happened in between our callback + // erroring and the code that follows the `dispatchEvent` call below. If + // the callback doesn't error, but the error event was fired, we know to + // ignore it because `didError` will be false, as described above. + let error; + // Use this to track whether the error event is ever called. + let didSetError = false; + let isCrossOriginError = false; + + function onError(event) { + error = event.error; + didSetError = true; + if (error === null && event.colno === 0 && event.lineno === 0) { + isCrossOriginError = true; + } + } + + // Create a fake event type. + const evtType = `react-${name ? name : 'invokeguardedcallback'}`; + + // Attach our event handlers + window.addEventListener('error', onError); + fakeNode.addEventListener(evtType, callCallback, false); + + // Synchronously dispatch our fake event. If the user-provided function + // errors, it will trigger our global error handler. + evt.initEvent(evtType, false, false); + fakeNode.dispatchEvent(evt); + + if (didError) { + if (!didSetError) { + // The callback errored, but the error event never fired. + error = new Error( + 'An error was thrown inside one of your components, but React ' + + "doesn't know what it was. This is likely due to browser " + + 'flakiness. React does its best to preserve the "Pause on ' + + 'exceptions" behavior of the DevTools, which requires some ' + + "DEV-mode only tricks. It's possible that these don't work in " + + 'your browser. Try triggering the error in production mode, ' + + 'or switching to a modern browser. If you suspect that this is ' + + 'actually an issue with React, please file an issue.', + ); + } else if (isCrossOriginError) { + error = new Error( + "A cross-origin error was thrown. React doesn't have access to " + + 'the actual error object in development. ' + + 'See https://fb.me/react-crossorigin-error for more information.', + ); + } + this._hasCaughtError = true; + this._caughtError = error; + } else { + this._hasCaughtError = false; + this._caughtError = null; + } + + // Remove our event listeners + window.removeEventListener('error', onError); + }; + + invokeGuardedCallback = invokeGuardedCallbackDev; + } +} + +export default invokeGuardedCallback; diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js index 920b6a49e4f..fe8626bcfa8 100644 --- a/scripts/rollup/forks.js +++ b/scripts/rollup/forks.js @@ -69,7 +69,18 @@ const forks = Object.freeze({ } }, - // Different behavior for caught errors. + // Different wrapping/reporting for caught errors. + 'shared/invokeGuardedCallback': (bundleType, entry) => { + switch (bundleType) { + case FB_DEV: + case FB_PROD: + return 'shared/forks/invokeGuardedCallback.www.js'; + default: + return null; + } + }, + + // Different dialogs for caught errors. 'react-reconciler/src/ReactFiberErrorDialog': (bundleType, entry) => { switch (bundleType) { case FB_DEV: