Skip to content

Commit c59ba12

Browse files
Merge pull request #382 from Workiva/fix-react-dom-typings
FED-1910 Fix react_dom API typings, add tests
2 parents c0ccd1b + 9f6993e commit c59ba12

9 files changed

+241
-18
lines changed

CHANGELOG.md

+19
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
## 7.0.1
2+
3+
Breaking change - fix nullability/typings for `ReactDom.findDomNode` and `ReactDom.render` from `package:react/react_client/react_interop.dart`:
4+
```diff
5+
-Element findDOMNode(dynamic object);
6+
+Element? findDOMNode(dynamic object);
7+
8+
-ReactComponent render(ReactElement component, Element element);
9+
+dynamic render(dynamic component, Element element);
10+
```
11+
12+
The previous typings were incorrect:
13+
- `findDOMNode` returns null in many cases, but its return type was incorrectly non-nullable.
14+
- `render` returns null for some cases (function components, `null`), `Element` for DOM components, and `CharacterData` for strings and numbers, but was incorrectly typed as non-nullable `ReactComponent`. The `component` argument also accepts `null` and other "ReactNode" arguments to rendered, but its type is incorrectly non-nullable and restricted to just `ReactElement`.
15+
16+
These typings only affect these APIs under the `ReactDom` class in package:react/react_client/react_interop.dart, and not the top-level `Function`-typed `findDOMNode` and `render` APIs exported from `package:react/react_dom.dart`, which most consumers use.
17+
18+
Because these typings were incorrect and will lead to runtime errors in some cases, and the changes have a low likelihood of causing breakages, we feel it's appropriate to release these changes as a patch.
19+
120
## 7.0.0
221

322
- Migrate to null safety

lib/react_client/react_interop.dart

+2-2
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,8 @@ ReactComponentFactoryProxy memo2(ReactComponentFactoryProxy factory,
274274
}
275275

276276
abstract class ReactDom {
277-
static Element findDOMNode(object) => ReactDOM.findDOMNode(object);
278-
static ReactComponent render(ReactElement component, Element element) => ReactDOM.render(component, element);
277+
static Element? findDOMNode(dynamic object) => ReactDOM.findDOMNode(object);
278+
static dynamic render(dynamic component, Element element) => ReactDOM.render(component, element);
279279
static bool unmountComponentAtNode(Element element) => ReactDOM.unmountComponentAtNode(element);
280280

281281
/// Returns a a portal that renders [children] into a [container].

lib/src/react_client/dart2_interop_workaround_bindings.dart

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import 'package:react/react_client/react_interop.dart';
88

99
@JS()
1010
abstract class ReactDOM {
11-
external static Element findDOMNode(object);
12-
external static ReactComponent render(ReactElement component, Element element);
11+
external static Element? findDOMNode(dynamic object);
12+
external static dynamic render(dynamic component, Element element);
1313
external static bool unmountComponentAtNode(Element element);
1414
external static ReactPortal createPortal(dynamic children, Element container);
1515
}

test/lifecycle_test.dart

+3-3
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,7 @@ main() {
480480
final mountNode = DivElement();
481481
final renderedInstance = react_dom.render(components2.SetStateTest({}), mountNode);
482482
final component = getDartComponent<LifecycleTestHelper>(renderedInstance);
483-
final renderedNode = findDomNode(renderedInstance);
483+
final renderedNode = findDomNode(renderedInstance)!;
484484
LifecycleTestHelper.staticLifecycleCalls.clear();
485485
component.setState({'shouldThrow': true});
486486

@@ -1518,7 +1518,7 @@ void sharedLifecycleTests<T extends react.Component>({
15181518
test('when shouldComponentUpdate returns false', () {
15191519
final mountNode = DivElement();
15201520
final renderedInstance = react_dom.render(SetStateTest({'shouldUpdate': false}), mountNode);
1521-
final renderedNode = findDomNode(renderedInstance);
1521+
final renderedNode = findDomNode(renderedInstance)!;
15221522
final component = getDartComponent<LifecycleTestHelper>(renderedInstance);
15231523

15241524
react_test_utils.Simulate.click(renderedNode.children.first);
@@ -1541,7 +1541,7 @@ void sharedLifecycleTests<T extends react.Component>({
15411541
test('when shouldComponentUpdate returns true', () {
15421542
final mountNode = DivElement();
15431543
final renderedInstance = react_dom.render(SetStateTest({}), mountNode);
1544-
final renderedNode = findDomNode(renderedInstance);
1544+
final renderedNode = findDomNode(renderedInstance)!;
15451545
final component = getDartComponent<LifecycleTestHelper>(renderedInstance);
15461546

15471547
react_test_utils.Simulate.click(renderedNode.children.first);

test/react_client_test.dart

+2-3
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,7 @@ main() {
146146
final jsProps = unconvertJsProps(component);
147147
for (final key in knownEventKeys) {
148148
expect(jsProps[key], isNotNull, reason: 'JS event handler prop should not be null');
149-
expect(
150-
jsProps[key], anyOf(same(originalHandlers[key]), same(allowInterop(originalHandlers[key] as Function))),
149+
expect(jsProps[key], anyOf(same(originalHandlers[key]), same(allowInterop(originalHandlers[key]!))),
151150
reason: 'JS event handler should be the original or original wrapped in allowInterop');
152151
}
153152
});
@@ -157,7 +156,7 @@ main() {
157156
final jsProps = unconvertJsProps(component);
158157
for (final key in knownEventKeys) {
159158
expect(jsProps[key], isNotNull, reason: 'JS event handler prop should not be null');
160-
expect(jsProps[key], same(allowInterop(originalHandlers[key] as Function)),
159+
expect(jsProps[key], same(allowInterop(originalHandlers[key]!)),
161160
reason: 'JS event handler prop was unexpectedly modified');
162161
}
163162
});

test/react_dom_test.dart

+192
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// ignore_for_file: deprecated_member_use_from_same_package
2+
@TestOn('browser')
3+
import 'dart:html';
4+
5+
import 'package:react/react.dart' as react;
6+
import 'package:react/react_client/react_interop.dart';
7+
import 'package:react/react_dom.dart' as react_dom;
8+
import 'package:react/react_test_utils.dart';
9+
import 'package:test/test.dart';
10+
11+
void main() {
12+
// These tests redundantly test some React behavior, but mostly serve to validate that
13+
// all supported inputs/outputs are handled properly by the Dart bindings and their typings.
14+
group('react_dom entrypoint APIs:', () {
15+
group('render', () {
16+
group(
17+
'accepts and renders content of different supported types,'
18+
' and returns a representation of what was mounted', () {
19+
test('ReactElement for a DOM component', () {
20+
final mountNode = DivElement();
21+
final result = react_dom.render(react.button({}, 'test button'), mountNode);
22+
expect(mountNode.children, [isA<ButtonElement>()]);
23+
expect(mountNode.children.single.innerText, 'test button');
24+
expect(result, isA<ButtonElement>());
25+
});
26+
27+
test('ReactElement for a class component', () {
28+
final mountNode = DivElement();
29+
final result = react_dom.render(classComponent({}), mountNode);
30+
expect(mountNode.innerText, 'class component content');
31+
expect(result, isA<ReactComponent>().having((c) => c.dartComponent, 'dartComponent', isA<_ClassComponent>()));
32+
});
33+
34+
test('ReactElement for a function component', () {
35+
final mountNode = DivElement();
36+
final result = react_dom.render(functionComponent({}), mountNode);
37+
expect(mountNode.innerText, 'function component content');
38+
expect(result, isNull);
39+
});
40+
41+
group('other "ReactNode" types:', () {
42+
test('string', () {
43+
final mountNode = DivElement();
44+
final result = react_dom.render('test string', mountNode);
45+
expect(mountNode.innerText, 'test string');
46+
expect(result, isA<Node>());
47+
});
48+
49+
test('lists and nested lists', () {
50+
final mountNode = DivElement();
51+
final result = react_dom.render([
52+
'test string',
53+
['test string 2', react.span({}, 'test span')]
54+
], mountNode);
55+
expect(mountNode.innerText, 'test string' 'test string 2' 'test span');
56+
expect(result, isA<Node>());
57+
});
58+
59+
test('number', () {
60+
final mountNode = DivElement();
61+
final result = react_dom.render(123, mountNode);
62+
expect(mountNode.innerText, '123');
63+
expect(result, isA<Node>());
64+
});
65+
66+
test('false', () {
67+
final mountNode = DivElement();
68+
react_dom.render(react.span({}, 'test content that will be cleared'), mountNode);
69+
expect(mountNode.innerText, 'test content that will be cleared');
70+
final result = react_dom.render(false, mountNode);
71+
expect(mountNode.innerText, isEmpty);
72+
expect(result, isNull);
73+
});
74+
75+
test('null', () {
76+
final mountNode = DivElement();
77+
react_dom.render(react.span({}, 'test content that will be cleared'), mountNode);
78+
expect(mountNode.innerText, 'test content that will be cleared');
79+
final result = react_dom.render(null, mountNode);
80+
expect(mountNode.innerText, isEmpty);
81+
expect(result, isNull);
82+
});
83+
});
84+
});
85+
});
86+
87+
group('unmountComponentAtNode', () {
88+
test('unmounts a React tree at a node, and returns true to indicate it has unmounted', () {
89+
final mountNode = DivElement();
90+
react_dom.render(react.span({}), mountNode);
91+
final result = react_dom.unmountComponentAtNode(mountNode);
92+
expect(result, isTrue);
93+
});
94+
95+
test('returns false when a React tree has already been unmounted', () {
96+
final mountNode = DivElement();
97+
react_dom.render(react.span({}), mountNode);
98+
final result = react_dom.unmountComponentAtNode(mountNode);
99+
expect(result, isTrue, reason: 'test setup check');
100+
final secondUnmountResult = react_dom.unmountComponentAtNode(mountNode);
101+
expect(secondUnmountResult, isFalse);
102+
});
103+
104+
test('returns false when no React tree has been mounted within a node', () {
105+
final result = react_dom.unmountComponentAtNode(DivElement());
106+
expect(result, isFalse);
107+
});
108+
});
109+
110+
group('findDOMNode', () {
111+
group('returns the mounted element when provided', () {
112+
test('a Dart class component instance', () {
113+
final ref = createRef<_ClassComponent>();
114+
renderIntoDocument(classComponent({'ref': ref}));
115+
final dartComponentInstance = ref.current;
116+
expect(dartComponentInstance, isNotNull, reason: 'test setup check');
117+
dartComponentInstance!;
118+
expect(react_dom.findDOMNode(dartComponentInstance), isA<AnchorElement>());
119+
});
120+
121+
test('a JS class component instance', () {
122+
final ref = createRef<_ClassComponent>();
123+
renderIntoDocument(classComponent({'ref': ref}));
124+
final jsComponentInstance = ref.current!.jsThis;
125+
expect(jsComponentInstance, isNotNull, reason: 'test setup check');
126+
expect(react_dom.findDOMNode(jsComponentInstance), isA<AnchorElement>());
127+
});
128+
129+
test('an element representing a mounted component', () {
130+
final ref = createRef<SpanElement>();
131+
renderIntoDocument(react.span({'ref': ref}));
132+
final element = ref.current;
133+
expect(element, isNotNull, reason: 'test setup check');
134+
element!;
135+
expect(react_dom.findDOMNode(element), same(element));
136+
});
137+
});
138+
139+
test('returns null when a component does not render any content', () {
140+
final ref = createRef<_ClassComponentThatRendersNothing>();
141+
renderIntoDocument(classComponentThatRendersNothing({'ref': ref}));
142+
final dartComponentInstance = ref.current;
143+
expect(dartComponentInstance, isNotNull, reason: 'test setup check');
144+
dartComponentInstance!;
145+
expect(react_dom.findDOMNode(dartComponentInstance), isNull);
146+
});
147+
148+
test('passes through a non-React-mounted element', () {
149+
final element = DivElement();
150+
expect(react_dom.findDOMNode(element), same(element));
151+
});
152+
153+
test('passes through null', () {
154+
expect(react_dom.findDOMNode(null), isNull);
155+
});
156+
157+
group('throws when passed other objects that don\'t represent mounted React components', () {
158+
test('arbitrary Dart objects', () {
159+
expect(() => react_dom.findDOMNode(Object()),
160+
throwsA(isA<Object>().havingToStringValue(contains('Argument appears to not be a ReactComponent'))));
161+
});
162+
163+
test('ReactElement', () {
164+
expect(() => react_dom.findDOMNode(react.span({})),
165+
throwsA(isA<Object>().havingToStringValue(contains('Argument appears to not be a ReactComponent'))));
166+
});
167+
});
168+
});
169+
});
170+
}
171+
172+
final classComponent = react.registerComponent2(() => _ClassComponent());
173+
174+
class _ClassComponent extends react.Component2 {
175+
@override
176+
Object? render() => react.a({}, 'class component content');
177+
}
178+
179+
final classComponentThatRendersNothing = react.registerComponent2(() => _ClassComponentThatRendersNothing());
180+
181+
class _ClassComponentThatRendersNothing extends react.Component2 {
182+
@override
183+
Object? render() => null;
184+
}
185+
186+
final functionComponent = react.registerFunctionComponent((props) {
187+
return react.pre({}, 'function component content');
188+
});
189+
190+
extension<T> on TypeMatcher<T> {
191+
TypeMatcher<T> havingToStringValue(dynamic matcher) => having((o) => o.toString(), 'toString() value', matcher);
192+
}

test/react_dom_test.html

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head lang="en">
4+
<meta charset="UTF-8">
5+
<title></title>
6+
<script src="packages/react/react_with_addons.js"></script>
7+
<script src="packages/react/react_dom.js"></script>
8+
<link rel="x-dart-test" href="react_dom_test.dart">
9+
<script src="packages/test/dart.js"></script>
10+
</head>
11+
<body>
12+
</body>
13+
</html>

test/react_test_utils_test.dart

+6-6
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ void testUtils({
6666

6767
setUp(() {
6868
component = renderIntoDocument(eventComponent({})) as Object;
69-
domNode = findDomNode(component);
69+
domNode = findDomNode(component)!;
7070
expect(domNode.text, equals(''));
7171
});
7272

@@ -350,12 +350,12 @@ void testUtils({
350350

351351
expect(divElements.length, equals(3));
352352
// First div should be the parent div created by renderIntoDocument()
353-
expect(findDomNode(divElements[0]).text, equals('A headerFirst divSecond div'));
354-
expect(findDomNode(divElements[1]).text, equals('First div'));
355-
expect(findDomNode(divElements[2]).text, equals('Second div'));
353+
expect(findDomNode(divElements[0])!.text, equals('A headerFirst divSecond div'));
354+
expect(findDomNode(divElements[1])!.text, equals('First div'));
355+
expect(findDomNode(divElements[2])!.text, equals('Second div'));
356356
expect(h1Elements.length, equals(1));
357-
expect(findDomNode(h1Elements[0]).text, equals('A header'));
357+
expect(findDomNode(h1Elements[0])!.text, equals('A header'));
358358
expect(spanElements.length, equals(1));
359-
expect(findDomNode(spanElements[0]).text, equals(''));
359+
expect(findDomNode(spanElements[0])!.text, equals(''));
360360
});
361361
}

test/util.dart

+2-2
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ bool isDartComponent2(ReactElement element) =>
3333
bool isDartComponent(ReactElement element) => ReactDartComponentVersion.fromType(element.type) != null;
3434

3535
T getDartComponent<T extends react.Component>(dynamic dartComponent) {
36-
return (dartComponent as ReactComponent).dartComponent as T;
36+
return (dartComponent as ReactComponent).dartComponent! as T;
3737
}
3838

3939
Map getDartComponentProps(dynamic dartComponent) {
@@ -49,7 +49,7 @@ ReactComponent render(ReactElement reactElement) {
4949
}
5050

5151
// Same as the public API but with tightened types to help fix implicit casts
52-
Element findDomNode(dynamic component) => react_dom.findDOMNode(component) as Element;
52+
Element? findDomNode(dynamic component) => react_dom.findDOMNode(component) as Element?;
5353

5454
/// Returns a new [Map.unmodifiable] with all argument maps merged in.
5555
Map unmodifiableMap([Map? map1, Map? map2, Map? map3, Map? map4]) {

0 commit comments

Comments
 (0)