Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CPLAT-8600: Unrecoverable ErrorBoundary Fix #435

Merged
merged 14 commits into from
Dec 18, 2019
2 changes: 1 addition & 1 deletion lib/over_react.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export 'src/component/abstract_transition_props.dart';
export 'src/component/aria_mixin.dart';
export 'src/component/callback_typedefs.dart';
export 'src/component/error_boundary.dart';
export 'src/component/error_boundary_mixins.dart';
export 'src/component/error_boundary_mixins.dart' hide ErrorBoundaryApi;
export 'src/component/dom_components.dart';
export 'src/component/ref_util.dart';
export 'src/component/fragment_component.dart';
Expand Down
123 changes: 122 additions & 1 deletion lib/src/component/error_boundary.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:over_react/over_react.dart';
import 'package:over_react/src/component/error_boundary_mixins.dart';
import 'package:over_react/src/component/error_boundary_recoverable.dart';

part 'error_boundary.over_react.g.dart';

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

@Component2(isWrapper: true, isErrorBoundary: true)
class ErrorBoundaryComponent<T extends ErrorBoundaryProps, S extends ErrorBoundaryState>
extends UiStatefulComponent2<T, S> with ErrorBoundaryMixin<T, S> {}
extends UiStatefulComponent2<T, S> with ErrorBoundaryApi<T, S> {

// ---------------------------------------------- \/ ----------------------------------------------
// How This ErrorBoundary Works:
//
// Background Info:
// React gives each Error Boundary one chance to handle or recover when an error is throws, if
// the Error Boundary's children throw again on its next render after React calls
// `getDerivedStateFromError` and `componentDidCatch`, React will ascend the tree to the next
// Error Boundary above it and rerender offering it a chance to handle the error. If none of
// the parent Error Boundaries have a successful render cycle, React unmounts the entire tree.
// __Note:__ When an Error Boundary remounts its children React clears all child components
// previous state, including child ErrorBoundaries meaning they lose all previous knowledge
// of any errors thrown.
//
// Solution:
// To prevent unmounting the entire tree when React cannot find an Error Boundary that is able
// to handle the error we wrap an Error Boundary with another Error Boundary (this one!). The
// child Error Boundary will handle errors that are "recoverable", so if an error gets to this
// Error Boundary we know it is "unrecoverable" and can present a fallback.
//
// -----------------------------------------------------------------------------------------------
// Implementation:
//
// [1] Renders a child Error Boundary that is able to handle Errors thrown outside of the initial
// render cycle, allowing it a chance to "recover".
//
// [2] If we catch an error in this Error Boundary that indicates that the child Error Boundary was
// unable to handle or recover from the error, so we know that it was "unrecoverable" and we
// haven't had a successful render there is never any DOM created that can used to display,
// so we present an empty div instead.
//
// ---------------------------------------------- /\ ----------------------------------------------

@override
get defaultProps => (newProps()
..identicalErrorFrequencyTolerance = Duration(seconds: 5)
..loggerName = defaultErrorBoundaryLoggerName
..shouldLogErrors = true
);

@override
get initialState => (newState()
..hasError = false
..showFallbackUIOnError = true
);

@override
Map getDerivedStateFromError(error) => (newState()
..hasError = true
..showFallbackUIOnError = true
);

@override
void componentDidCatch(error, ReactErrorInfo info) {
if (props.onComponentDidCatch != null) {
props.onComponentDidCatch(error, info);
}

_logErrorCaughtByErrorBoundary(error, info);

if (props.onComponentIsUnrecoverable != null) {
props.onComponentIsUnrecoverable(error, info);
}
}

@override
render() {
if (state.hasError) { // [2]
return (Dom.div()
..key = 'ohnoes'
..addTestId('ErrorBoundary.unrecoverableErrorInnerHtmlContainerNode')
)();
}
return (RecoverableErrorBoundary()
..addTestId('RecoverableErrorBoundary')
..modifyProps(addUnconsumedProps)
)(props.children); // [1]
}

@override
void componentDidUpdate(Map prevProps, Map prevState, [dynamic snapshot]) {
// If the child is different, and the error boundary is currently in an error state,
// give the children a chance to mount.
if (state.hasError) {
final childThatCausedError = typedPropsFactory(prevProps).children.single;
if (childThatCausedError != props.children.single) {
reset();
}
}
}

/// Resets the [ErrorBoundary] to a non-error state.
///
/// This can be called manually on the component instance using a `ref` -
/// or by passing in a new child instance after a child has thrown an error.
void reset() {
setState(initialState);
}

String get _loggerName {
if (props.logger != null) return props.logger.name;

return props.loggerName ?? defaultErrorBoundaryLoggerName;
}

void _logErrorCaughtByErrorBoundary(
/*Error|Exception*/ dynamic error,
ReactErrorInfo info, {
bool isRecoverable = true,
}) {
if (!props.shouldLogErrors) return;

final message = 'An unrecoverable error was caught by an ErrorBoundary (attempting to remount it was unsuccessful): \nInfo: ${info.componentStack}';

(props.logger ?? Logger(_loggerName)).severe(message, error, info.dartStackTrace);
}
}
17 changes: 17 additions & 0 deletions lib/src/component/error_boundary_mixins.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,23 @@ part 'error_boundary_mixins.over_react.g.dart';
@visibleForTesting
const String defaultErrorBoundaryLoggerName = 'over_react.ErrorBoundary';

/// An API mixin used for shared APIs in ErrorBoundary Components.
mixin ErrorBoundaryApi<T extends ErrorBoundaryPropsMixin, S extends ErrorBoundaryStateMixin> on UiStatefulComponent2<T, S> {
/// Resets the [ErrorBoundary] to a non-error state.
///
/// This can be called manually on the component instance using a `ref` -
/// or by passing in a new child instance after a child has thrown an error.
void reset() {
setState(initialState);
}
}

/// A props mixin you can use to implement / extend from the behaviors of an [ErrorBoundary]
/// within a custom component.
///
/// > See: [ErrorBoundaryMixin] for a usage example.
@Deprecated('Building custom error boundaries with this mixin will no longer be supported in version 4.0.0.'
'Use ErrorBoundary and its prop API to customize error handling instead.')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NICE

@PropsMixin()
abstract class _$ErrorBoundaryPropsMixin implements UiProps {
@override
Expand Down Expand Up @@ -109,6 +122,8 @@ abstract class _$ErrorBoundaryPropsMixin implements UiProps {
/// within a custom component.
///
/// > See: [ErrorBoundaryMixin] for a usage example.
@Deprecated('Building custom error boundaries with this mixin will no longer be supported in version 4.0.0.'
'Use ErrorBoundary and its prop API to customize error handling instead.')
@StateMixin()
abstract class _$ErrorBoundaryStateMixin implements UiState {
@override
Expand Down Expand Up @@ -158,6 +173,8 @@ abstract class _$ErrorBoundaryStateMixin implements UiState {
/// return Dom.h3()('Error!');
/// }
/// }
@Deprecated('Building custom error boundaries with this mixin will no longer be supported in version 4.0.0.'
'Use ErrorBoundary and its prop API to customize error handling instead.')
mixin ErrorBoundaryMixin<T extends ErrorBoundaryPropsMixin, S extends ErrorBoundaryStateMixin>
on UiStatefulComponent2<T, S> {
@override
Expand Down
23 changes: 23 additions & 0 deletions lib/src/component/error_boundary_recoverable.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import 'package:over_react/over_react.dart';
import 'package:over_react/src/component/error_boundary_mixins.dart';

part 'error_boundary_recoverable.over_react.g.dart';

/// A higher-order component that will catch "recoverable" ReactJS errors, errors outside of the render/mount cycle,
/// anywhere within the child component tree and display a fallback UI instead of the component tree that crashed.
///
/// __NOTE:__
/// 1. This component is not / should never be publicly exported.
/// 2. This component should never be used, except as a child of the outer (public) `ErrorBoundary` component.
@Factory()
UiFactory<RecoverableErrorBoundaryProps> RecoverableErrorBoundary = _$RecoverableErrorBoundary;

@Props()
class _$RecoverableErrorBoundaryProps extends UiProps with ErrorBoundaryPropsMixin {}

@State()
class _$RecoverableErrorBoundaryState extends UiState with ErrorBoundaryStateMixin {}

@Component2(isWrapper: true, isErrorBoundary: true)
class RecoverableErrorBoundaryComponent<T extends RecoverableErrorBoundaryProps, S extends RecoverableErrorBoundaryState>
extends UiStatefulComponent2<T, S> with ErrorBoundaryMixin<T, S>, ErrorBoundaryApi<T, S> {}
Loading