Allow the user to opt out of seeing "The above error..." addendum (#13384)
* Remove e.suppressReactErrorLogging check before last resort throw It's unnecessary here. It was here because this method called console.error(). But we now rethrow with a clean stack, and that's worth doing regardless of whether the logging is silenced. * Don't print error addendum if 'error' event got preventDefault() * Add fixtures * Use an expando property instead of a WeakSet * Make it a bit less fragile * Clarify comments
Dan Abramov committed
Aug 13, 2018 at 21:33 UTC
3938ccc88aa3dcc5a4460474bda40af97dd6e234
6 files changed
+213
-15
fixtures/dom/src/components/fixtures/error-handling/index.js
+161
-2
@@ -7,9 +7,21 @@ const ReactDOM = window.ReactDOM;
7
function BadRender(props) {
8
props.doThrow();
9
}
10
+
11
+class BadDidMount extends React.Component {
12
+ componentDidMount() {
13
+ this.props.doThrow();
14
+ }
15
+
16
+ render() {
17
+ return null;
18
+ }
19
+}
20
+
21
class ErrorBoundary extends React.Component {
22
static defaultProps = {
23
buttonText: 'Trigger error',
24
+ badChildType: BadRender,
25
};
26
state = {
27
shouldThrow: false,
@@ -33,7 +45,8 @@ class ErrorBoundary extends React.Component {
45
}
46
}
47
if (this.state.shouldThrow) {
36
- return <BadRender doThrow={this.props.doThrow} />;
48
+ const BadChild = this.props.badChildType;
49
+ return <BadChild doThrow={this.props.doThrow} />;
50
}
51
return <button onClick={this.triggerError}>{this.props.buttonText}</button>;
52
}
@@ -84,6 +97,112 @@ class TriggerErrorAndCatch extends React.Component {
97
}
98
}
99
100
+function silenceWindowError(event) {
101
+ event.preventDefault();
102
+}
103
+
104
+class SilenceErrors extends React.Component {
105
+ state = {
106
+ silenceErrors: false,
107
+ };
108
+ componentDidMount() {
109
+ if (this.state.silenceErrors) {
110
+ window.addEventListener('error', silenceWindowError);
111
+ }
112
+ }
113
+ componentDidUpdate(prevProps, prevState) {
114
+ if (!prevState.silenceErrors && this.state.silenceErrors) {
115
+ window.addEventListener('error', silenceWindowError);
116
+ } else if (prevState.silenceErrors && !this.state.silenceErrors) {
117
+ window.removeEventListener('error', silenceWindowError);
118
+ }
119
+ }
120
+ componentWillUnmount() {
121
+ if (this.state.silenceErrors) {
122
+ window.removeEventListener('error', silenceWindowError);
123
+ }
124
+ }
125
+ render() {
126
+ return (
127
+ <div>
128
+ <label>
129
+ <input
130
+ type="checkbox"
131
+ value={this.state.silenceErrors}
132
+ onChange={() =>
133
+ this.setState(state => ({
134
+ silenceErrors: !state.silenceErrors,
135
+ }))
136
+ }
137
+ />
138
+ Silence errors
139
+ </label>
140
+ {this.state.silenceErrors && (
141
+ <div>
142
+ {this.props.children}
143
+ <br />
144
+ <hr />
145
+ <b style={{color: 'red'}}>
146
+ Don't forget to uncheck "Silence errors" when you're done with
147
+ this test!
148
+ </b>
149
+ </div>
150
+ )}
151
+ </div>
152
+ );
153
+ }
154
+}
155
+
156
+class SilenceRecoverableError extends React.Component {
157
+ render() {
158
+ return (
159
+ <SilenceErrors>
160
+ <ErrorBoundary
161
+ badChildType={BadRender}
162
+ buttonText={'Throw (render phase)'}
163
+ doThrow={() => {
164
+ throw new Error('Silenced error (render phase)');
165
+ }}
166
+ />
167
+ <ErrorBoundary
168
+ badChildType={BadDidMount}
169
+ buttonText={'Throw (commit phase)'}
170
+ doThrow={() => {
171
+ throw new Error('Silenced error (commit phase)');
172
+ }}
173
+ />
174
+ </SilenceErrors>
175
+ );
176
+ }
177
+}
178
+
179
+class TrySilenceFatalError extends React.Component {
180
+ container = document.createElement('div');
181
+
182
+ triggerErrorAndCatch = () => {
183
+ try {
184
+ ReactDOM.flushSync(() => {
185
+ ReactDOM.render(
186
+ <BadRender
187
+ doThrow={() => {
188
+ throw new Error('Caught error');
189
+ }}
190
+ />,
191
+ this.container
192
+ );
193
+ });
194
+ } catch (e) {}
195
+ };
196
+
197
+ render() {
198
+ return (
199
+ <SilenceErrors>
200
+ <button onClick={this.triggerErrorAndCatch}>Throw fatal error</button>
201
+ </SilenceErrors>
202
+ );
203
+ }
204
+}
205
+
206
export default class ErrorHandlingTestCases extends React.Component {
207
render() {
208
return (
@@ -103,6 +222,12 @@ export default class ErrorHandlingTestCases extends React.Component {
222
the BadRender component. After resuming, the "Trigger error" button
223
should be replaced with "Captured an error: Oops!" Clicking reset
224
should reset the test case.
225
+ <br />
226
+ <br />
227
+ In the console, you should see <b>two</b> messages: the actual error
228
+ ("Oops") printed natively by the browser with its JavaScript stack,
229
+ and our addendum ("The above error occurred in BadRender component")
230
+ with a React component stack.
231
</TestCase.ExpectedResult>
232
<Example
233
doThrow={() => {
@@ -155,10 +280,44 @@ export default class ErrorHandlingTestCases extends React.Component {
280
</TestCase.Steps>
281
<TestCase.ExpectedResult>
282
Open the console. "Uncaught Error: Caught error" should have been
158
- logged by the browser.
283
+ logged by the browser. You should also see our addendum ("The above
284
+ error...").
285
</TestCase.ExpectedResult>
286
<TriggerErrorAndCatch />
287
</TestCase>
288
+ <TestCase
289
+ title="Recoverable errors can be silenced with preventDefault (development mode only)"
290
+ description="">
291
+ <TestCase.Steps>
292
+ <li>Check the "Silence errors" checkbox below</li>
293
+ <li>Click the "Throw (render phase)" button</li>
294
+ <li>Click the "Throw (commit phase)" button</li>
295
+ <li>Uncheck the "Silence errors" checkbox</li>
296
+ </TestCase.Steps>
297
+ <TestCase.ExpectedResult>
298
+ Open the console. You shouldn't see <b>any</b> messages in the
299
+ console: neither the browser error, nor our "The above error"
300
+ addendum, from either of the buttons. The buttons themselves should
301
+ get replaced by two labels: "Captured an error: Silenced error
302
+ (render phase)" and "Captured an error: Silenced error (commit
303
+ phase)".
304
+ </TestCase.ExpectedResult>
305
+ <SilenceRecoverableError />
306
+ </TestCase>
307
+ <TestCase
308
+ title="Fatal errors cannot be silenced with preventDefault (development mode only)"
309
+ description="">
310
+ <TestCase.Steps>
311
+ <li>Check the "Silence errors" checkbox below</li>
312
+ <li>Click the "Throw fatal error" button</li>
313
+ <li>Uncheck the "Silence errors" checkbox</li>
314
+ </TestCase.Steps>
315
+ <TestCase.ExpectedResult>
316
+ Open the console. "Error: Caught error" should have been logged by
317
+ React. You should also see our addendum ("The above error...").
318
+ </TestCase.ExpectedResult>
319
+ <TrySilenceFatalError />
320
+ </TestCase>
321
</FixtureSet>
322
);
323
}
fixtures/dom/src/style.css
+1
-1
@@ -158,7 +158,7 @@ li {
158
}
159
160
.test-case__body {
161
- padding: 0 15px;
161
+ padding: 10px;
162
}
163
164
.test-case__desc {
packages/react-reconciler/src/ReactFiberCommitWork.js
+7
-11
@@ -112,17 +112,13 @@ export function logError(boundary: Fiber, errorInfo: CapturedValue<mixed>) {
112
try {
113
logCapturedError(capturedError);
114
} catch (e) {
115
- // Prevent cycle if logCapturedError() throws.
116
- // A cycle may still occur if logCapturedError renders a component that throws.
117
- const suppressLogging = e && e.suppressReactErrorLogging;
118
- if (!suppressLogging) {
119
- // Rethrow it from a clean stack because this function is assumed to never throw.
120
- // We can't safely call console.error() here because it could *also* throw if overridden.
121
- // https://github.com/facebook/react/issues/13188
122
- setTimeout(() => {
123
- throw e;
124
- });
125
- }
115
+ // This method must not throw, or React internal state will get messed up.
116
+ // If console.error is overridden, or logCapturedError() shows a dialog that throws,
117
+ // we want to report this error outside of the normal stack as a last resort.
118
+ // https://github.com/facebook/react/issues/13188
119
+ setTimeout(() => {
120
+ throw e;
121
+ });
122
}
123
}
124
packages/react-reconciler/src/ReactFiberErrorLogger.js
+19
@@ -35,6 +35,25 @@ export function logCapturedError(capturedError: CapturedError): void {
35
willRetry,
36
} = capturedError;
37
38
+ // Browsers support silencing uncaught errors by calling
39
+ // `preventDefault()` in window `error` handler.
40
+ // We record this information as an expando on the error.
41
+ if (error != null && error._suppressLogging) {
42
+ if (errorBoundaryFound && willRetry) {
43
+ // The error is recoverable and was silenced.
44
+ // Ignore it and don't print the stack addendum.
45
+ // This is handy for testing error boundaries without noise.
46
+ return;
47
+ }
48
+ // The error is fatal. Since the silencing might have
49
+ // been accidental, we'll surface it anyway.
50
+ // However, the browser would have silenced the original error
51
+ // so we'll print it first, and then print the stack addendum.
52
+ console.error(error);
53
+ // For a more detailed description of this block, see:
54
+ // https://github.com/facebook/react/pull/13384
55
+ }
56
+
57
const componentNameMessage = componentName
58
? `The above error occurred in the <${componentName}> component:`
59
: 'The above error occurred in one of your React components:';
packages/react-reconciler/src/ReactFiberScheduler.js
+13
-1
@@ -305,7 +305,19 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
305
isReplayingFailedUnitOfWork = false;
306
originalReplayError = null;
307
if (hasCaughtError()) {
308
- clearCaughtError();
308
+ const replayError = clearCaughtError();
309
+ if (replayError != null && thrownValue != null) {
310
+ try {
311
+ // Reading the expando property is intentionally
312
+ // inside `try` because it might be a getter or Proxy.
313
+ if (replayError._suppressLogging) {
314
+ // Also suppress logging for the original error.
315
+ (thrownValue: any)._suppressLogging = true;
316
+ }
317
+ } catch (inner) {
318
+ // Ignore.
319
+ }
320
+ }
321
} else {
322
// If the begin phase did not fail the second time, set this pointer
323
// back to the original value.
packages/shared/invokeGuardedCallback.js
+12
@@ -132,6 +132,18 @@ if (__DEV__) {
132
if (error === null && event.colno === 0 && event.lineno === 0) {
133
isCrossOriginError = true;
134
}
135
+ if (event.defaultPrevented) {
136
+ // Some other error handler has prevented default.
137
+ // Browsers silence the error report if this happens.
138
+ // We'll remember this to later decide whether to log it or not.
139
+ if (error != null && typeof error === 'object') {
140
+ try {
141
+ error._suppressLogging = true;
142
+ } catch (inner) {
143
+ // Ignore.
144
+ }
145
+ }
146
+ }
147
}
148
149
// Create a fake event type.