From 1db522dee802da0910ab7817d06e04ea0aaa0817 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Thu, 30 Nov 2017 18:16:34 +0000 Subject: [PATCH 1/4] Use `this` inside invokeGuardedCallback It's slightly odd but that's exactly how our www fork works. Might as well do it in the open source version to make it clear we rely on context here. --- packages/shared/ReactErrorUtils.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/shared/ReactErrorUtils.js b/packages/shared/ReactErrorUtils.js index cbbfc81652b..c1c73550ee2 100644 --- a/packages/shared/ReactErrorUtils.js +++ b/packages/shared/ReactErrorUtils.js @@ -115,14 +115,14 @@ const ReactErrorUtils = { }; let invokeGuardedCallback = function(name, func, context, a, b, c, d, e, f) { - ReactErrorUtils._hasCaughtError = false; - ReactErrorUtils._caughtError = null; + this._hasCaughtError = false; + this._caughtError = null; const funcArgs = Array.prototype.slice.call(arguments, 3); try { func.apply(context, funcArgs); } catch (error) { - ReactErrorUtils._caughtError = error; - ReactErrorUtils._hasCaughtError = true; + this._caughtError = error; + this._hasCaughtError = true; } }; @@ -261,11 +261,11 @@ if (__DEV__) { 'See https://fb.me/react-crossorigin-error for more information.', ); } - ReactErrorUtils._hasCaughtError = true; - ReactErrorUtils._caughtError = error; + this._hasCaughtError = true; + this._caughtError = error; } else { - ReactErrorUtils._hasCaughtError = false; - ReactErrorUtils._caughtError = null; + this._hasCaughtError = false; + this._caughtError = null; } // Remove our event listeners From 8d0a6ba31c34a3ee1959ce83940dbd2f71666b87 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Thu, 30 Nov 2017 18:27:50 +0000 Subject: [PATCH 2/4] Move invokeGuardedCallback into a separate file This lets us introduce forks for it. --- packages/shared/ReactErrorUtils.js | 173 +---------------- .../ReactErrorUtils-test.internal.js | 55 +++--- packages/shared/invokeGuardedCallback.js | 174 ++++++++++++++++++ 3 files changed, 206 insertions(+), 196 deletions(-) create mode 100644 packages/shared/invokeGuardedCallback.js diff --git a/packages/shared/ReactErrorUtils.js b/packages/shared/ReactErrorUtils.js index c1c73550ee2..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) { - 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, - 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.', - ); - } - this._hasCaughtError = true; - this._caughtError = error; - } else { - this._hasCaughtError = false; - this._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/invokeGuardedCallback.js b/packages/shared/invokeGuardedCallback.js new file mode 100644 index 00000000000..7221c46bb7e --- /dev/null +++ b/packages/shared/invokeGuardedCallback.js @@ -0,0 +1,174 @@ +/** + * 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, func, context, a, b, c, d, e, 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, + 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.', + ); + } + 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; From ec81e2823f76813da8118960388612f065faad34 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Thu, 30 Nov 2017 18:35:39 +0000 Subject: [PATCH 3/4] Add a www fork for invokeGuardedCallback --- packages/react-dom/src/client/ReactDOMFB.js | 2 -- .../shared/forks/invokeGuardedCallback.www.js | 17 +++++++++++++++++ scripts/rollup/forks.js | 13 ++++++++++++- 3 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 packages/shared/forks/invokeGuardedCallback.www.js 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/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/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: From dc2713b3ba2f860df8b20f3f06a7b3796f9f86c6 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Thu, 30 Nov 2017 19:01:17 +0000 Subject: [PATCH 4/4] Fix Flow --- packages/shared/invokeGuardedCallback.js | 32 ++++++++++++++++-------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/shared/invokeGuardedCallback.js b/packages/shared/invokeGuardedCallback.js index 7221c46bb7e..0abe767fdf0 100644 --- a/packages/shared/invokeGuardedCallback.js +++ b/packages/shared/invokeGuardedCallback.js @@ -9,7 +9,17 @@ import invariant from 'fbjs/lib/invariant'; -let invokeGuardedCallback = function(name, func, context, a, b, c, d, e, f) { +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); @@ -51,16 +61,16 @@ if (__DEV__) { ) { const fakeNode = document.createElement('react'); - const invokeGuardedCallbackDev = function( - name, - func, - context, - a, - b, - c, - d, - e, - f, + 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