Skip to content

Commit bde6993

Browse files
Merge pull request #435 from Workiva/errorboundary_unrecoverable
CPLAT-8600: Unrecoverable ErrorBoundary Fix
2 parents 9ac11e7 + 885d948 commit bde6993

13 files changed

+818
-58
lines changed

lib/over_react.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export 'src/component/abstract_transition_props.dart';
4040
export 'src/component/aria_mixin.dart';
4141
export 'src/component/callback_typedefs.dart';
4242
export 'src/component/error_boundary.dart';
43-
export 'src/component/error_boundary_mixins.dart';
43+
export 'src/component/error_boundary_mixins.dart' hide ErrorBoundaryApi;
4444
export 'src/component/dom_components.dart';
4545
export 'src/component/ref_util.dart';
4646
export 'src/component/fragment_component.dart';

lib/src/component/error_boundary.dart

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
import 'package:logging/logging.dart';
2+
import 'package:meta/meta.dart';
13
import 'package:over_react/over_react.dart';
4+
import 'package:over_react/src/component/error_boundary_mixins.dart';
5+
import 'package:over_react/src/component/error_boundary_recoverable.dart';
26

37
part 'error_boundary.over_react.g.dart';
48

@@ -22,4 +26,121 @@ class _$ErrorBoundaryState extends UiState with ErrorBoundaryStateMixin {}
2226

2327
@Component2(isWrapper: true, isErrorBoundary: true)
2428
class ErrorBoundaryComponent<T extends ErrorBoundaryProps, S extends ErrorBoundaryState>
25-
extends UiStatefulComponent2<T, S> with ErrorBoundaryMixin<T, S> {}
29+
extends UiStatefulComponent2<T, S> with ErrorBoundaryApi<T, S> {
30+
31+
// ---------------------------------------------- \/ ----------------------------------------------
32+
// How This ErrorBoundary Works:
33+
//
34+
// Background Info:
35+
// React gives each Error Boundary one chance to handle or recover when an error is throws, if
36+
// the Error Boundary's children throw again on its next render after React calls
37+
// `getDerivedStateFromError` and `componentDidCatch`, React will ascend the tree to the next
38+
// Error Boundary above it and rerender offering it a chance to handle the error. If none of
39+
// the parent Error Boundaries have a successful render cycle, React unmounts the entire tree.
40+
// __Note:__ When an Error Boundary remounts its children React clears all child components
41+
// previous state, including child ErrorBoundaries meaning they lose all previous knowledge
42+
// of any errors thrown.
43+
//
44+
// Solution:
45+
// To prevent unmounting the entire tree when React cannot find an Error Boundary that is able
46+
// to handle the error we wrap an Error Boundary with another Error Boundary (this one!). The
47+
// child Error Boundary will handle errors that are "recoverable", so if an error gets to this
48+
// Error Boundary we know it is "unrecoverable" and can present a fallback.
49+
//
50+
// -----------------------------------------------------------------------------------------------
51+
// Implementation:
52+
//
53+
// [1] Renders a child Error Boundary that is able to handle Errors thrown outside of the initial
54+
// render cycle, allowing it a chance to "recover".
55+
//
56+
// [2] If we catch an error in this Error Boundary that indicates that the child Error Boundary was
57+
// unable to handle or recover from the error, so we know that it was "unrecoverable" and we
58+
// haven't had a successful render there is never any DOM created that can used to display,
59+
// so we present an empty div instead.
60+
//
61+
// ---------------------------------------------- /\ ----------------------------------------------
62+
63+
@override
64+
get defaultProps => (newProps()
65+
..identicalErrorFrequencyTolerance = Duration(seconds: 5)
66+
..loggerName = defaultErrorBoundaryLoggerName
67+
..shouldLogErrors = true
68+
);
69+
70+
@override
71+
get initialState => (newState()
72+
..hasError = false
73+
..showFallbackUIOnError = true
74+
);
75+
76+
@override
77+
Map getDerivedStateFromError(error) => (newState()
78+
..hasError = true
79+
..showFallbackUIOnError = true
80+
);
81+
82+
@override
83+
void componentDidCatch(error, ReactErrorInfo info) {
84+
if (props.onComponentDidCatch != null) {
85+
props.onComponentDidCatch(error, info);
86+
}
87+
88+
_logErrorCaughtByErrorBoundary(error, info);
89+
90+
if (props.onComponentIsUnrecoverable != null) {
91+
props.onComponentIsUnrecoverable(error, info);
92+
}
93+
}
94+
95+
@override
96+
render() {
97+
if (state.hasError) { // [2]
98+
return (Dom.div()
99+
..key = 'ohnoes'
100+
..addTestId('ErrorBoundary.unrecoverableErrorInnerHtmlContainerNode')
101+
)();
102+
}
103+
return (RecoverableErrorBoundary()
104+
..addTestId('RecoverableErrorBoundary')
105+
..modifyProps(addUnconsumedProps)
106+
)(props.children); // [1]
107+
}
108+
109+
@override
110+
void componentDidUpdate(Map prevProps, Map prevState, [dynamic snapshot]) {
111+
// If the child is different, and the error boundary is currently in an error state,
112+
// give the children a chance to mount.
113+
if (state.hasError) {
114+
final childThatCausedError = typedPropsFactory(prevProps).children.single;
115+
if (childThatCausedError != props.children.single) {
116+
reset();
117+
}
118+
}
119+
}
120+
121+
/// Resets the [ErrorBoundary] to a non-error state.
122+
///
123+
/// This can be called manually on the component instance using a `ref` -
124+
/// or by passing in a new child instance after a child has thrown an error.
125+
void reset() {
126+
setState(initialState);
127+
}
128+
129+
String get _loggerName {
130+
if (props.logger != null) return props.logger.name;
131+
132+
return props.loggerName ?? defaultErrorBoundaryLoggerName;
133+
}
134+
135+
void _logErrorCaughtByErrorBoundary(
136+
/*Error|Exception*/ dynamic error,
137+
ReactErrorInfo info, {
138+
bool isRecoverable = true,
139+
}) {
140+
if (!props.shouldLogErrors) return;
141+
142+
final message = 'An unrecoverable error was caught by an ErrorBoundary (attempting to remount it was unsuccessful): \nInfo: ${info.componentStack}';
143+
144+
(props.logger ?? Logger(_loggerName)).severe(message, error, info.dartStackTrace);
145+
}
146+
}

lib/src/component/error_boundary_mixins.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,23 @@ part 'error_boundary_mixins.over_react.g.dart';
99
@visibleForTesting
1010
const String defaultErrorBoundaryLoggerName = 'over_react.ErrorBoundary';
1111

12+
/// An API mixin used for shared APIs in ErrorBoundary Components.
13+
mixin ErrorBoundaryApi<T extends ErrorBoundaryPropsMixin, S extends ErrorBoundaryStateMixin> on UiStatefulComponent2<T, S> {
14+
/// Resets the [ErrorBoundary] to a non-error state.
15+
///
16+
/// This can be called manually on the component instance using a `ref` -
17+
/// or by passing in a new child instance after a child has thrown an error.
18+
void reset() {
19+
setState(initialState);
20+
}
21+
}
22+
1223
/// A props mixin you can use to implement / extend from the behaviors of an [ErrorBoundary]
1324
/// within a custom component.
1425
///
1526
/// > See: [ErrorBoundaryMixin] for a usage example.
27+
@Deprecated('Building custom error boundaries with this mixin will no longer be supported in version 4.0.0.'
28+
'Use ErrorBoundary and its prop API to customize error handling instead.')
1629
@PropsMixin()
1730
abstract class _$ErrorBoundaryPropsMixin implements UiProps {
1831
@override
@@ -109,6 +122,8 @@ abstract class _$ErrorBoundaryPropsMixin implements UiProps {
109122
/// within a custom component.
110123
///
111124
/// > See: [ErrorBoundaryMixin] for a usage example.
125+
@Deprecated('Building custom error boundaries with this mixin will no longer be supported in version 4.0.0.'
126+
'Use ErrorBoundary and its prop API to customize error handling instead.')
112127
@StateMixin()
113128
abstract class _$ErrorBoundaryStateMixin implements UiState {
114129
@override
@@ -158,6 +173,8 @@ abstract class _$ErrorBoundaryStateMixin implements UiState {
158173
/// return Dom.h3()('Error!');
159174
/// }
160175
/// }
176+
@Deprecated('Building custom error boundaries with this mixin will no longer be supported in version 4.0.0.'
177+
'Use ErrorBoundary and its prop API to customize error handling instead.')
161178
mixin ErrorBoundaryMixin<T extends ErrorBoundaryPropsMixin, S extends ErrorBoundaryStateMixin>
162179
on UiStatefulComponent2<T, S> {
163180
@override
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import 'package:over_react/over_react.dart';
2+
import 'package:over_react/src/component/error_boundary_mixins.dart';
3+
4+
part 'error_boundary_recoverable.over_react.g.dart';
5+
6+
/// A higher-order component that will catch "recoverable" ReactJS errors, errors outside of the render/mount cycle,
7+
/// anywhere within the child component tree and display a fallback UI instead of the component tree that crashed.
8+
///
9+
/// __NOTE:__
10+
/// 1. This component is not / should never be publicly exported.
11+
/// 2. This component should never be used, except as a child of the outer (public) `ErrorBoundary` component.
12+
@Factory()
13+
UiFactory<RecoverableErrorBoundaryProps> RecoverableErrorBoundary = _$RecoverableErrorBoundary;
14+
15+
@Props()
16+
class _$RecoverableErrorBoundaryProps extends UiProps with ErrorBoundaryPropsMixin {}
17+
18+
@State()
19+
class _$RecoverableErrorBoundaryState extends UiState with ErrorBoundaryStateMixin {}
20+
21+
@Component2(isWrapper: true, isErrorBoundary: true)
22+
class RecoverableErrorBoundaryComponent<T extends RecoverableErrorBoundaryProps, S extends RecoverableErrorBoundaryState>
23+
extends UiStatefulComponent2<T, S> with ErrorBoundaryMixin<T, S>, ErrorBoundaryApi<T, S> {}

0 commit comments

Comments
 (0)