Skip to content

Commit c277ef9

Browse files
Merge pull request #344 from Workiva/3.0.0-wip-dart1-rebased
RM-58638 Release over_react 3.0.0-alpha.0+dart1 (Includes CPLAT-7548)
2 parents 91202ac + 3ccb36d commit c277ef9

File tree

12 files changed

+305
-26
lines changed

12 files changed

+305
-26
lines changed

lib/src/component/dom_components.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ class DomProps extends component_base.UiProps
5050
DomProps(this.componentFactory, [Map props]) : this.props = props ?? ({});
5151

5252
@override
53-
final ReactDomComponentFactoryProxy componentFactory;
53+
ReactComponentFactoryProxy componentFactory;
5454

5555
@override
5656
final Map props;
@@ -76,7 +76,7 @@ class SvgProps extends component_base.UiProps
7676
SvgProps(this.componentFactory, [Map props]) : this.props = props ?? ({});
7777

7878
@override
79-
final ReactDomComponentFactoryProxy componentFactory;
79+
ReactComponentFactoryProxy componentFactory;
8080

8181
@override
8282
final Map props;

lib/src/component/error_boundary.dart

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,80 @@
1+
import 'dart:html';
2+
import 'dart:js_util' as js_util;
3+
4+
import 'package:js/js.dart';
15
import 'package:meta/meta.dart';
26
import 'package:over_react/over_react.dart';
7+
import 'package:react/react_client.dart';
8+
import 'package:react/react_client/js_interop_helpers.dart';
9+
import 'package:react/react_client/react_interop.dart' show React, ReactClassConfig, throwErrorFromJS;
310

411
// ignore: uri_has_not_been_generated
512
part 'error_boundary.over_react.g.dart';
613

14+
/// A __temporary, private JS component for use only by [ErrorBoundary]__ that utilizes its own lightweight
15+
/// JS interop to make use of the ReactJS 16 `componentDidCatch` lifecycle method to prevent consumer
16+
/// react component trees from unmounting as a result of child component errors being "uncaught".
17+
///
18+
/// > __Why this is here__
19+
/// >
20+
/// > In order to release react-dart 5.0.0 _(which upgrades to ReactJS 16)_
21+
/// without depending on Dart 2 / `Component2` (coming in react-dart 5.1.0) / `UiComponent2` (coming in over_react 3.1.0) -
22+
/// and all the new lifecycle methods that those expose, we need to ensure that - at a minimum - the `componentDidCatch`
23+
/// lifecycle method is handled by components wrapped in our [ErrorBoundary] component so that the behavior of
24+
/// an application when a component within a tree throws - is the same as it was when using ReactJS 15.
25+
/// >
26+
/// > Otherwise, the update to react-dart 5.0.0 / over_react 3.0.0 will result in consumer apps rendering completely
27+
/// "blank" screens when their trees unmount as a result of a child component throwing an error.
28+
/// This would be unexpected, unwanted - and since we will not add a Dart js-interop layer around `componentDidCatch`
29+
/// until react-dart 5.1.0 / over_react 3.1.0 - unmanageable for consumers.
30+
///
31+
/// __This will be removed in over_react 3.1.0__ once [ErrorBoundaryComponent] is extending from `UiStatefulComponent2`
32+
/// which will ensure that the [ErrorBoundaryComponent.componentDidCatch] lifecycle method has real js-interop bindings
33+
/// via react-dart 5.1.0's `Component2` base class.
34+
///
35+
/// TODO: Remove in 3.1.0
36+
final ReactElement Function([Map props, List children]) _jsErrorBoundaryComponentFactory = (() {
37+
var componentClass = React.createClass(jsifyAndAllowInterop({
38+
'displayName': 'JsErrorBoundary',
39+
'render': allowInteropCaptureThis((jsThis) {
40+
final jsProps = js_util.getProperty(jsThis, 'props');
41+
return js_util.getProperty(jsProps, 'children');
42+
}),
43+
'componentDidCatch': allowInteropCaptureThis((jsThis, error, info) {
44+
final jsProps = js_util.getProperty(jsThis, 'props');
45+
// Due to the error object being passed in from ReactJS it is a javascript object that does not get dartified.
46+
// To fix this we throw the error again from Dart to the JS side and catch it Dart side which re-dartifies it.
47+
try {
48+
throwErrorFromJS(error);
49+
} catch (error, stack) {
50+
final callback = js_util.getProperty(jsProps, 'onComponentDidCatch');
51+
52+
if (callback != null) {
53+
callback(error, info);
54+
}
55+
}
56+
}),
57+
}));
58+
59+
// Despite what the ReactJS docs say about only needing _either_ componentDidCatch or getDerivedStateFromError
60+
// in order to define an "error boundary" component, that is not actually the case.
61+
//
62+
// The tree will never get re-rendered after an error is caught unless both are defined.
63+
// ignore: argument_type_not_assignable
64+
js_util.setProperty(componentClass, 'getDerivedStateFromError', allowInterop((_) => js_util.newObject()));
65+
66+
var reactFactory = React.createFactory(componentClass);
67+
68+
return ([Map props = const {}, List children = const []]) {
69+
return reactFactory(jsifyAndAllowInterop(props), listifyChildren(children));
70+
};
71+
})();
72+
773
// TODO: Need to type the second argument once react-dart implements bindings for the ReactJS "componentStack".
8-
typedef _ComponentDidCatchCallback(Error error, /*ComponentStack*/dynamic componentStack);
74+
typedef _ComponentDidCatchCallback(/*Error*/dynamic error, /*ComponentStack*/dynamic componentStack);
975

1076
// TODO: Need to type the second argument once react-dart implements bindings for the ReactJS "componentStack".
11-
typedef ReactElement _FallbackUiRenderer(Error error, /*ComponentStack*/dynamic componentStack);
77+
typedef ReactElement _FallbackUiRenderer(/*Error*/dynamic error, /*ComponentStack*/dynamic componentStack);
1278

1379
/// A higher-order component that will catch ReactJS errors anywhere within the child component tree and
1480
/// display a fallback UI instead of the component tree that crashed.
@@ -93,12 +159,19 @@ class ErrorBoundaryComponent<T extends ErrorBoundaryProps, S extends ErrorBounda
93159
}
94160

95161
@override
96-
render() => state.hasError
97-
? props.fallbackUIRenderer(_error, _componentStack)
98-
: props.children.single;
162+
render() {
163+
// TODO: 3.1.0 - Remove the `_jsErrorBoundaryComponentFactory`, and restore just the children of it once this component is extending from `UiStatefulComponent2`.
164+
return _jsErrorBoundaryComponentFactory({
165+
'onComponentDidCatch': props.onComponentDidCatch
166+
},
167+
state.hasError
168+
? [props.fallbackUIRenderer(_error, _componentStack)]
169+
: props.children
170+
);
171+
}
99172

100173
ReactElement _renderDefaultFallbackUI(_, __) =>
101-
throw new UnimplementedError('Fallback UI will not be supported until support for ReactJS 16 is released in version 3.0.0');
174+
throw new UnimplementedError('Fallback UI will not be supported until support for ReactJS 16 lifecycle methods is released in version 3.1.0');
102175

103176
@mustCallSuper
104177
@override

pubspec.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: over_react
2-
version: 2.5.0+dart1
2+
version: 3.0.0-alpha.0+dart1
33
description: A library for building statically-typed React UI components using Dart.
44
homepage: https://github.com/Workiva/over_react/
55
authors:
@@ -16,11 +16,11 @@ dependencies:
1616
logging: ">=0.11.3+2 <1.0.0"
1717
meta: ^1.1.6
1818
path: ^1.5.1
19-
react: ^4.9.0
19+
react: ^5.0.0-alpha
2020
source_span: ^1.4.1
2121
transformer_utils: ^0.1.5
2222
w_common: ^1.13.0
23-
w_flux: ^2.9.5
23+
w_flux: ^2.10.4
2424
platform_detect: ^1.3.4
2525
quiver: ">=0.25.0 <=0.28.0" # 0.28.0+ is Dart 2 only
2626
dev_dependencies:
@@ -31,7 +31,7 @@ dev_dependencies:
3131
dart_dev: ^1.9.6
3232
dependency_validator: ^1.2.2
3333
mockito: ^2.2.2
34-
over_react_test: '>=1.6.0 <3.0.0'
34+
over_react_test: ^2.5.2
3535
source_gen: ^0.7.4+3
3636
test: ^0.12.34
3737

test/over_react/component/error_boundary_test.dart

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@
1515
@Timeout(const Duration(seconds: 2))
1616
library error_boundary_test;
1717

18+
import 'dart:html';
19+
import 'dart:js';
1820
import 'package:over_react/over_react.dart';
1921
import 'package:over_react_test/over_react_test.dart';
2022
import 'package:test/test.dart';
2123

24+
import './fixtures/flawed_component.dart';
25+
2226
void main() {
2327
group('ErrorBoundary', () {
2428
TestJacket<ErrorBoundaryComponent> jacket;
@@ -34,6 +38,70 @@ void main() {
3438
});
3539

3640
// TODO: Add tests that exercise the actual ReactJS 16 error lifecycle methods once implemented.
41+
group('catches component errors', () {
42+
List<Map<String, List>> calls;
43+
DivElement mountNode;
44+
45+
void verifyReact16ErrorHandlingWithoutErrorBoundary() {
46+
mountNode = new DivElement();
47+
document.body.append(mountNode);
48+
var jacketOfFlawedComponentWithNoErrorBoundary = mount(Flawed()(), mountNode: mountNode);
49+
expect(mountNode.children, isNotEmpty, reason: 'test setup sanity check');
50+
jacketOfFlawedComponentWithNoErrorBoundary.getNode().click();
51+
expect(mountNode.children, isEmpty,
52+
reason: 'rendered trees not wrapped in an ErrorBoundary '
53+
'should get unmounted when an error is thrown within child component lifecycle methods');
54+
55+
mountNode.remove();
56+
mountNode = new DivElement();
57+
document.body.append(mountNode);
58+
}
59+
60+
setUp(() {
61+
// Verify the behavior of a component that throws when it is not wrapped in an error boundary first
62+
verifyReact16ErrorHandlingWithoutErrorBoundary();
63+
64+
calls = [];
65+
jacket = mount(
66+
(ErrorBoundary()
67+
..onComponentDidCatch = (err, info) {
68+
calls.add({'onComponentDidCatch': [err, info]});
69+
}
70+
)(Flawed()()),
71+
mountNode: mountNode,
72+
);
73+
expect(mountNode.children, isNotEmpty, reason: 'test setup sanity check');
74+
// Cause an error to be thrown within a ReactJS lifecycle method
75+
jacket.getNode().click();
76+
});
77+
78+
tearDown(() {
79+
mountNode.remove();
80+
mountNode = null;
81+
});
82+
83+
test('and calls `props.onComponentDidCatch`', () {
84+
expect(calls.single.keys, ['onComponentDidCatch']);
85+
final errArg = calls.single['onComponentDidCatch'][0];
86+
expect(errArg.toString(), contains('FlawedComponentException: I was thrown from inside FlawedComponent.componentWillUpdate!'));
87+
88+
final infoArg = calls.single['onComponentDidCatch'][1];
89+
expect(infoArg, isNotNull);
90+
});
91+
92+
test('and re-renders the tree as a result', () {
93+
expect(mountNode.children, isNotEmpty,
94+
reason: 'rendered trees wrapped in an ErrorBoundary '
95+
'should NOT get unmounted when an error is thrown within child component lifecycle methods');
96+
});
97+
98+
test('does not throw a null exception when `props.onComponentDidCatch` is not set', () {
99+
jacket = mount(ErrorBoundary()((Flawed()..addTestId('flawed'))()), mountNode: mountNode);
100+
// The click causes the componentDidCatch lifecycle method to execute
101+
// and we want to ensure that no Dart null error is thrown as a result of no consumer prop callback being set.
102+
expect(() => jacket.getNode().click(), returnsNormally);
103+
});
104+
});
37105

38106
test('initializes with the expected default prop values', () {
39107
jacket = mount(ErrorBoundary()(dummyChild));
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import 'package:over_react/over_react.dart';
2+
3+
// ignore: uri_has_not_been_generated
4+
part 'flawed_component.over_react.g.dart';
5+
6+
@Factory()
7+
// ignore: undefined_identifier
8+
UiFactory<FlawedProps> Flawed = _$Flawed;
9+
10+
@Props()
11+
class _$FlawedProps extends UiProps {}
12+
13+
// AF-3369 This will be removed once the transition to Dart 2 is complete.
14+
// ignore: mixin_of_non_class, undefined_class
15+
class FlawedProps extends _$FlawedProps with _$FlawedPropsAccessorsMixin {
16+
// ignore: undefined_identifier, undefined_class, const_initialized_with_non_constant_value
17+
static const PropsMeta meta = _$metaForFlawedProps;
18+
}
19+
20+
@State()
21+
class _$FlawedState extends UiState {
22+
bool hasError;
23+
}
24+
25+
// AF-3369 This will be removed once the transition to Dart 2 is complete.
26+
// ignore: mixin_of_non_class, undefined_class
27+
class FlawedState extends _$FlawedState with _$FlawedStateAccessorsMixin {
28+
// ignore: undefined_identifier, undefined_class, const_initialized_with_non_constant_value
29+
static const StateMeta meta = _$metaForFlawedState;
30+
}
31+
32+
@Component()
33+
class FlawedComponent extends UiStatefulComponent<FlawedProps, FlawedState> {
34+
@override
35+
Map getInitialState() => (newState()..hasError = false);
36+
37+
@override
38+
void componentWillUpdate(_, Map nextState) {
39+
final tNextState = typedStateFactory(nextState);
40+
if (tNextState.hasError && !state.hasError) {
41+
throw new FlawedComponentException();
42+
}
43+
}
44+
45+
@override
46+
render() {
47+
return (Dom.button()
48+
..addTestId('flawedButton')
49+
..onClick = (_) {
50+
setState(newState()..hasError = true);
51+
}
52+
)('oh hai');
53+
}
54+
}
55+
56+
class FlawedComponentException implements Exception {
57+
@override
58+
String toString() => 'FlawedComponentException: I was thrown from inside FlawedComponent.componentWillUpdate!';
59+
}

test/over_react/dom/fixtures/dummy_composite_component.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class TestCompositeComponentComponent extends UiComponent<TestCompositeComponent
5555

5656
@override
5757
render() {
58-
return Dom.div()('oh hai');
58+
return Dom.div()('oh hai', props.children);
5959
}
6060
}
6161

test/over_react/dom/render_test.dart

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,6 @@ main() {
9292
});
9393

9494
group('throws', () {
95-
test('when `element` is `null`', () {
96-
expect(() => react_dom.render(null, mountNode), throwsA(anything));
97-
expect(mountNode.children, isEmpty);
98-
});
99-
10095
test('when `mountNode` is `null`', () {
10196
expect(() => react_dom.render(Dom.div()('oh hai'), null), throwsA(anything));
10297
});

test/over_react/util/react_wrappers_test.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -984,8 +984,8 @@ main() {
984984
});
985985

986986
test('a String', () {
987-
expect(() => getProps('string'), throwsArgumentError);
988-
}, testOn: 'js');
987+
expect(() => getProps('string'), throwsA(anything));
988+
});
989989

990990
test('null', () {
991991
expect(() => getProps(null), throwsArgumentError);
@@ -1019,6 +1019,7 @@ main() {
10191019
]));
10201020
});
10211021

1022+
// TODO: 3.0.0 this is failing on Dart 2 dart2js tests only.
10221023
test('a JS composite component', () {
10231024
var calls = [];
10241025

@@ -1040,6 +1041,7 @@ main() {
10401041
]));
10411042
});
10421043

1044+
// TODO: 3.0.0 this is failing on Dart 2 dart2js tests only.
10431045
test('a DOM component', () {
10441046
var calls = [];
10451047

web/index.dart

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,42 @@
11
import 'dart:html';
22

3+
import 'package:over_react/over_react.dart';
34
import 'package:over_react/react_dom.dart' as react_dom;
45
import 'package:react/react_client.dart' show setClientConfiguration;
56
import './demos/demos.dart';
67
import './demos/constants.dart';
8+
import 'src/demos/faulty-component.dart';
79

810
void main() {
911
setClientConfiguration();
1012

1113
react_dom.render(
12-
buttonExamplesDemo(), querySelector('$demoMountNodeSelectorPrefix--button'));
14+
ErrorBoundary()(buttonExamplesDemo()), querySelector('$demoMountNodeSelectorPrefix--button'));
1315

1416
react_dom.render(
15-
listGroupBasicDemo(), querySelector('$demoMountNodeSelectorPrefix--list-group'));
17+
ErrorBoundary()(listGroupBasicDemo()), querySelector('$demoMountNodeSelectorPrefix--list-group'));
1618

1719
react_dom.render(
18-
progressBasicDemo(), querySelector('$demoMountNodeSelectorPrefix--progress'));
20+
ErrorBoundary()(progressBasicDemo()), querySelector('$demoMountNodeSelectorPrefix--progress'));
1921

2022
react_dom.render(
21-
tagBasicDemo(), querySelector('$demoMountNodeSelectorPrefix--tag'));
23+
ErrorBoundary()(tagBasicDemo()), querySelector('$demoMountNodeSelectorPrefix--tag'));
2224

2325
react_dom.render(
24-
checkboxToggleButtonDemo(), querySelector('$demoMountNodeSelectorPrefix--checkbox-toggle'));
26+
ErrorBoundary()(checkboxToggleButtonDemo()), querySelector('$demoMountNodeSelectorPrefix--checkbox-toggle'));
2527

2628
react_dom.render(
27-
radioToggleButtonDemo(), querySelector('$demoMountNodeSelectorPrefix--radio-toggle'));
29+
ErrorBoundary()(radioToggleButtonDemo()), querySelector('$demoMountNodeSelectorPrefix--radio-toggle'));
30+
31+
react_dom.render(
32+
(ErrorBoundary()
33+
..onComponentDidCatch = (error, info) {
34+
print('Consumer props.onComponentDidCatch($error, $info)');
35+
}
36+
)(Faulty()()),
37+
querySelector('$demoMountNodeSelectorPrefix--faulty-component'),
38+
);
39+
40+
react_dom.render(
41+
Faulty()(), querySelector('$demoMountNodeSelectorPrefix--faulty-component-without-error-boundary'));
2842
}

0 commit comments

Comments
 (0)