Skip to content

Commit 87f74c6

Browse files
committed
Modify InvokeGuardedCallbackImpl to opt into browser extension at runtime
InvokeGuardedCallback was implemented with a metaprogramming pattern we are now trying to eliminate. It has one particular consequence in test environments because the dev/browser patch is applied when the module loads but we may change the global document after this import. This can lead to a mismatch in the prototype chain between the HTMLUnknownElement and the event we are trying to dispatch on it. This patch rewrites the guarded callback impl to opt into the browser specific path at error time rather than when the module is loaded. This changes some test behaviors in subtle ways so there are some related test changes.
1 parent dd0619b commit 87f74c6

File tree

1 file changed

+75
-89
lines changed

1 file changed

+75
-89
lines changed

packages/shared/invokeGuardedCallbackImpl.js

Lines changed: 75 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -7,81 +7,53 @@
77
* @flow
88
*/
99

10-
// $FlowFixMe[missing-this-annot]
11-
function invokeGuardedCallbackProd<Args: Array<mixed>, Context>(
12-
name: string | null,
13-
func: (...Args) => mixed,
14-
context: Context,
15-
): void {
16-
// $FlowFixMe[method-unbinding]
17-
const funcArgs = Array.prototype.slice.call(arguments, 3);
18-
try {
19-
// $FlowFixMe[incompatible-call] Flow doesn't understand the arguments splicing.
20-
func.apply(context, funcArgs);
21-
} catch (error) {
22-
this.onError(error);
23-
}
24-
}
25-
26-
let invokeGuardedCallbackImpl: <Args: Array<mixed>, Context>(
27-
name: string | null,
28-
func: (...Args) => mixed,
29-
context: Context,
30-
) => void = invokeGuardedCallbackProd;
31-
10+
let fakeNode: Element = (null: any);
11+
let doc: Document = (null: any);
12+
let win: any = (null: any); // Window type
3213
if (__DEV__) {
33-
// In DEV mode, we swap out invokeGuardedCallback for a special version
34-
// that plays more nicely with the browser's DevTools. The idea is to preserve
35-
// "Pause on exceptions" behavior. Because React wraps all user-provided
36-
// functions in invokeGuardedCallback, and the production version of
37-
// invokeGuardedCallback uses a try-catch, all user exceptions are treated
38-
// like caught exceptions, and the DevTools won't pause unless the developer
39-
// takes the extra step of enabling pause on caught exceptions. This is
40-
// unintuitive, though, because even though React has caught the error, from
41-
// the developer's perspective, the error is uncaught.
42-
//
43-
// To preserve the expected "Pause on exceptions" behavior, we don't use a
44-
// try-catch in DEV. Instead, we synchronously dispatch a fake event to a fake
45-
// DOM node, and call the user-provided callback from inside an event handler
46-
// for that fake event. If the callback throws, the error is "captured" using
47-
// a global event handler. But because the error happens in a different
48-
// event loop context, it does not interrupt the normal program flow.
49-
// Effectively, this gives us try-catch behavior without actually using
50-
// try-catch. Neat!
51-
52-
// Check that the browser supports the APIs we need to implement our special
53-
// DEV version of invokeGuardedCallback
5414
if (
5515
typeof window !== 'undefined' &&
5616
typeof window.dispatchEvent === 'function' &&
5717
typeof document !== 'undefined' &&
5818
// $FlowFixMe[method-unbinding]
5919
typeof document.createEvent === 'function'
6020
) {
61-
const fakeNode = document.createElement('react');
62-
63-
invokeGuardedCallbackImpl = function invokeGuardedCallbackDev<
64-
Args: Array<mixed>,
65-
Context,
66-
// $FlowFixMe[missing-this-annot]
67-
>(name: string | null, func: (...Args) => mixed, context: Context): void {
68-
// If document doesn't exist we know for sure we will crash in this method
69-
// when we call document.createEvent(). However this can cause confusing
70-
// errors: https://github.com/facebook/create-react-app/issues/3482
71-
// So we preemptively throw with a better message instead.
72-
if (typeof document === 'undefined' || document === null) {
73-
throw new Error(
74-
'The `document` global was defined when React was initialized, but is not ' +
75-
'defined anymore. This can happen in a test environment if a component ' +
76-
'schedules an update from an asynchronous callback, but the test has already ' +
77-
'finished running. To solve this, you can either unmount the component at ' +
78-
'the end of your test (and ensure that any asynchronous operations get ' +
79-
'canceled in `componentWillUnmount`), or you can change the test itself ' +
80-
'to be asynchronous.',
81-
);
82-
}
21+
fakeNode = document.createElement('react');
22+
doc = document;
23+
win = window;
24+
}
25+
}
8326

84-
const evt = document.createEvent('Event');
27+
export default function invokeGuardedCallbackImpl<Args: Array<mixed>, Context>(
28+
this: {onError: (error: mixed) => void},
29+
name: string | null,
30+
func: (...Args) => mixed,
31+
context: Context,
32+
): void {
33+
if (__DEV__) {
34+
// In DEV mode, we use a special version
35+
// that plays more nicely with the browser's DevTools. The idea is to preserve
36+
// "Pause on exceptions" behavior. Because React wraps all user-provided
37+
// functions in invokeGuardedCallback, and the production version of
38+
// invokeGuardedCallback uses a try-catch, all user exceptions are treated
39+
// like caught exceptions, and the DevTools won't pause unless the developer
40+
// takes the extra step of enabling pause on caught exceptions. This is
41+
// unintuitive, though, because even though React has caught the error, from
42+
// the developer's perspective, the error is uncaught.
43+
//
44+
// To preserve the expected "Pause on exceptions" behavior, we don't use a
45+
// try-catch in DEV. Instead, we synchronously dispatch a fake event to a fake
46+
// DOM node, and call the user-provided callback from inside an event handler
47+
// for that fake event. If the callback throws, the error is "captured" using
48+
// a global event handler. But because the error happens in a different
49+
// event loop context, it does not interrupt the normal program flow.
50+
// Effectively, this gives us try-catch behavior without actually using
51+
// try-catch. Neat!
52+
53+
// fakeNode or doc or win could be our signal for whether we have set up the necessary
54+
// state to execute this path. We just use fakeNode because we only need to check one of them.
55+
if (fakeNode) {
56+
const evt = doc.createEvent('Event');
8557

8658
let didCall = false;
8759
// Keeps track of whether the user-provided callback threw an error. We
@@ -95,16 +67,16 @@ if (__DEV__) {
9567
// Keeps track of the value of window.event so that we can reset it
9668
// during the callback to let user code access window.event in the
9769
// browsers that support it.
98-
const windowEvent = window.event;
70+
const windowEvent = win.event;
9971

10072
// Keeps track of the descriptor of window.event to restore it after event
10173
// dispatching: https://github.com/facebook/react/issues/13688
10274
const windowEventDescriptor = Object.getOwnPropertyDescriptor(
103-
window,
75+
win,
10476
'event',
10577
);
10678

107-
function restoreAfterDispatch() {
79+
const restoreAfterDispatch = () => {
10880
// We immediately remove the callback from event listeners so that
10981
// nested `invokeGuardedCallback` calls do not clash. Otherwise, a
11082
// nested call would trigger the fake event handlers of any call higher
@@ -115,26 +87,23 @@ if (__DEV__) {
11587
// window.event assignment in both IE <= 10 as they throw an error
11688
// "Member not found" in strict mode, and in Firefox which does not
11789
// support window.event.
118-
if (
119-
typeof window.event !== 'undefined' &&
120-
window.hasOwnProperty('event')
121-
) {
122-
window.event = windowEvent;
90+
if (typeof win.event !== 'undefined' && win.hasOwnProperty('event')) {
91+
win.event = windowEvent;
12392
}
124-
}
93+
};
12594

12695
// Create an event handler for our fake event. We will synchronously
12796
// dispatch our fake event using `dispatchEvent`. Inside the handler, we
12897
// call the user-provided callback.
12998
// $FlowFixMe[method-unbinding]
13099
const funcArgs = Array.prototype.slice.call(arguments, 3);
131-
function callCallback() {
100+
const callCallback = () => {
132101
didCall = true;
133102
restoreAfterDispatch();
134103
// $FlowFixMe[incompatible-call] Flow doesn't understand the arguments splicing.
135104
func.apply(context, funcArgs);
136105
didError = false;
137-
}
106+
};
138107

139108
// Create a global error event handler. We use this to capture the value
140109
// that was thrown. It's possible that this error handler will fire more
@@ -152,8 +121,7 @@ if (__DEV__) {
152121
let didSetError = false;
153122
let isCrossOriginError = false;
154123

155-
// $FlowFixMe[missing-local-annot]
156-
function handleWindowError(event) {
124+
const handleWindowError = (event: ErrorEvent) => {
157125
error = event.error;
158126
didSetError = true;
159127
if (error === null && event.colno === 0 && event.lineno === 0) {
@@ -171,22 +139,21 @@ if (__DEV__) {
171139
}
172140
}
173141
}
174-
}
142+
};
175143

176144
// Create a fake event type.
177145
const evtType = `react-${name ? name : 'invokeguardedcallback'}`;
178146

179147
// Attach our event handlers
180-
window.addEventListener('error', handleWindowError);
148+
win.addEventListener('error', handleWindowError);
181149
fakeNode.addEventListener(evtType, callCallback, false);
182150

183151
// Synchronously dispatch our fake event. If the user-provided function
184152
// errors, it will trigger our global error handler.
185153
evt.initEvent(evtType, false, false);
186154
fakeNode.dispatchEvent(evt);
187-
188155
if (windowEventDescriptor) {
189-
Object.defineProperty(window, 'event', windowEventDescriptor);
156+
Object.defineProperty(win, 'event', windowEventDescriptor);
190157
}
191158

192159
if (didCall && didError) {
@@ -215,18 +182,37 @@ if (__DEV__) {
215182
}
216183

217184
// Remove our event listeners
218-
window.removeEventListener('error', handleWindowError);
185+
win.removeEventListener('error', handleWindowError);
219186

220-
if (!didCall) {
187+
if (didCall) {
188+
return;
189+
} else {
221190
// Something went really wrong, and our event was not dispatched.
222191
// https://github.com/facebook/react/issues/16734
223192
// https://github.com/facebook/react/issues/16585
224193
// Fall back to the production implementation.
225194
restoreAfterDispatch();
226-
return invokeGuardedCallbackProd.apply(this, arguments);
195+
// we fall through and call the prod version instead
227196
}
228-
};
197+
}
198+
// We only get here if we are in an environment that either does not support the browser
199+
// variant or we had trouble getting the browser to emit the error.
200+
// $FlowFixMe[method-unbinding]
201+
const funcArgs = Array.prototype.slice.call(arguments, 3);
202+
try {
203+
// $FlowFixMe[incompatible-call] Flow doesn't understand the arguments splicing.
204+
func.apply(context, funcArgs);
205+
} catch (error) {
206+
this.onError(error);
207+
}
208+
} else {
209+
// $FlowFixMe[method-unbinding]
210+
const funcArgs = Array.prototype.slice.call(arguments, 3);
211+
try {
212+
// $FlowFixMe[incompatible-call] Flow doesn't understand the arguments splicing.
213+
func.apply(context, funcArgs);
214+
} catch (error) {
215+
this.onError(error);
216+
}
229217
}
230218
}
231-
232-
export default invokeGuardedCallbackImpl;

0 commit comments

Comments
 (0)