Skip to content

Commit 2d32405

Browse files
Merge pull request #357 from Workiva/suspense-component
FED-1254 add Suspense component
2 parents 38a29e0 + d30709f commit 2d32405

10 files changed

+315
-0
lines changed

example/index.html

+3
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ <h2 class="display-5">Use Case / Test Examples</h2>
5858
<li>
5959
<a href="test/function_component_test.html"><code>Function Component</code> test</a>
6060
</li>
61+
<li>
62+
<a href="suspense/index.html"><code>Suspense</code> Example</a>
63+
</li>
6164
</ul>
6265

6366
</div>

example/suspense/index.html

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head lang="en">
4+
<meta charset="UTF-8">
5+
<title>Suspense - react-dart Examples</title>
6+
<style>
7+
#content {
8+
width: 100%;
9+
height: 100%;
10+
text-align: center;
11+
}
12+
</style>
13+
</head>
14+
<body>
15+
<em>See Dart source for more info</em>
16+
17+
<div id="content"></div>
18+
19+
<script src="packages/react/react.js"></script>
20+
<script src="packages/react/react_dom.js"></script>
21+
22+
<!-- Where the JS React components are declared. -->
23+
<script defer src="suspense.dart.js"></script>
24+
25+
</body>
26+
</html>
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import 'package:react/hooks.dart';
2+
import 'package:react/react.dart' as react;
3+
4+
var SimpleComponent = react.registerFunctionComponent(SimpleFunctionComponent, displayName: 'simple');
5+
6+
SimpleFunctionComponent(Map props) {
7+
final count = useState(1);
8+
final evenOdd = useState('even');
9+
10+
useEffect(() {
11+
if (count.value % 2 == 0) {
12+
print('count changed to ' + count.value.toString());
13+
evenOdd.set('even');
14+
} else {
15+
print('count changed to ' + count.value.toString());
16+
evenOdd.set('odd');
17+
}
18+
return () {
19+
print('count is changing... do some cleanup if you need to');
20+
};
21+
22+
/// This dependency prevents the effect from running every time [evenOdd.value] changes.
23+
}, [count.value]);
24+
25+
return react.div({}, [
26+
react.button({'onClick': (_) => count.set(1), 'key': 'ust1'}, ['Reset']),
27+
react.button({'onClick': (_) => count.setWithUpdater((prev) => prev + 1), 'key': 'ust2'}, ['+']),
28+
react.br({'key': 'ust3'}),
29+
react.p({'key': 'ust4'}, ['${count.value} is ${evenOdd.value}']),
30+
]);
31+
}

example/suspense/suspense.dart

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
@JS()
2+
library js_components;
3+
4+
import 'dart:html';
5+
import 'dart:js_util';
6+
7+
import 'package:js/js.dart';
8+
import 'package:react/react.dart' as react;
9+
import 'package:react/react_client.dart';
10+
import 'package:react/react_client/react_interop.dart';
11+
import 'package:react/react_dom.dart' as react_dom;
12+
import 'package:react/src/js_interop_util.dart';
13+
import './simple_component.dart' deferred as simple;
14+
15+
@JS('React.lazy')
16+
external ReactClass jsLazy(Promise Function() factory);
17+
18+
// Only intended for testing purposes, Please do not copy/paste this into repo.
19+
// This will most likely be added to the PUBLIC api in the future,
20+
// but needs more testing and Typing decisions to be made first.
21+
ReactJsComponentFactoryProxy lazy(Future<ReactComponentFactoryProxy> factory()) => ReactJsComponentFactoryProxy(
22+
jsLazy(
23+
allowInterop(
24+
() => futureToPromise(
25+
// React.lazy only supports "default exports" from a module.
26+
// This `{default: yourExport}` workaround can be found in the React.lazy RFC comments.
27+
// See: https://github.com/reactjs/rfcs/pull/64#issuecomment-431507924
28+
(() async => jsify({'default': (await factory()).type}))(),
29+
),
30+
),
31+
),
32+
);
33+
34+
main() {
35+
var content = wrapper({});
36+
37+
react_dom.render(content, querySelector('#content'));
38+
}
39+
40+
final lazyComponent = lazy(() async {
41+
await simple.loadLibrary();
42+
await Future.delayed(Duration(seconds: 5));
43+
return simple.SimpleComponent;
44+
});
45+
46+
var wrapper = react.registerFunctionComponent(WrapperComponent, displayName: 'wrapper');
47+
48+
WrapperComponent(Map props) {
49+
return react.div({
50+
'id': 'lazy-wrapper'
51+
}, [
52+
react.Suspense({'fallback': 'Loading...'}, [lazyComponent({})])
53+
]);
54+
}

lib/react.dart

+32
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,38 @@ typedef ReactDartFunctionComponentFactoryProxy FunctionComponentRegistrar(DartFu
6565
/// See: <https://reactjs.org/docs/fragments.html>
6666
var Fragment = ReactJsComponentFactoryProxy(React.Fragment);
6767

68+
/// [Suspense] lets you display a fallback UI until its children have finished loading.
69+
///
70+
/// Like [react.Fragment], [Suspense] does not render any visible UI.
71+
/// It lets you specify a loading indicator in case some components in
72+
/// the tree below it are not yet ready to render.
73+
/// [Suspense] currently works with:
74+
/// - Components that use React.lazy
75+
/// - (dynamic imports, not currently implemented in dart)
76+
///
77+
/// Example Usage:
78+
/// ```
79+
/// render() {
80+
/// return react.div({}, [
81+
/// Header({}),
82+
/// react.Suspense({'fallback': LoadingIndicator({})}, [
83+
/// LazyBodyComponent({}),
84+
/// NotALazyComponent({})
85+
/// ]),
86+
/// Footer({}),
87+
/// ]);
88+
/// }
89+
/// ```
90+
///
91+
/// In the above example, [Suspense] will display the `LoadingIndicator` until
92+
/// `LazyBodyComponent` is loaded. It will not display for `Header` or `Footer`.
93+
///
94+
/// However, any "lazy" descendant components in `LazyBodyComponent` and
95+
/// `NotALazyComponent` will trigger the closest ancestor [Suspense].
96+
///
97+
/// See: <https://react.dev/reference/react/Suspense>
98+
var Suspense = ReactJsComponentFactoryProxy(React.Suspense);
99+
68100
/// StrictMode is a tool for highlighting potential problems in an application.
69101
///
70102
/// StrictMode does not render any visible UI. It activates additional checks and warnings for its descendants.

lib/react_client/react_interop.dart

+1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ abstract class React {
5656
external static bool isValidElement(dynamic object);
5757

5858
external static ReactClass get StrictMode;
59+
external static ReactClass get Suspense;
5960
external static ReactClass get Fragment;
6061

6162
external static List<dynamic> useState(dynamic value);

lib/src/js_interop_util.dart

+18
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,21 @@ class JsPropertyDescriptor {
1919
external void defineProperty(dynamic object, String propertyName, JsPropertyDescriptor descriptor);
2020

2121
String getJsFunctionName(Function object) => getProperty(object, 'name') ?? getProperty(object, '\$static_name');
22+
23+
/// Creates JS `Promise` which is resolved when [future] completes.
24+
///
25+
/// See also:
26+
/// - [promiseToFuture]
27+
Promise futureToPromise<T>(Future<T> future) {
28+
return Promise(allowInterop((Function resolve, Function reject) {
29+
future.then((result) => resolve(result), onError: reject);
30+
}));
31+
}
32+
33+
@JS()
34+
abstract class Promise {
35+
external factory Promise(
36+
Function(dynamic Function(dynamic value) resolve, dynamic Function(dynamic error) reject) executor);
37+
38+
external Promise then(dynamic Function(dynamic value) onFulfilled, [dynamic Function(dynamic error) onRejected]);
39+
}
+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import 'package:react/hooks.dart';
2+
import 'package:react/react.dart' as react;
3+
4+
var SimpleFunctionComponent = react.registerFunctionComponent(SimpleComponent, displayName: 'simple');
5+
6+
SimpleComponent(Map props) {
7+
return react.div({'id': 'simple-component'}, []);
8+
}

test/react_suspense_test.dart

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
@TestOn('browser')
2+
@JS()
3+
library react_test_utils_test;
4+
5+
import 'dart:html';
6+
import 'dart:js_util';
7+
8+
import 'package:js/js.dart';
9+
import 'package:react/react.dart' as react;
10+
import 'package:react/react.dart';
11+
import 'package:react/react_client/component_factory.dart';
12+
import 'package:react/react_client/react_interop.dart';
13+
import 'package:react/react_dom.dart' as react_dom;
14+
import 'package:react/src/js_interop_util.dart';
15+
import 'package:test/test.dart';
16+
17+
import './react_suspense_lazy_component.dart' deferred as simple;
18+
19+
@JS('React.lazy')
20+
external ReactClass jsLazy(Promise Function() factory);
21+
22+
// Only intended for testing purposes, Please do not copy/paste this into repo.
23+
// This will most likely be added to the PUBLIC api in the future,
24+
// but needs more testing and Typing decisions to be made first.
25+
ReactJsComponentFactoryProxy lazy(Future<ReactComponentFactoryProxy> factory()) => ReactJsComponentFactoryProxy(
26+
jsLazy(
27+
allowInterop(
28+
() => futureToPromise(
29+
// React.lazy only supports "default exports" from a module.
30+
// This `{default: yourExport}` workaround can be found in the React.lazy RFC comments.
31+
// See: https://github.com/reactjs/rfcs/pull/64#issuecomment-431507924
32+
(() async => jsify({'default': (await factory()).type}))(),
33+
),
34+
),
35+
),
36+
);
37+
38+
main() {
39+
group('Suspense', () {
40+
test('renders fallback UI first followed by the real component', () async {
41+
final lazyComponent = lazy(() async {
42+
await simple.loadLibrary();
43+
await Future.delayed(Duration(seconds: 1));
44+
return simple.SimpleFunctionComponent;
45+
});
46+
var wrappingDivRef;
47+
var mountElement = new Element.div();
48+
react_dom.render(
49+
react.div({
50+
'ref': (ref) {
51+
wrappingDivRef = ref;
52+
}
53+
}, [
54+
react.Suspense({
55+
'fallback': react.span({'id': 'loading'}, 'Loading...')
56+
}, [
57+
lazyComponent({})
58+
])
59+
]),
60+
mountElement,
61+
);
62+
63+
expect(wrappingDivRef.querySelector('#loading'), isNotNull,
64+
reason: 'It should be showing the fallback UI for 2 seconds.');
65+
expect(wrappingDivRef.querySelector('#simple-component'), isNull,
66+
reason: 'This component should not be present yet.');
67+
await Future.delayed(Duration(seconds: 2));
68+
expect(wrappingDivRef.querySelector('#simple-component'), isNotNull,
69+
reason: 'This component should be present now.');
70+
expect(wrappingDivRef.querySelector('#loading'), isNull, reason: 'The loader should have hidden.');
71+
});
72+
73+
test('is instant after the lazy component has been loaded once', () async {
74+
final lazyComponent = lazy(() async {
75+
await simple.loadLibrary();
76+
await Future.delayed(Duration(seconds: 1));
77+
return simple.SimpleFunctionComponent;
78+
});
79+
var wrappingDivRef;
80+
var wrappingDivRef2;
81+
var mountElement = new Element.div();
82+
var mountElement2 = new Element.div();
83+
84+
react_dom.render(
85+
react.div({
86+
'ref': (ref) {
87+
wrappingDivRef = ref;
88+
}
89+
}, [
90+
react.Suspense({
91+
'fallback': react.span({'id': 'loading'}, 'Loading...')
92+
}, [
93+
lazyComponent({})
94+
])
95+
]),
96+
mountElement,
97+
);
98+
99+
expect(wrappingDivRef.querySelector('#loading'), isNotNull,
100+
reason: 'It should be showing the fallback UI for 2 seconds.');
101+
expect(wrappingDivRef.querySelector('#simple-component'), isNull,
102+
reason: 'This component should not be present yet.');
103+
await Future.delayed(Duration(seconds: 2));
104+
expect(wrappingDivRef.querySelector('#simple-component'), isNotNull,
105+
reason: 'This component should be present now.');
106+
expect(wrappingDivRef.querySelector('#loading'), isNull, reason: 'The loader should have hidden.');
107+
108+
// Mounting to a new element should be instant since it was already loaded before
109+
react_dom.render(
110+
react.div({
111+
'ref': (ref) {
112+
wrappingDivRef2 = ref;
113+
}
114+
}, [
115+
react.Suspense({
116+
'fallback': react.span({'id': 'loading'}, 'Loading...')
117+
}, [
118+
lazyComponent({})
119+
])
120+
]),
121+
mountElement2,
122+
);
123+
expect(wrappingDivRef2.querySelector('#simple-component'), isNotNull,
124+
reason: 'Its already been loaded, so this should appear instantly.');
125+
expect(wrappingDivRef2.querySelector('#loading'), isNull,
126+
reason: 'Its already been loaded, so the loading UI shouldn\'t show.');
127+
});
128+
});
129+
}

test/react_suspense_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_suspense_test.dart">
9+
<script src="packages/test/dart.js"></script>
10+
</head>
11+
<body>
12+
</body>
13+
</html>

0 commit comments

Comments
 (0)