From 9380d626feaa1e28ce0ae8a3b7ec8e839e79783a Mon Sep 17 00:00:00 2001 From: Aaron Lademann Date: Thu, 16 May 2019 15:56:15 -0700 Subject: [PATCH 1/3] Finalize error boundary componentry + Remove the temporary JS error boundary in-favor-of the bindings available in `Component2` + Make all the `ErrorBoundary` componentry mixin-based so that consumers can more easily make any component an error boundary with default behavior(s). + Finish tests --- lib/over_react.dart | 1 + lib/src/component/error_boundary.dart | 174 +----------- .../error_boundary.over_react.g.dart | 254 +++++++++--------- lib/src/component/error_boundary_mixins.dart | 116 ++++++++ .../error_boundary_mixins.over_react.g.dart | 143 ++++++++++ .../component/error_boundary_mixin_test.dart | 27 ++ .../component/error_boundary_test.dart | 140 +--------- .../custom_error_boundary_component.dart | 18 ++ ...error_boundary_component.over_react.g.dart | 240 +++++++++++++++++ .../shared_error_boundary_tests.dart | 128 +++++++++ test/over_react_component_test.dart | 2 + .../src/demos/custom_error_boundary.dart | 50 +--- .../custom_error_boundary.over_react.g.dart | 56 ++-- 13 files changed, 833 insertions(+), 516 deletions(-) create mode 100644 lib/src/component/error_boundary_mixins.dart create mode 100644 lib/src/component/error_boundary_mixins.over_react.g.dart create mode 100644 test/over_react/component/error_boundary_mixin_test.dart create mode 100644 test/over_react/component/fixtures/custom_error_boundary_component.dart create mode 100644 test/over_react/component/fixtures/custom_error_boundary_component.over_react.g.dart create mode 100644 test/over_react/component/shared_error_boundary_tests.dart diff --git a/lib/over_react.dart b/lib/over_react.dart index ec1bbbef0..7798df978 100644 --- a/lib/over_react.dart +++ b/lib/over_react.dart @@ -37,6 +37,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/dom_components.dart'; export 'src/component/dummy_component.dart'; export 'src/component/prop_mixins.dart'; diff --git a/lib/src/component/error_boundary.dart b/lib/src/component/error_boundary.dart index 35bd8cb26..238993edc 100644 --- a/lib/src/component/error_boundary.dart +++ b/lib/src/component/error_boundary.dart @@ -1,186 +1,26 @@ -import 'dart:html'; -import 'dart:js_util' as js_util; - -import 'package:js/js.dart'; -import 'package:meta/meta.dart'; import 'package:over_react/over_react.dart'; import 'package:react/react_client.dart'; -import 'package:react/react_client/js_interop_helpers.dart'; -import 'package:react/react_client/react_interop.dart' show React, ReactClassConfig, throwErrorFromJS; part 'error_boundary.over_react.g.dart'; -/// A __temporary, private JS component for use only by [ErrorBoundary]__ that utilizes its own lightweight -/// JS interop to make use of the ReactJS 16 `componentDidCatch` lifecycle method to prevent consumer -/// react component trees from unmounting as a result of child component errors being "uncaught". -/// -/// > __Why this is here__ -/// > -/// > In order to release react-dart 5.0.0 _(which upgrades to ReactJS 16)_ -/// without depending on Dart 2 / `Component2` (coming in react-dart 5.1.0) / `UiComponent2` (coming in over_react 3.1.0) - -/// and all the new lifecycle methods that those expose, we need to ensure that - at a minimum - the `componentDidCatch` -/// lifecycle method is handled by components wrapped in our [ErrorBoundary] component so that the behavior of -/// an application when a component within a tree throws - is the same as it was when using ReactJS 15. -/// > -/// > Otherwise, the update to react-dart 5.0.0 / over_react 3.0.0 will result in consumer apps rendering completely -/// "blank" screens when their trees unmount as a result of a child component throwing an error. -/// This would be unexpected, unwanted - and since we will not add a Dart js-interop layer around `componentDidCatch` -/// until react-dart 5.1.0 / over_react 3.1.0 - unmanageable for consumers. -/// -/// __This will be removed in over_react 3.1.0__ once [ErrorBoundaryComponent] is extending from `UiStatefulComponent2` -/// which will ensure that the [ErrorBoundaryComponent.componentDidCatch] lifecycle method has real js-interop bindings -/// via react-dart 5.1.0's `Component2` base class. -/// -/// TODO: Remove in 3.1.0 -final ReactElement Function([Map props, List children]) _jsErrorBoundaryComponentFactory = (() { - var componentClass = React.createClass(jsifyAndAllowInterop({ - 'displayName': 'JsErrorBoundary', - 'render': allowInteropCaptureThis((jsThis) { - final jsProps = js_util.getProperty(jsThis, 'props'); - return js_util.getProperty(jsProps, 'children'); - }), - 'componentDidCatch': allowInteropCaptureThis((jsThis, error, info) { - final jsProps = js_util.getProperty(jsThis, 'props'); - // Due to the error object being passed in from ReactJS it is a javascript object that does not get dartified. - // To fix this we throw the error again from Dart to the JS side and catch it Dart side which re-dartifies it. - try { - throwErrorFromJS(error); - } catch (error, stack) { - final callback = js_util.getProperty(jsProps, 'onComponentDidCatch'); - - if (callback != null) { - callback(error, info); - } - } - }), - })); - - // Despite what the ReactJS docs say about only needing _either_ componentDidCatch or getDerivedStateFromError - // in order to define an "error boundary" component, that is not actually the case. - // - // The tree will never get re-rendered after an error is caught unless both are defined. - // ignore: argument_type_not_assignable - js_util.setProperty(componentClass, 'getDerivedStateFromError', allowInterop((_) => js_util.newObject())); - - var reactFactory = React.createFactory(componentClass); - - return ([Map props = const {}, List children = const []]) { - return reactFactory(jsifyAndAllowInterop(props), listifyChildren(children)); - }; -})(); - -// TODO: Need to type the second argument once react-dart implements bindings for the ReactJS "componentStack". -typedef _ComponentDidCatchCallback(/*Error*/dynamic error, /*ComponentStack*/dynamic componentStack); - -// TODO: Need to type the second argument once react-dart implements bindings for the ReactJS "componentStack". -typedef ReactElement _FallbackUiRenderer(/*Error*/dynamic error, /*ComponentStack*/dynamic componentStack); - /// A higher-order component that will catch ReactJS errors anywhere within the child component tree and /// display a fallback UI instead of the component tree that crashed. /// /// Optionally, use the [ErrorBoundaryProps.onComponentDidCatch] /// to send error / stack trace information to a logging endpoint for your application. /// -/// > __NOTE: This component does not yet do any of this__. -/// > -/// > It will begin providing the boundary / fallback UI behavior once support -/// for ReactJS 16 is released in over_react version 3.0.0 +/// To make your own custom error boundaries, you can utilize the [ErrorBoundaryPropsMixin], +/// [ErrorBoundaryStateMixin] and [ErrorBoundaryMixin]s on any component that is annotated +/// using `@Component2(isErrorBoundary: true)`. See the [ErrorBoundaryMixin] for an example implementation. @Factory() UiFactory ErrorBoundary = _$ErrorBoundary; @Props() -class _$ErrorBoundaryProps extends UiProps { - /// An optional callback that will be called with an [Error] and a `ComponentStack` - /// containing information about which component in the tree threw the error when - /// the `componentDidCatch` lifecycle method is called. - /// - /// This callback can be used to log component errors like so: - /// - /// (ErrorBoundary() - /// ..onComponentDidCatch = (error, componentStack) { - /// // It is up to you to implement the service / thing that calls the service. - /// logComponentStackToAService(error, componentStack); - /// } - /// )( - /// // The rest of your component tree - /// ) - /// - /// > See: - _ComponentDidCatchCallback onComponentDidCatch; - - /// A renderer that will be used to render "fallback" UI instead of the child - /// component tree that crashed. - /// - /// > Default: [ErrorBoundaryComponent._renderDefaultFallbackUI] - _FallbackUiRenderer fallbackUIRenderer; -} +class _$ErrorBoundaryProps extends UiProps with ErrorBoundaryPropsMixin {} @State() -class _$ErrorBoundaryState extends UiState { - /// Whether the tree that the [ErrorBoundary] is wrapping around threw an error. - /// - /// When `true`, fallback UI will be rendered using [ErrorBoundaryProps.fallbackUIRenderer]. - bool hasError; -} +class _$ErrorBoundaryState extends UiState with ErrorBoundaryStateMixin {} -@Component(isWrapper: true) +@Component2(isWrapper: true, isErrorBoundary: true) class ErrorBoundaryComponent - extends UiStatefulComponent { - Error _error; - /*ComponentStack*/dynamic _componentStack; - - @override - Map getDefaultProps() => (newProps() - ..fallbackUIRenderer = _renderDefaultFallbackUI - ); - - @override - Map getInitialState() => (newState() - ..hasError = false - ); - - @mustCallSuper - /*@override*/ - S getDerivedStateFromError(_) { - return newState()..hasError = true; - } - - @mustCallSuper - /*@override*/ - void componentDidCatch(Error error, /*ComponentStack*/dynamic componentStack) { - _error = error; - _componentStack = componentStack; - - if (props.onComponentDidCatch != null) { - props.onComponentDidCatch(error, componentStack); - } - } - - @override - render() { - // TODO: 3.1.0 - Remove the `_jsErrorBoundaryComponentFactory`, and restore just the children of it once this component is extending from `UiStatefulComponent2`. - return _jsErrorBoundaryComponentFactory({ - 'onComponentDidCatch': props.onComponentDidCatch - }, - state.hasError - ? [props.fallbackUIRenderer(_error, _componentStack)] - : props.children - ); - } - - ReactElement _renderDefaultFallbackUI(_, __) => - throw new UnimplementedError('Fallback UI will not be supported until support for ReactJS 16 lifecycle methods is released in version 3.1.0'); - - @mustCallSuper - @override - void validateProps([Map appliedProps]) { - super.validateProps(appliedProps); - final children = domProps(appliedProps).children; - - if (children.length != 1) { - throw new PropError.value(children, 'children', 'ErrorBoundary accepts only a single child.'); - } else if (!isValidElement(children.single)) { - throw new PropError.value(children, 'children', 'ErrorBoundary accepts only a single ReactComponent child.'); - } - } -} + extends UiStatefulComponent2 with ErrorBoundaryMixin {} diff --git a/lib/src/component/error_boundary.over_react.g.dart b/lib/src/component/error_boundary.over_react.g.dart index d0d9e2f74..e5a72c8d5 100644 --- a/lib/src/component/error_boundary.over_react.g.dart +++ b/lib/src/component/error_boundary.over_react.g.dart @@ -9,101 +9,25 @@ part of 'error_boundary.dart'; // React component factory implementation. // // Registers component implementation and links type meta to builder factory. -final $ErrorBoundaryComponentFactory = registerComponent( - () => new _$ErrorBoundaryComponent(), - builderFactory: ErrorBoundary, - componentClass: ErrorBoundaryComponent, - isWrapper: true, - parentType: null, - displayName: 'ErrorBoundary'); +final $ErrorBoundaryComponentFactory = registerComponent2( + () => new _$ErrorBoundaryComponent(), + builderFactory: ErrorBoundary, + componentClass: ErrorBoundaryComponent, + isWrapper: true, + parentType: null, + displayName: 'ErrorBoundary', + skipMethods: const [], +); abstract class _$ErrorBoundaryPropsAccessorsMixin implements _$ErrorBoundaryProps { @override Map get props; - /// An optional callback that will be called with an [Error] and a `ComponentStack` - /// containing information about which component in the tree threw the error when - /// the `componentDidCatch` lifecycle method is called. - /// - /// This callback can be used to log component errors like so: - /// - /// (ErrorBoundary() - /// ..onComponentDidCatch = (error, componentStack) { - /// // It is up to you to implement the service / thing that calls the service. - /// logComponentStackToAService(error, componentStack); - /// } - /// )( - /// // The rest of your component tree - /// ) - /// - /// > See: - /// - /// - @override - _ComponentDidCatchCallback get onComponentDidCatch => - props[_$key__onComponentDidCatch___$ErrorBoundaryProps] ?? - null; // Add ` ?? null` to workaround DDC bug: ; - /// An optional callback that will be called with an [Error] and a `ComponentStack` - /// containing information about which component in the tree threw the error when - /// the `componentDidCatch` lifecycle method is called. - /// - /// This callback can be used to log component errors like so: - /// - /// (ErrorBoundary() - /// ..onComponentDidCatch = (error, componentStack) { - /// // It is up to you to implement the service / thing that calls the service. - /// logComponentStackToAService(error, componentStack); - /// } - /// )( - /// // The rest of your component tree - /// ) - /// - /// > See: - /// - /// - @override - set onComponentDidCatch(_ComponentDidCatchCallback value) => - props[_$key__onComponentDidCatch___$ErrorBoundaryProps] = value; - - /// A renderer that will be used to render "fallback" UI instead of the child - /// component tree that crashed. - /// - /// > Default: [ErrorBoundaryComponent._renderDefaultFallbackUI] - /// - /// - @override - _FallbackUiRenderer get fallbackUIRenderer => - props[_$key__fallbackUIRenderer___$ErrorBoundaryProps] ?? - null; // Add ` ?? null` to workaround DDC bug: ; - /// A renderer that will be used to render "fallback" UI instead of the child - /// component tree that crashed. - /// - /// > Default: [ErrorBoundaryComponent._renderDefaultFallbackUI] - /// - /// - @override - set fallbackUIRenderer(_FallbackUiRenderer value) => - props[_$key__fallbackUIRenderer___$ErrorBoundaryProps] = value; /* GENERATED CONSTANTS */ - static const PropDescriptor - _$prop__onComponentDidCatch___$ErrorBoundaryProps = - const PropDescriptor(_$key__onComponentDidCatch___$ErrorBoundaryProps); - static const PropDescriptor _$prop__fallbackUIRenderer___$ErrorBoundaryProps = - const PropDescriptor(_$key__fallbackUIRenderer___$ErrorBoundaryProps); - static const String _$key__onComponentDidCatch___$ErrorBoundaryProps = - 'ErrorBoundaryProps.onComponentDidCatch'; - static const String _$key__fallbackUIRenderer___$ErrorBoundaryProps = - 'ErrorBoundaryProps.fallbackUIRenderer'; - - static const List $props = const [ - _$prop__onComponentDidCatch___$ErrorBoundaryProps, - _$prop__fallbackUIRenderer___$ErrorBoundaryProps - ]; - static const List $propKeys = const [ - _$key__onComponentDidCatch___$ErrorBoundaryProps, - _$key__fallbackUIRenderer___$ErrorBoundaryProps - ]; + + static const List $props = const []; + static const List $propKeys = const []; } const PropsMeta _$metaForErrorBoundaryProps = const PropsMeta( @@ -117,25 +41,26 @@ class ErrorBoundaryProps extends _$ErrorBoundaryProps } _$$ErrorBoundaryProps _$ErrorBoundary([Map backingProps]) => - new _$$ErrorBoundaryProps(backingProps); + backingProps == null + ? new _$$ErrorBoundaryProps$JsMap(new JsBackedMap()) + : new _$$ErrorBoundaryProps(backingProps); // Concrete props implementation. // // Implements constructor and backing map, and links up to generated component factory. -class _$$ErrorBoundaryProps extends _$ErrorBoundaryProps +abstract class _$$ErrorBoundaryProps extends _$ErrorBoundaryProps with _$ErrorBoundaryPropsAccessorsMixin implements ErrorBoundaryProps { - // This initializer of `_props` to an empty map, as well as the reassignment - // of `_props` in the constructor body is necessary to work around a DDC bug: https://github.com/dart-lang/sdk/issues/36217 - _$$ErrorBoundaryProps(Map backingMap) : this._props = {} { - this._props = backingMap ?? {}; + _$$ErrorBoundaryProps._(); + + factory _$$ErrorBoundaryProps(Map backingMap) { + if (backingMap is JsBackedMap) { + return new _$$ErrorBoundaryProps$JsMap(backingMap); + } else { + return new _$$ErrorBoundaryProps$PlainMap(backingMap); + } } - /// The backing props map proxied by this class. - @override - Map get props => _props; - Map _props; - /// Let [UiProps] internals know that this class has been generated. @override bool get $isClassGenerated => true; @@ -150,40 +75,48 @@ class _$$ErrorBoundaryProps extends _$ErrorBoundaryProps String get propKeyNamespace => 'ErrorBoundaryProps.'; } +// Concrete props implementation that can be backed by any [Map]. +class _$$ErrorBoundaryProps$PlainMap extends _$$ErrorBoundaryProps { + // This initializer of `_props` to an empty map, as well as the reassignment + // of `_props` in the constructor body is necessary to work around a DDC bug: https://github.com/dart-lang/sdk/issues/36217 + _$$ErrorBoundaryProps$PlainMap(Map backingMap) + : this._props = {}, + super._() { + this._props = backingMap ?? {}; + } + + /// The backing props map proxied by this class. + @override + Map get props => _props; + Map _props; +} + +// Concrete props implementation that can only be backed by [JsMap], +// allowing dart2js to compile more optimal code for key-value pair reads/writes. +class _$$ErrorBoundaryProps$JsMap extends _$$ErrorBoundaryProps { + // This initializer of `_props` to an empty map, as well as the reassignment + // of `_props` in the constructor body is necessary to work around a DDC bug: https://github.com/dart-lang/sdk/issues/36217 + _$$ErrorBoundaryProps$JsMap(JsBackedMap backingMap) + : this._props = new JsBackedMap(), + super._() { + this._props = backingMap ?? new JsBackedMap(); + } + + /// The backing props map proxied by this class. + @override + JsBackedMap get props => _props; + JsBackedMap _props; +} + abstract class _$ErrorBoundaryStateAccessorsMixin implements _$ErrorBoundaryState { @override Map get state; - /// Whether the tree that the [ErrorBoundary] is wrapping around threw an error. - /// - /// When `true`, fallback UI will be rendered using [ErrorBoundaryProps.fallbackUIRenderer]. - /// - /// - @override - bool get hasError => - state[_$key__hasError___$ErrorBoundaryState] ?? - null; // Add ` ?? null` to workaround DDC bug: ; - /// Whether the tree that the [ErrorBoundary] is wrapping around threw an error. - /// - /// When `true`, fallback UI will be rendered using [ErrorBoundaryProps.fallbackUIRenderer]. - /// - /// - @override - set hasError(bool value) => - state[_$key__hasError___$ErrorBoundaryState] = value; /* GENERATED CONSTANTS */ - static const StateDescriptor _$prop__hasError___$ErrorBoundaryState = - const StateDescriptor(_$key__hasError___$ErrorBoundaryState); - static const String _$key__hasError___$ErrorBoundaryState = - 'ErrorBoundaryState.hasError'; - static const List $state = const [ - _$prop__hasError___$ErrorBoundaryState - ]; - static const List $stateKeys = const [ - _$key__hasError___$ErrorBoundaryState - ]; + static const List $state = const []; + static const List $stateKeys = const []; } const StateMeta _$metaForErrorBoundaryState = const StateMeta( @@ -199,12 +132,31 @@ class ErrorBoundaryState extends _$ErrorBoundaryState // Concrete state implementation. // // Implements constructor and backing map. -class _$$ErrorBoundaryState extends _$ErrorBoundaryState +abstract class _$$ErrorBoundaryState extends _$ErrorBoundaryState with _$ErrorBoundaryStateAccessorsMixin implements ErrorBoundaryState { + _$$ErrorBoundaryState._(); + + factory _$$ErrorBoundaryState(Map backingMap) { + if (backingMap is JsBackedMap) { + return new _$$ErrorBoundaryState$JsMap(backingMap); + } else { + return new _$$ErrorBoundaryState$PlainMap(backingMap); + } + } + + /// Let [UiState] internals know that this class has been generated. + @override + bool get $isClassGenerated => true; +} + +// Concrete state implementation that can be backed by any [Map]. +class _$$ErrorBoundaryState$PlainMap extends _$$ErrorBoundaryState { // This initializer of `_state` to an empty map, as well as the reassignment // of `_state` in the constructor body is necessary to work around a DDC bug: https://github.com/dart-lang/sdk/issues/36217 - _$$ErrorBoundaryState(Map backingMap) : this._state = {} { + _$$ErrorBoundaryState$PlainMap(Map backingMap) + : this._state = {}, + super._() { this._state = backingMap ?? {}; } @@ -212,10 +164,23 @@ class _$$ErrorBoundaryState extends _$ErrorBoundaryState @override Map get state => _state; Map _state; +} - /// Let [UiState] internals know that this class has been generated. +// Concrete state implementation that can only be backed by [JsMap], +// allowing dart2js to compile more optimal code for key-value pair reads/writes. +class _$$ErrorBoundaryState$JsMap extends _$$ErrorBoundaryState { + // This initializer of `_state` to an empty map, as well as the reassignment + // of `_state` in the constructor body is necessary to work around a DDC bug: https://github.com/dart-lang/sdk/issues/36217 + _$$ErrorBoundaryState$JsMap(JsBackedMap backingMap) + : this._state = new JsBackedMap(), + super._() { + this._state = backingMap ?? new JsBackedMap(); + } + + /// The backing state map proxied by this class. @override - bool get $isClassGenerated => true; + JsBackedMap get state => _state; + JsBackedMap _state; } // Concrete component implementation mixin. @@ -223,10 +188,39 @@ class _$$ErrorBoundaryState extends _$ErrorBoundaryState // Implements typed props/state factories, defaults `consumedPropKeys` to the keys // generated for the associated props class. class _$ErrorBoundaryComponent extends ErrorBoundaryComponent { + _$$ErrorBoundaryProps$JsMap _cachedTypedProps; + + @override + _$$ErrorBoundaryProps$JsMap get props => _cachedTypedProps; + + @override + set props(Map value) { + super.props = value; + _cachedTypedProps = typedPropsFactoryJs(value); + } + + @override + _$$ErrorBoundaryProps$JsMap typedPropsFactoryJs(JsBackedMap backingMap) => + new _$$ErrorBoundaryProps$JsMap(backingMap); + @override _$$ErrorBoundaryProps typedPropsFactory(Map backingMap) => new _$$ErrorBoundaryProps(backingMap); + _$$ErrorBoundaryState$JsMap _cachedTypedState; + @override + _$$ErrorBoundaryState$JsMap get state => _cachedTypedState; + + @override + set state(Map value) { + super.state = value; + _cachedTypedState = typedStateFactoryJs(value); + } + + @override + _$$ErrorBoundaryState$JsMap typedStateFactoryJs(JsBackedMap backingMap) => + new _$$ErrorBoundaryState$JsMap(backingMap); + @override _$$ErrorBoundaryState typedStateFactory(Map backingMap) => new _$$ErrorBoundaryState(backingMap); diff --git a/lib/src/component/error_boundary_mixins.dart b/lib/src/component/error_boundary_mixins.dart new file mode 100644 index 000000000..b2a02cbbf --- /dev/null +++ b/lib/src/component/error_boundary_mixins.dart @@ -0,0 +1,116 @@ +import 'package:meta/meta.dart'; +import 'package:over_react/over_react.dart'; +import 'package:react/react_client.dart'; +import 'package:react/react_client/react_interop.dart' show ReactErrorInfo; + +part 'error_boundary_mixins.over_react.g.dart'; + +/// 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. +@PropsMixin() +abstract class _$ErrorBoundaryPropsMixin implements UiProps { + @override + Map get props; + + /// An optional callback that will be called with an [Error] and a [ReactErrorInfo] + /// containing information about which component in the tree threw the error when + /// the `componentDidCatch` lifecycle method is called. + /// + /// This callback can be used to log component errors like so: + /// + /// (ErrorBoundary() + /// ..onComponentDidCatch = (error, info) { + /// // It is up to you to implement the service / thing that calls the service. + /// logComponentStackToAService(error, info); + /// } + /// )( + /// // The rest of your component tree + /// ) + /// + /// > See: + Function(/*Error*/dynamic error, ReactErrorInfo info) onComponentDidCatch; + + /// A renderer that will be used to render "fallback" UI instead of the child + /// component tree that crashed. + ReactElement Function() fallbackUIRenderer; +} + +/// A state mixin you can use to implement / extend from the behaviors of an [ErrorBoundary] +/// within a custom component. +/// +/// > See: [ErrorBoundaryMixin] for a usage example. +@StateMixin() +abstract class _$ErrorBoundaryStateMixin implements UiState { + @override + Map get state; + + /// Whether the tree that the [ErrorBoundary] is wrapping around threw an error. + /// + /// When `true`, fallback UI will be rendered using [ErrorBoundaryProps.fallbackUIRenderer]. + bool hasError; +} + +/// A component mixin you can use to implement / extend from the behaviors of an [ErrorBoundary] +/// within a custom component: +/// +/// @Factory() +/// UiFactory CustomErrorBoundary = _$CustomErrorBoundary; +/// +/// @Props() +/// class _$CustomErrorBoundaryProps extends UiProps with ErrorBoundaryPropsMixin {} +/// +/// @State() +/// class _$CustomErrorBoundaryState extends UiState with ErrorBoundaryStateMixin {} +/// +/// @Component2(isWrapper: true, isErrorBoundary: true) +/// class CustomErrorBoundaryComponent +/// extends UiStatefulComponent2 +/// with ErrorBoundaryMixin { +/// // Your custom component implementation, complete with a custom fallback renderer UI +/// @override +/// Map getDefaultProps() => newProps() +/// ..fallbackUIRenderer = _renderFallbackUI; +/// +/// ReactElement _renderFallbackUI() { +/// return Dom.h3()('Error!'); +/// } +/// } +mixin ErrorBoundaryMixin + on UiStatefulComponent2 { + // TODO (3.1.0-wip): Convert this to use `init` once the generated setter doesn't cause an RTE + @mustCallSuper + @override + Map getInitialState() => newState()..hasError = false; + + @mustCallSuper + @override + Map getDerivedStateFromError(dynamic error) { + return newState()..hasError = true; + } + + @mustCallSuper + @override + void componentDidCatch(dynamic error, ReactErrorInfo info) { + if (props.onComponentDidCatch != null) { + props.onComponentDidCatch(error, info); + } + } + + @override + render() { + if (state.hasError && props.fallbackUIRenderer != null) { + return props.fallbackUIRenderer(); + } + + return props.children; + } +} + +/// A MapView with the typed getters/setters for [ErrorBoundaryPropsMixin]. +class ErrorBoundaryPropsMapView extends UiPropsMapView + with ErrorBoundaryPropsMixin { + /// Create a new instance backed by the specified map. + ErrorBoundaryPropsMapView(Map map) : super(map); +} diff --git a/lib/src/component/error_boundary_mixins.over_react.g.dart b/lib/src/component/error_boundary_mixins.over_react.g.dart new file mode 100644 index 000000000..0d0ad2481 --- /dev/null +++ b/lib/src/component/error_boundary_mixins.over_react.g.dart @@ -0,0 +1,143 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'error_boundary_mixins.dart'; + +// ************************************************************************** +// OverReactBuilder (package:over_react/src/builder.dart) +// ************************************************************************** + +abstract class ErrorBoundaryPropsMixin implements _$ErrorBoundaryPropsMixin { + @override + Map get props; + + static const PropsMeta meta = _$metaForErrorBoundaryPropsMixin; + + /// An optional callback that will be called with an [Error] and a [ReactErrorInfo] + /// containing information about which component in the tree threw the error when + /// the `componentDidCatch` lifecycle method is called. + /// + /// This callback can be used to log component errors like so: + /// + /// (ErrorBoundary() + /// ..onComponentDidCatch = (error, info) { + /// // It is up to you to implement the service / thing that calls the service. + /// logComponentStackToAService(error, info); + /// } + /// )( + /// // The rest of your component tree + /// ) + /// + /// > See: + /// + /// + @override + Function(dynamic error, ReactErrorInfo info) get onComponentDidCatch => + props[_$key__onComponentDidCatch___$ErrorBoundaryPropsMixin] ?? + null; // Add ` ?? null` to workaround DDC bug: ; + /// An optional callback that will be called with an [Error] and a [ReactErrorInfo] + /// containing information about which component in the tree threw the error when + /// the `componentDidCatch` lifecycle method is called. + /// + /// This callback can be used to log component errors like so: + /// + /// (ErrorBoundary() + /// ..onComponentDidCatch = (error, info) { + /// // It is up to you to implement the service / thing that calls the service. + /// logComponentStackToAService(error, info); + /// } + /// )( + /// // The rest of your component tree + /// ) + /// + /// > See: + /// + /// + @override + set onComponentDidCatch(Function(dynamic error, ReactErrorInfo info) value) => + props[_$key__onComponentDidCatch___$ErrorBoundaryPropsMixin] = value; + + /// A renderer that will be used to render "fallback" UI instead of the child + /// component tree that crashed. + /// + /// + @override + ReactElement Function() get fallbackUIRenderer => + props[_$key__fallbackUIRenderer___$ErrorBoundaryPropsMixin] ?? + null; // Add ` ?? null` to workaround DDC bug: ; + /// A renderer that will be used to render "fallback" UI instead of the child + /// component tree that crashed. + /// + /// + @override + set fallbackUIRenderer(ReactElement Function() value) => + props[_$key__fallbackUIRenderer___$ErrorBoundaryPropsMixin] = value; + /* GENERATED CONSTANTS */ + static const PropDescriptor + _$prop__onComponentDidCatch___$ErrorBoundaryPropsMixin = + const PropDescriptor( + _$key__onComponentDidCatch___$ErrorBoundaryPropsMixin); + static const PropDescriptor + _$prop__fallbackUIRenderer___$ErrorBoundaryPropsMixin = + const PropDescriptor( + _$key__fallbackUIRenderer___$ErrorBoundaryPropsMixin); + static const String _$key__onComponentDidCatch___$ErrorBoundaryPropsMixin = + 'ErrorBoundaryPropsMixin.onComponentDidCatch'; + static const String _$key__fallbackUIRenderer___$ErrorBoundaryPropsMixin = + 'ErrorBoundaryPropsMixin.fallbackUIRenderer'; + + static const List $props = const [ + _$prop__onComponentDidCatch___$ErrorBoundaryPropsMixin, + _$prop__fallbackUIRenderer___$ErrorBoundaryPropsMixin + ]; + static const List $propKeys = const [ + _$key__onComponentDidCatch___$ErrorBoundaryPropsMixin, + _$key__fallbackUIRenderer___$ErrorBoundaryPropsMixin + ]; +} + +const PropsMeta _$metaForErrorBoundaryPropsMixin = const PropsMeta( + fields: ErrorBoundaryPropsMixin.$props, + keys: ErrorBoundaryPropsMixin.$propKeys, +); + +abstract class ErrorBoundaryStateMixin implements _$ErrorBoundaryStateMixin { + @override + Map get state; + + static const StateMeta meta = _$metaForErrorBoundaryStateMixin; + + /// Whether the tree that the [ErrorBoundary] is wrapping around threw an error. + /// + /// When `true`, fallback UI will be rendered using [ErrorBoundaryProps.fallbackUIRenderer]. + /// + /// + @override + bool get hasError => + state[_$key__hasError___$ErrorBoundaryStateMixin] ?? + null; // Add ` ?? null` to workaround DDC bug: ; + /// Whether the tree that the [ErrorBoundary] is wrapping around threw an error. + /// + /// When `true`, fallback UI will be rendered using [ErrorBoundaryProps.fallbackUIRenderer]. + /// + /// + @override + set hasError(bool value) => + state[_$key__hasError___$ErrorBoundaryStateMixin] = value; + /* GENERATED CONSTANTS */ + static const StateDescriptor _$prop__hasError___$ErrorBoundaryStateMixin = + const StateDescriptor(_$key__hasError___$ErrorBoundaryStateMixin); + static const String _$key__hasError___$ErrorBoundaryStateMixin = + 'ErrorBoundaryStateMixin.hasError'; + + static const List $state = const [ + _$prop__hasError___$ErrorBoundaryStateMixin + ]; + static const List $stateKeys = const [ + _$key__hasError___$ErrorBoundaryStateMixin + ]; +} + +const StateMeta _$metaForErrorBoundaryStateMixin = const StateMeta( + fields: ErrorBoundaryStateMixin.$state, + keys: ErrorBoundaryStateMixin.$stateKeys, +); diff --git a/test/over_react/component/error_boundary_mixin_test.dart b/test/over_react/component/error_boundary_mixin_test.dart new file mode 100644 index 000000000..2c731fc9b --- /dev/null +++ b/test/over_react/component/error_boundary_mixin_test.dart @@ -0,0 +1,27 @@ +// Copyright 2019 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@Timeout(const Duration(seconds: 2)) +library error_boundary_mixin_test; + +import 'package:test/test.dart'; + +import 'fixtures/custom_error_boundary_component.dart'; +import 'shared_error_boundary_tests.dart'; + +void main() { + group('ErrorBoundaryMixin', () { + sharedErrorBoundaryTests(() => CustomErrorBoundary()); + }); +} diff --git a/test/over_react/component/error_boundary_test.dart b/test/over_react/component/error_boundary_test.dart index 32f9e818c..56dbb9e91 100644 --- a/test/over_react/component/error_boundary_test.dart +++ b/test/over_react/component/error_boundary_test.dart @@ -15,149 +15,13 @@ @Timeout(const Duration(seconds: 2)) library error_boundary_test; -import 'dart:html'; -import 'dart:js'; import 'package:over_react/over_react.dart'; -import 'package:over_react_test/over_react_test.dart'; import 'package:test/test.dart'; -import './fixtures/flawed_component.dart'; +import 'shared_error_boundary_tests.dart'; void main() { group('ErrorBoundary', () { - TestJacket jacket; - ReactElement dummyChild; - - setUp(() { - dummyChild = Dom.div()('hi there'); - }); - - tearDown(() { - jacket = null; - dummyChild = null; - }); - - // TODO: Add tests that exercise the actual ReactJS 16 error lifecycle methods once implemented. - group('catches component errors', () { - List> calls; - DivElement mountNode; - - void verifyReact16ErrorHandlingWithoutErrorBoundary() { - mountNode = new DivElement(); - document.body.append(mountNode); - var jacketOfFlawedComponentWithNoErrorBoundary = mount(Flawed()(), mountNode: mountNode); - expect(mountNode.children, isNotEmpty, reason: 'test setup sanity check'); - jacketOfFlawedComponentWithNoErrorBoundary.getNode().click(); - expect(mountNode.children, isEmpty, - reason: 'rendered trees not wrapped in an ErrorBoundary ' - 'should get unmounted when an error is thrown within child component lifecycle methods'); - - mountNode.remove(); - mountNode = new DivElement(); - document.body.append(mountNode); - } - - setUp(() { - // Verify the behavior of a component that throws when it is not wrapped in an error boundary first - verifyReact16ErrorHandlingWithoutErrorBoundary(); - - calls = []; - jacket = mount( - (ErrorBoundary() - ..onComponentDidCatch = (err, info) { - calls.add({'onComponentDidCatch': [err, info]}); - } - )(Flawed()()), - mountNode: mountNode, - ); - expect(mountNode.children, isNotEmpty, reason: 'test setup sanity check'); - // Cause an error to be thrown within a ReactJS lifecycle method - jacket.getNode().click(); - }); - - tearDown(() { - mountNode.remove(); - mountNode = null; - }); - - test('and calls `props.onComponentDidCatch`', () { - expect(calls.single.keys, ['onComponentDidCatch']); - final errArg = calls.single['onComponentDidCatch'][0]; - expect(errArg, isA()); - - final infoArg = calls.single['onComponentDidCatch'][1]; - expect(infoArg, isNotNull); - }); - - test('and re-renders the tree as a result', () { - expect(mountNode.children, isNotEmpty, - reason: 'rendered trees wrapped in an ErrorBoundary ' - 'should NOT get unmounted when an error is thrown within child component lifecycle methods'); - }); - - test('does not throw a null exception when `props.onComponentDidCatch` is not set', () { - jacket = mount(ErrorBoundary()((Flawed()..addTestId('flawed'))()), mountNode: mountNode); - // The click causes the componentDidCatch lifecycle method to execute - // and we want to ensure that no Dart null error is thrown as a result of no consumer prop callback being set. - expect(() => jacket.getNode().click(), returnsNormally); - }); - }); - - test('initializes with the expected default prop values', () { - jacket = mount(ErrorBoundary()(dummyChild)); - - expect(() => ErrorBoundary(jacket.getProps()).fallbackUIRenderer(null, null), throwsUnimplementedError); - }); - - test('initializes with the expected initial state values', () { - jacket = mount(ErrorBoundary()(dummyChild)); - - expect(jacket.getDartInstance().state.hasError, isFalse); - }); - - group('renders', () { - test('its child when `state.error` is false', () { - jacket = mount(ErrorBoundary()(dummyChild)); - expect(jacket.getDartInstance().state.hasError, isFalse, reason: 'test setup sanity check'); - - expect(jacket.getNode().text, 'hi there'); - }); - - group('fallback UI when `state.error` is true', () { - test('', () { - jacket = mount(ErrorBoundary()(dummyChild)); - final component = jacket.getDartInstance(); - - // Using throws for now since this is temporary, and the throwsUnimplementedError doesn't work here for some reason - expect(() => component.setState(component.newState()..hasError = true), throws); - }); - - // TODO: Update this test to assert the error / component stack values passed to the callback once the actual ReactJS 16 error lifecycle methods are implemented. - test('and props.fallbackUIRenderer is set', () { - ReactElement _fallbackUIRenderer(_, __) { - return Dom.h4()('Something super not awesome just happened.'); - } - - jacket = mount((ErrorBoundary()..fallbackUIRenderer = _fallbackUIRenderer)(dummyChild)); - final component = jacket.getDartInstance(); - component.setState(component.newState()..hasError = true); - - expect(jacket.getNode(), hasNodeName('H4')); - expect(jacket.getNode().text, 'Something super not awesome just happened.'); - }); - }); - }); - - group('throws a PropError when', () { - test('more than one child is provided', () { - expect(() => mount(ErrorBoundary()(dummyChild, dummyChild)), - throwsPropError_Value([dummyChild, dummyChild], 'children')); - }); - - test('an invalid child is provided', () { - expect(() => mount(ErrorBoundary()('oh hai')), - throwsPropError_Value(['oh hai'], 'children')); - }); - }); + sharedErrorBoundaryTests(() => ErrorBoundary()); }); } diff --git a/test/over_react/component/fixtures/custom_error_boundary_component.dart b/test/over_react/component/fixtures/custom_error_boundary_component.dart new file mode 100644 index 000000000..0df6800fb --- /dev/null +++ b/test/over_react/component/fixtures/custom_error_boundary_component.dart @@ -0,0 +1,18 @@ +import 'package:over_react/over_react.dart'; + +// ignore: uri_has_not_been_generated +part 'custom_error_boundary_component.over_react.g.dart'; + +@Factory() +// ignore: undefined_identifier +UiFactory CustomErrorBoundary = _$CustomErrorBoundary; + +@Props() +class _$CustomErrorBoundaryProps extends UiProps with ErrorBoundaryPropsMixin {} + +@State() +class _$CustomErrorBoundaryState extends UiState with ErrorBoundaryStateMixin {} + +@Component2(isErrorBoundary: true) +class CustomErrorBoundaryComponent extends UiStatefulComponent2 + with ErrorBoundaryMixin {} diff --git a/test/over_react/component/fixtures/custom_error_boundary_component.over_react.g.dart b/test/over_react/component/fixtures/custom_error_boundary_component.over_react.g.dart new file mode 100644 index 000000000..ca0444b57 --- /dev/null +++ b/test/over_react/component/fixtures/custom_error_boundary_component.over_react.g.dart @@ -0,0 +1,240 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'custom_error_boundary_component.dart'; + +// ************************************************************************** +// OverReactBuilder (package:over_react/src/builder.dart) +// ************************************************************************** + +// React component factory implementation. +// +// Registers component implementation and links type meta to builder factory. +final $CustomErrorBoundaryComponentFactory = registerComponent2( + () => new _$CustomErrorBoundaryComponent(), + builderFactory: CustomErrorBoundary, + componentClass: CustomErrorBoundaryComponent, + isWrapper: false, + parentType: null, + displayName: 'CustomErrorBoundary', + skipMethods: const [], +); + +abstract class _$CustomErrorBoundaryPropsAccessorsMixin + implements _$CustomErrorBoundaryProps { + @override + Map get props; + + /* GENERATED CONSTANTS */ + + static const List $props = const []; + static const List $propKeys = const []; +} + +const PropsMeta _$metaForCustomErrorBoundaryProps = const PropsMeta( + fields: _$CustomErrorBoundaryPropsAccessorsMixin.$props, + keys: _$CustomErrorBoundaryPropsAccessorsMixin.$propKeys, +); + +class CustomErrorBoundaryProps extends _$CustomErrorBoundaryProps + with _$CustomErrorBoundaryPropsAccessorsMixin { + static const PropsMeta meta = _$metaForCustomErrorBoundaryProps; +} + +_$$CustomErrorBoundaryProps _$CustomErrorBoundary([Map backingProps]) => + backingProps == null + ? new _$$CustomErrorBoundaryProps$JsMap(new JsBackedMap()) + : new _$$CustomErrorBoundaryProps(backingProps); + +// Concrete props implementation. +// +// Implements constructor and backing map, and links up to generated component factory. +abstract class _$$CustomErrorBoundaryProps extends _$CustomErrorBoundaryProps + with _$CustomErrorBoundaryPropsAccessorsMixin + implements CustomErrorBoundaryProps { + _$$CustomErrorBoundaryProps._(); + + factory _$$CustomErrorBoundaryProps(Map backingMap) { + if (backingMap is JsBackedMap) { + return new _$$CustomErrorBoundaryProps$JsMap(backingMap); + } else { + return new _$$CustomErrorBoundaryProps$PlainMap(backingMap); + } + } + + /// Let [UiProps] internals know that this class has been generated. + @override + bool get $isClassGenerated => true; + + /// The [ReactComponentFactory] associated with the component built by this class. + @override + ReactComponentFactoryProxy get componentFactory => + $CustomErrorBoundaryComponentFactory; + + /// The default namespace for the prop getters/setters generated for this class. + @override + String get propKeyNamespace => 'CustomErrorBoundaryProps.'; +} + +// Concrete props implementation that can be backed by any [Map]. +class _$$CustomErrorBoundaryProps$PlainMap extends _$$CustomErrorBoundaryProps { + // This initializer of `_props` to an empty map, as well as the reassignment + // of `_props` in the constructor body is necessary to work around a DDC bug: https://github.com/dart-lang/sdk/issues/36217 + _$$CustomErrorBoundaryProps$PlainMap(Map backingMap) + : this._props = {}, + super._() { + this._props = backingMap ?? {}; + } + + /// The backing props map proxied by this class. + @override + Map get props => _props; + Map _props; +} + +// Concrete props implementation that can only be backed by [JsMap], +// allowing dart2js to compile more optimal code for key-value pair reads/writes. +class _$$CustomErrorBoundaryProps$JsMap extends _$$CustomErrorBoundaryProps { + // This initializer of `_props` to an empty map, as well as the reassignment + // of `_props` in the constructor body is necessary to work around a DDC bug: https://github.com/dart-lang/sdk/issues/36217 + _$$CustomErrorBoundaryProps$JsMap(JsBackedMap backingMap) + : this._props = new JsBackedMap(), + super._() { + this._props = backingMap ?? new JsBackedMap(); + } + + /// The backing props map proxied by this class. + @override + JsBackedMap get props => _props; + JsBackedMap _props; +} + +abstract class _$CustomErrorBoundaryStateAccessorsMixin + implements _$CustomErrorBoundaryState { + @override + Map get state; + + /* GENERATED CONSTANTS */ + + static const List $state = const []; + static const List $stateKeys = const []; +} + +const StateMeta _$metaForCustomErrorBoundaryState = const StateMeta( + fields: _$CustomErrorBoundaryStateAccessorsMixin.$state, + keys: _$CustomErrorBoundaryStateAccessorsMixin.$stateKeys, +); + +class CustomErrorBoundaryState extends _$CustomErrorBoundaryState + with _$CustomErrorBoundaryStateAccessorsMixin { + static const StateMeta meta = _$metaForCustomErrorBoundaryState; +} + +// Concrete state implementation. +// +// Implements constructor and backing map. +abstract class _$$CustomErrorBoundaryState extends _$CustomErrorBoundaryState + with _$CustomErrorBoundaryStateAccessorsMixin + implements CustomErrorBoundaryState { + _$$CustomErrorBoundaryState._(); + + factory _$$CustomErrorBoundaryState(Map backingMap) { + if (backingMap is JsBackedMap) { + return new _$$CustomErrorBoundaryState$JsMap(backingMap); + } else { + return new _$$CustomErrorBoundaryState$PlainMap(backingMap); + } + } + + /// Let [UiState] internals know that this class has been generated. + @override + bool get $isClassGenerated => true; +} + +// Concrete state implementation that can be backed by any [Map]. +class _$$CustomErrorBoundaryState$PlainMap extends _$$CustomErrorBoundaryState { + // This initializer of `_state` to an empty map, as well as the reassignment + // of `_state` in the constructor body is necessary to work around a DDC bug: https://github.com/dart-lang/sdk/issues/36217 + _$$CustomErrorBoundaryState$PlainMap(Map backingMap) + : this._state = {}, + super._() { + this._state = backingMap ?? {}; + } + + /// The backing state map proxied by this class. + @override + Map get state => _state; + Map _state; +} + +// Concrete state implementation that can only be backed by [JsMap], +// allowing dart2js to compile more optimal code for key-value pair reads/writes. +class _$$CustomErrorBoundaryState$JsMap extends _$$CustomErrorBoundaryState { + // This initializer of `_state` to an empty map, as well as the reassignment + // of `_state` in the constructor body is necessary to work around a DDC bug: https://github.com/dart-lang/sdk/issues/36217 + _$$CustomErrorBoundaryState$JsMap(JsBackedMap backingMap) + : this._state = new JsBackedMap(), + super._() { + this._state = backingMap ?? new JsBackedMap(); + } + + /// The backing state map proxied by this class. + @override + JsBackedMap get state => _state; + JsBackedMap _state; +} + +// Concrete component implementation mixin. +// +// Implements typed props/state factories, defaults `consumedPropKeys` to the keys +// generated for the associated props class. +class _$CustomErrorBoundaryComponent extends CustomErrorBoundaryComponent { + _$$CustomErrorBoundaryProps$JsMap _cachedTypedProps; + + @override + _$$CustomErrorBoundaryProps$JsMap get props => _cachedTypedProps; + + @override + set props(Map value) { + super.props = value; + _cachedTypedProps = typedPropsFactoryJs(value); + } + + @override + _$$CustomErrorBoundaryProps$JsMap typedPropsFactoryJs( + JsBackedMap backingMap) => + new _$$CustomErrorBoundaryProps$JsMap(backingMap); + + @override + _$$CustomErrorBoundaryProps typedPropsFactory(Map backingMap) => + new _$$CustomErrorBoundaryProps(backingMap); + + _$$CustomErrorBoundaryState$JsMap _cachedTypedState; + @override + _$$CustomErrorBoundaryState$JsMap get state => _cachedTypedState; + + @override + set state(Map value) { + super.state = value; + _cachedTypedState = typedStateFactoryJs(value); + } + + @override + _$$CustomErrorBoundaryState$JsMap typedStateFactoryJs( + JsBackedMap backingMap) => + new _$$CustomErrorBoundaryState$JsMap(backingMap); + + @override + _$$CustomErrorBoundaryState typedStateFactory(Map backingMap) => + new _$$CustomErrorBoundaryState(backingMap); + + /// Let [UiComponent] internals know that this class has been generated. + @override + bool get $isClassGenerated => true; + + /// The default consumed props, taken from _$CustomErrorBoundaryProps. + /// Used in [UiProps.consumedProps] if [consumedProps] is not overridden. + @override + final List $defaultConsumedProps = const [ + _$metaForCustomErrorBoundaryProps + ]; +} diff --git a/test/over_react/component/shared_error_boundary_tests.dart b/test/over_react/component/shared_error_boundary_tests.dart new file mode 100644 index 000000000..9154d50b1 --- /dev/null +++ b/test/over_react/component/shared_error_boundary_tests.dart @@ -0,0 +1,128 @@ +import 'dart:html'; +import 'package:over_react/over_react.dart'; +import 'package:over_react_test/over_react_test.dart'; +import 'package:react/react_client/react_interop.dart'; +import 'package:test/test.dart'; + +import './fixtures/flawed_component.dart'; + +void sharedErrorBoundaryTests(BuilderOnlyUiFactory builder) { + TestJacket jacket; + ReactElement dummyChild; + + setUp(() { + dummyChild = Dom.div()('hi there'); + }); + + tearDown(() { + jacket = null; + dummyChild = null; + }); + + // TODO: Add tests that exercise the actual ReactJS 16 error lifecycle methods once implemented. + group('catches component errors', () { + List> calls; + DivElement mountNode; + + void verifyReact16ErrorHandlingWithoutErrorBoundary() { + mountNode = new DivElement(); + document.body.append(mountNode); + var jacketOfFlawedComponentWithNoErrorBoundary = mount(Flawed()(), mountNode: mountNode); + expect(mountNode.children, isNotEmpty, reason: 'test setup sanity check'); + jacketOfFlawedComponentWithNoErrorBoundary.getNode().click(); + expect(mountNode.children, isEmpty, + reason: 'rendered trees not wrapped in an ErrorBoundary ' + 'should get unmounted when an error is thrown within child component lifecycle methods'); + + mountNode.remove(); + mountNode = new DivElement(); + document.body.append(mountNode); + } + + setUp(() { + // Verify the behavior of a component that throws when it is not wrapped in an error boundary first + verifyReact16ErrorHandlingWithoutErrorBoundary(); + + calls = []; + jacket = mount( + (builder() + ..addProps(new ErrorBoundaryPropsMapView({})..onComponentDidCatch = (err, info) { + calls.add({'onComponentDidCatch': [err, info]}); + }) + )(Flawed()()), + mountNode: mountNode, + ); + expect(mountNode.children, isNotEmpty, reason: 'test setup sanity check'); + // Cause an error to be thrown within a ReactJS lifecycle method + jacket.getNode().click(); + }); + + tearDown(() { + mountNode.remove(); + mountNode = null; + }); + + test('and calls `props.onComponentDidCatch`', () { + expect(calls.single.keys, ['onComponentDidCatch']); + final errArg = calls.single['onComponentDidCatch'][0]; + expect(errArg, isA()); + + final infoArg = calls.single['onComponentDidCatch'][1]; + expect(infoArg, isA()); + }); + + test('and sets `state.hasError` to true as a result', () { + expect(jacket.getDartInstance().state.hasError, isTrue); + }); + + test('and re-renders the tree as a result', () { + expect(mountNode.children, isNotEmpty, + reason: 'rendered trees wrapped in an ErrorBoundary ' + 'should NOT get unmounted when an error is thrown within child component lifecycle methods'); + }); + + test('does not throw a null exception when `props.onComponentDidCatch` is not set', () { + jacket = mount(builder()((Flawed()..addTestId('flawed'))()), mountNode: mountNode); + // The click causes the componentDidCatch lifecycle method to execute + // and we want to ensure that no Dart null error is thrown as a result of no consumer prop callback being set. + expect(() => jacket.getNode().click(), returnsNormally); + }); + }); + + test('initializes with the expected initial state values', () { + jacket = mount(builder()(dummyChild)); + + expect(jacket.getDartInstance().state.hasError, isFalse); + }); + + group('renders', () { + test('its children when `state.error` is false', () { + jacket = mount(builder()(dummyChild)); + expect(jacket.getDartInstance().state.hasError, isFalse, reason: 'test setup sanity check'); + + expect(jacket.getNode().text, 'hi there'); + }); + + test('its children when `state.error` is true and props.fallbackUIRenderer is not set', () { + jacket = mount(builder()(dummyChild)); + expect(jacket.getDartInstance().state.hasError, isFalse, reason: 'test setup sanity check'); + final component = jacket.getDartInstance(); + component.setState(component.newState()..hasError = true); + + expect(jacket.getNode().text, 'hi there'); + }); + + test('fallback UI when `state.error` is true and props.fallbackUIRenderer is set', () { + ReactElement _fallbackUIRenderer() { + return Dom.h4()('Something super not awesome just happened.'); + } + + jacket = mount((builder()..addProps(new ErrorBoundaryPropsMapView({})..fallbackUIRenderer = _fallbackUIRenderer))(dummyChild)); + final component = jacket.getDartInstance(); + component.setState(component.newState()..hasError = true); + + expect(jacket.getNode(), hasNodeName('H4')); + expect(jacket.getNode().text, 'Something super not awesome just happened.'); + }); + }); +} diff --git a/test/over_react_component_test.dart b/test/over_react_component_test.dart index 20ab6823e..c3d2f4d5f 100644 --- a/test/over_react_component_test.dart +++ b/test/over_react_component_test.dart @@ -25,6 +25,7 @@ import 'package:test/test.dart'; import 'over_react/component/abstract_transition_test.dart' as abstract_transition_test; import 'over_react/component/dom_components_test.dart' as dom_components_test; +import 'over_react/component/error_boundary_mixin_test.dart' as error_boundary_mixin_test; import 'over_react/component/error_boundary_test.dart' as error_boundary_test; import 'over_react/component/prop_mixins_test.dart' as prop_mixins_test; import 'over_react/component/resize_sensor_test.dart' as resize_sensor_test; @@ -36,6 +37,7 @@ void main() { enableTestMode(); abstract_transition_test.main(); + error_boundary_mixin_test.main(); error_boundary_test.main(); dom_components_test.main(); prop_mixins_test.main(); diff --git a/web/component2/src/demos/custom_error_boundary.dart b/web/component2/src/demos/custom_error_boundary.dart index 5b9dda744..cb9a0df67 100644 --- a/web/component2/src/demos/custom_error_boundary.dart +++ b/web/component2/src/demos/custom_error_boundary.dart @@ -1,7 +1,6 @@ import 'package:over_react/over_react.dart'; import 'package:react/react_client/react_interop.dart'; -// ignore: uri_has_not_been_generated part 'custom_error_boundary.over_react.g.dart'; @Factory() @@ -9,50 +8,21 @@ part 'custom_error_boundary.over_react.g.dart'; UiFactory CustomErrorBoundary = _$CustomErrorBoundary; @Props() -class _$CustomErrorBoundaryProps extends UiProps { - Function(dynamic error, ReactErrorInfo info) onComponentDidCatch; -} - -// AF-3369 This will be removed once the transition to Dart 2 is complete. -// ignore: mixin_of_non_class, undefined_class -class CustomErrorBoundaryProps extends _$CustomErrorBoundaryProps with _$CustomErrorBoundaryPropsAccessorsMixin { - // ignore: undefined_identifier, undefined_class, const_initialized_with_non_constant_value - static const PropsMeta meta = _$metaForCustomErrorBoundaryProps; -} +class _$CustomErrorBoundaryProps extends UiProps with ErrorBoundaryPropsMixin {} @State() -class _$CustomErrorBoundaryState extends UiState { - bool hasError; -} - -// AF-3369 This will be removed once the transition to Dart 2 is complete. -// ignore: mixin_of_non_class, undefined_class -class CustomErrorBoundaryState extends _$CustomErrorBoundaryState with _$CustomErrorBoundaryStateAccessorsMixin { - // ignore: undefined_identifier, undefined_class, const_initialized_with_non_constant_value - static const StateMeta meta = _$metaForCustomErrorBoundaryState; -} +class _$CustomErrorBoundaryState extends UiState with ErrorBoundaryStateMixin {} @Component2(isErrorBoundary: true) -class CustomErrorBoundaryComponent extends UiStatefulComponent2 { +class CustomErrorBoundaryComponent extends UiStatefulComponent2 + with ErrorBoundaryMixin { @override - Map getInitialState() => (newState()..hasError = false); + Map getDefaultProps() => newProps() + ..fallbackUIRenderer = _renderFallbackUI; - @override - CustomErrorBoundaryState getDerivedStateFromError(_) { - return newState()..hasError = true; - } - - @override - void componentDidCatch(dynamic error, ReactErrorInfo info) { - if (props.onComponentDidCatch != null) { - props.onComponentDidCatch(error, info); - } - } - - @override - render() { - return state.hasError - ? Dom.h3()('This is a custom error message!') - : props.children; + ReactElement _renderFallbackUI() { + return Dom.div()( + Dom.h3()('Error!'), + ); } } diff --git a/web/component2/src/demos/custom_error_boundary.over_react.g.dart b/web/component2/src/demos/custom_error_boundary.over_react.g.dart index e56c1aa73..d326cbf7d 100644 --- a/web/component2/src/demos/custom_error_boundary.over_react.g.dart +++ b/web/component2/src/demos/custom_error_boundary.over_react.g.dart @@ -24,29 +24,10 @@ abstract class _$CustomErrorBoundaryPropsAccessorsMixin @override Map get props; - /// - @override - Function(dynamic error, ReactErrorInfo info) get onComponentDidCatch => - props[_$key__onComponentDidCatch___$CustomErrorBoundaryProps] ?? - null; // Add ` ?? null` to workaround DDC bug: ; - /// - @override - set onComponentDidCatch(Function(dynamic error, ReactErrorInfo info) value) => - props[_$key__onComponentDidCatch___$CustomErrorBoundaryProps] = value; /* GENERATED CONSTANTS */ - static const PropDescriptor - _$prop__onComponentDidCatch___$CustomErrorBoundaryProps = - const PropDescriptor( - _$key__onComponentDidCatch___$CustomErrorBoundaryProps); - static const String _$key__onComponentDidCatch___$CustomErrorBoundaryProps = - 'CustomErrorBoundaryProps.onComponentDidCatch'; - - static const List $props = const [ - _$prop__onComponentDidCatch___$CustomErrorBoundaryProps - ]; - static const List $propKeys = const [ - _$key__onComponentDidCatch___$CustomErrorBoundaryProps - ]; + + static const List $props = const []; + static const List $propKeys = const []; } const PropsMeta _$metaForCustomErrorBoundaryProps = const PropsMeta( @@ -54,6 +35,11 @@ const PropsMeta _$metaForCustomErrorBoundaryProps = const PropsMeta( keys: _$CustomErrorBoundaryPropsAccessorsMixin.$propKeys, ); +class CustomErrorBoundaryProps extends _$CustomErrorBoundaryProps + with _$CustomErrorBoundaryPropsAccessorsMixin { + static const PropsMeta meta = _$metaForCustomErrorBoundaryProps; +} + _$$CustomErrorBoundaryProps _$CustomErrorBoundary([Map backingProps]) => backingProps == null ? new _$$CustomErrorBoundaryProps$JsMap(new JsBackedMap()) @@ -127,27 +113,10 @@ abstract class _$CustomErrorBoundaryStateAccessorsMixin @override Map get state; - /// - @override - bool get hasError => - state[_$key__hasError___$CustomErrorBoundaryState] ?? - null; // Add ` ?? null` to workaround DDC bug: ; - /// - @override - set hasError(bool value) => - state[_$key__hasError___$CustomErrorBoundaryState] = value; /* GENERATED CONSTANTS */ - static const StateDescriptor _$prop__hasError___$CustomErrorBoundaryState = - const StateDescriptor(_$key__hasError___$CustomErrorBoundaryState); - static const String _$key__hasError___$CustomErrorBoundaryState = - 'CustomErrorBoundaryState.hasError'; - static const List $state = const [ - _$prop__hasError___$CustomErrorBoundaryState - ]; - static const List $stateKeys = const [ - _$key__hasError___$CustomErrorBoundaryState - ]; + static const List $state = const []; + static const List $stateKeys = const []; } const StateMeta _$metaForCustomErrorBoundaryState = const StateMeta( @@ -155,6 +124,11 @@ const StateMeta _$metaForCustomErrorBoundaryState = const StateMeta( keys: _$CustomErrorBoundaryStateAccessorsMixin.$stateKeys, ); +class CustomErrorBoundaryState extends _$CustomErrorBoundaryState + with _$CustomErrorBoundaryStateAccessorsMixin { + static const StateMeta meta = _$metaForCustomErrorBoundaryState; +} + // Concrete state implementation. // // Implements constructor and backing map. From ee27b777ff87b6fead682947403ad8a8737c71a8 Mon Sep 17 00:00:00 2001 From: Aaron Lademann Date: Fri, 17 May 2019 07:48:49 -0700 Subject: [PATCH 2/3] Export ReactErrorInfo --- lib/over_react.dart | 1 + lib/src/component/error_boundary.dart | 1 - lib/src/component/error_boundary_mixins.dart | 2 -- test/over_react/component/shared_error_boundary_tests.dart | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/over_react.dart b/lib/over_react.dart index 7798df978..f8e6b04ad 100644 --- a/lib/over_react.dart +++ b/lib/over_react.dart @@ -31,6 +31,7 @@ export 'package:react/react.dart' show export 'package:react/src/react_client/js_backed_map.dart' show JsBackedMap; export 'package:react/react_client.dart' show setClientConfiguration, ReactElement, ReactComponentFactoryProxy; +export 'package:react/react_client/react_interop.dart' show ReactErrorInfo; export 'src/component/abstract_transition.dart'; export 'src/component/abstract_transition_props.dart'; diff --git a/lib/src/component/error_boundary.dart b/lib/src/component/error_boundary.dart index 238993edc..3db97d23c 100644 --- a/lib/src/component/error_boundary.dart +++ b/lib/src/component/error_boundary.dart @@ -1,5 +1,4 @@ import 'package:over_react/over_react.dart'; -import 'package:react/react_client.dart'; part 'error_boundary.over_react.g.dart'; diff --git a/lib/src/component/error_boundary_mixins.dart b/lib/src/component/error_boundary_mixins.dart index b2a02cbbf..4750e4ca2 100644 --- a/lib/src/component/error_boundary_mixins.dart +++ b/lib/src/component/error_boundary_mixins.dart @@ -1,7 +1,5 @@ import 'package:meta/meta.dart'; import 'package:over_react/over_react.dart'; -import 'package:react/react_client.dart'; -import 'package:react/react_client/react_interop.dart' show ReactErrorInfo; part 'error_boundary_mixins.over_react.g.dart'; diff --git a/test/over_react/component/shared_error_boundary_tests.dart b/test/over_react/component/shared_error_boundary_tests.dart index 9154d50b1..b7cd0788f 100644 --- a/test/over_react/component/shared_error_boundary_tests.dart +++ b/test/over_react/component/shared_error_boundary_tests.dart @@ -1,7 +1,6 @@ import 'dart:html'; import 'package:over_react/over_react.dart'; import 'package:over_react_test/over_react_test.dart'; -import 'package:react/react_client/react_interop.dart'; import 'package:test/test.dart'; import './fixtures/flawed_component.dart'; From f1d27e94c81b4c787f0becc6424e7d14d58f34f5 Mon Sep 17 00:00:00 2001 From: Aaron Lademann Date: Fri, 17 May 2019 08:23:15 -0700 Subject: [PATCH 3/3] Add ticket number for TODO --- lib/src/component/error_boundary_mixins.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/component/error_boundary_mixins.dart b/lib/src/component/error_boundary_mixins.dart index 4750e4ca2..67dd6a579 100644 --- a/lib/src/component/error_boundary_mixins.dart +++ b/lib/src/component/error_boundary_mixins.dart @@ -77,7 +77,7 @@ abstract class _$ErrorBoundaryStateMixin implements UiState { /// } mixin ErrorBoundaryMixin on UiStatefulComponent2 { - // TODO (3.1.0-wip): Convert this to use `init` once the generated setter doesn't cause an RTE + // TODO (CPLAT-5816): Convert this to use `init` once the generated setter doesn't cause an RTE @mustCallSuper @override Map getInitialState() => newState()..hasError = false;