Error boundaries.
Jim committed
Apr 8, 2015 at 18:58 UTC
3afced6a75382ddf471a5490491b869a5f964689
4 files changed
+240
-8
src/core/__tests__/ReactErrorBoundaries-test.js
new
+158
@@ -0,0 +1,158 @@
1
+/**
2
+ * Copyright 2013-2015, Facebook, Inc.
3
+ * All rights reserved.
4
+ *
5
+ * This source code is licensed under the BSD-style license found in the
6
+ * LICENSE file in the root directory of this source tree. An additional grant
7
+ * of patent rights can be found in the PATENTS file in the same directory.
8
+ *
9
+ * @emails react-core
10
+ */
11
+
12
+'use strict';
13
+
14
+var React;
15
+var ReactDOM;
16
+
17
+describe('ReactErrorBoundaries', function() {
18
+
19
+ beforeEach(function() {
20
+ ReactDOM = require('ReactDOM');
21
+ React = require('React');
22
+ });
23
+
24
+ it('does not register event handlers for unmounted children', function() {
25
+ class Angry extends React.Component {
26
+ render() {
27
+ throw new Error('Please, do not render me.');
28
+ }
29
+ }
30
+
31
+ class Boundary extends React.Component {
32
+ constructor(props) {
33
+ super(props);
34
+ this.state = {error: false};
35
+ }
36
+ render() {
37
+ if (!this.state.error) {
38
+ return (<div><button onClick={this.onClick}>ClickMe</button><Angry /></div>);
39
+ } else {
40
+ return (<div>Happy Birthday!</div>);
41
+ }
42
+ }
43
+ onClick() {
44
+ /* do nothing */
45
+ }
46
+ unstable_handleError() {
47
+ this.setState({error: true});
48
+ }
49
+ }
50
+
51
+ var EventPluginHub = require('EventPluginHub');
52
+ var container = document.createElement('div');
53
+ EventPluginHub.putListener = jest.genMockFn();
54
+ ReactDOM.render(<Boundary />, container);
55
+ expect(EventPluginHub.putListener).not.toBeCalled();
56
+ });
57
+
58
+ it('expect uneventful render to succeed', function() {
59
+ class Boundary extends React.Component {
60
+ constructor(props) {
61
+ super(props);
62
+ this.state = {error: false};
63
+ }
64
+ render() {
65
+ return (<div><button onClick={this.onClick}>ClickMe</button></div>);
66
+ }
67
+ onClick() {
68
+ /* do nothing */
69
+ }
70
+ unstable_handleError() {
71
+ this.setState({error: true});
72
+ }
73
+ }
74
+
75
+ var EventPluginHub = require('EventPluginHub');
76
+ var container = document.createElement('div');
77
+ EventPluginHub.putListener = jest.genMockFn();
78
+ ReactDOM.render(<Boundary />, container);
79
+ expect(EventPluginHub.putListener).toBeCalled();
80
+ });
81
+
82
+
83
+ it('catches errors from children', function() {
84
+ var log = [];
85
+
86
+ class Box extends React.Component {
87
+ constructor(props) {
88
+ super(props);
89
+ this.state = {errorMessage: null};
90
+ }
91
+ render() {
92
+ if (this.state.errorMessage != null) {
93
+ log.push('Box renderError');
94
+ return <div>Error: {this.state.errorMessage}</div>;
95
+ }
96
+ log.push('Box render');
97
+ var ref = function(x) {
98
+ log.push('Inquisitive ref ' + x);
99
+ };
100
+ return (
101
+ <div>
102
+ <Inquisitive ref={ref} />
103
+ <Angry />
104
+ </div>
105
+ );
106
+ }
107
+ unstable_handleError(e) {
108
+ this.setState({errorMessage: e.message});
109
+ }
110
+ componentDidMount() {
111
+ log.push('Box componentDidMount');
112
+ }
113
+ componentWillUnmount() {
114
+ log.push('Box componentWillUnmount');
115
+ }
116
+ }
117
+
118
+ class Inquisitive extends React.Component {
119
+ render() {
120
+ log.push('Inquisitive render');
121
+ return <div>What is love?</div>;
122
+ }
123
+ componentDidMount() {
124
+ log.push('Inquisitive componentDidMount');
125
+ }
126
+ componentWillUnmount() {
127
+ log.push('Inquisitive componentWillUnmount');
128
+ }
129
+ }
130
+
131
+ class Angry extends React.Component {
132
+ render() {
133
+ log.push('Angry render');
134
+ throw new Error('Please, do not render me.');
135
+ }
136
+ componentDidMount() {
137
+ log.push('Angry componentDidMount');
138
+ }
139
+ componentWillUnmount() {
140
+ log.push('Angry componentWillUnmount');
141
+ }
142
+ }
143
+
144
+ var container = document.createElement('div');
145
+ ReactDOM.render(<Box />, container);
146
+ expect(container.textContent).toBe('Error: Please, do not render me.');
147
+ expect(log).toEqual([
148
+ 'Box render',
149
+ 'Inquisitive render',
150
+ 'Angry render',
151
+ 'Inquisitive ref null',
152
+ 'Inquisitive componentWillUnmount',
153
+ 'Angry componentWillUnmount',
154
+ 'Box renderError',
155
+ 'Box componentDidMount',
156
+ ]);
157
+ });
158
+});
src/renderers/dom/client/ReactReconcileTransaction.js
+13
@@ -136,6 +136,19 @@ var Mixin = {
136
return this.reactMountReady;
137
},
138
139
+ /**
140
+ * Save current transaction state -- if the return value from this method is
141
+ * passed to `rollback`, the transaction will be reset to that state.
142
+ */
143
+ checkpoint: function() {
144
+ // reactMountReady is the our only stateful wrapper
145
+ return this.reactMountReady.checkpoint();
146
+ },
147
+
148
+ rollback: function(checkpoint) {
149
+ this.reactMountReady.rollback(checkpoint);
150
+ },
151
+
152
/**
153
* `PooledClass` looks for this, and will invoke this before allowing this
154
* instance to be reused.
src/renderers/shared/reconciler/ReactCompositeComponent.js
+58
-8
@@ -108,7 +108,6 @@ var ReactCompositeComponentMixin = {
108
109
this._renderedNodeType = null;
110
this._renderedComponent = null;
111
-
111
this._context = null;
112
this._mountOrder = 0;
113
this._topLevelWrapper = null;
@@ -278,6 +277,58 @@ var ReactCompositeComponentMixin = {
277
this._pendingReplaceState = false;
278
this._pendingForceUpdate = false;
279
280
+ var markup;
281
+ if (inst.unstable_handleError) {
282
+ markup = this.performInitialMountWithErrorHandling(
283
+ renderedElement,
284
+ nativeParent,
285
+ nativeContainerInfo,
286
+ transaction,
287
+ context
288
+ );
289
+ } else {
290
+ markup = this.performInitialMount(renderedElement, nativeParent, nativeContainerInfo, transaction, context);
291
+ }
292
+
293
+ if (inst.componentDidMount) {
294
+ transaction.getReactMountReady().enqueue(inst.componentDidMount, inst);
295
+ }
296
+
297
+ return markup;
298
+ },
299
+
300
+ performInitialMountWithErrorHandling: function(
301
+ renderedElement,
302
+ nativeParent,
303
+ nativeContainerInfo,
304
+ transaction,
305
+ context
306
+ ) {
307
+ var markup;
308
+ var checkpoint = transaction.checkpoint();
309
+ try {
310
+ markup = this.performInitialMount(renderedElement, nativeParent, nativeContainerInfo, transaction, context);
311
+ } catch (e) {
312
+ // Roll back to checkpoint, handle error (which may add items to the transaction), and take a new checkpoint
313
+ transaction.rollback(checkpoint);
314
+ this._instance.unstable_handleError(e);
315
+ if (this._pendingStateQueue) {
316
+ this._instance.state = this._processPendingState(this._instance.props, this._instance.context);
317
+ }
318
+ checkpoint = transaction.checkpoint();
319
+
320
+ this._renderedComponent.unmountComponent();
321
+ transaction.rollback(checkpoint);
322
+
323
+ // Try again - we've informed the component about the error, so they can render an error message this time.
324
+ // If this throws again, the error will bubble up (and can be caught by a higher error boundary).
325
+ markup = this.performInitialMount(renderedElement, nativeParent, nativeContainerInfo, transaction, context);
326
+ }
327
+ return markup;
328
+ },
329
+
330
+ performInitialMount: function(renderedElement, nativeParent, nativeContainerInfo, transaction, context) {
331
+ var inst = this._instance;
332
if (inst.componentWillMount) {
333
inst.componentWillMount();
334
// When mounting, calls to `setState` by `componentWillMount` will set
@@ -304,9 +355,6 @@ var ReactCompositeComponentMixin = {
355
nativeContainerInfo,
356
this._processChildContext(context)
357
);
307
- if (inst.componentDidMount) {
308
- transaction.getReactMountReady().enqueue(inst.componentDidMount, inst);
309
- }
358
359
return markup;
360
},
@@ -328,10 +376,12 @@ var ReactCompositeComponentMixin = {
376
inst.componentWillUnmount();
377
}
378
331
- ReactReconciler.unmountComponent(this._renderedComponent);
332
- this._renderedNodeType = null;
333
- this._renderedComponent = null;
334
- this._instance = null;
379
+ if (this._renderedComponent) {
380
+ ReactReconciler.unmountComponent(this._renderedComponent);
381
+ this._renderedNodeType = null;
382
+ this._renderedComponent = null;
383
+ this._instance = null;
384
+ }
385
386
// Reset pending fields
387
// Even if this component is scheduled for another update in ReactUpdates,
src/shared/utils/CallbackQueue.js
+11
@@ -72,6 +72,17 @@ assign(CallbackQueue.prototype, {
72
}
73
},
74
75
+ checkpoint: function() {
76
+ return this._callbacks ? this._callbacks.length : 0;
77
+ },
78
+
79
+ rollback: function(len) {
80
+ if (this._callbacks) {
81
+ this._callbacks.length = len;
82
+ this._contexts.length = len;
83
+ }
84
+ },
85
+
86
/**
87
* Resets the internal queue.
88
*