diff --git a/example/suspense/suspense.dart b/example/suspense/suspense.dart index 7517a647..f92f356e 100644 --- a/example/suspense/suspense.dart +++ b/example/suspense/suspense.dart @@ -1,55 +1,39 @@ @JS() -library js_components; +library example.suspense.suspense; import 'dart:html'; -import 'dart:js_util'; import 'package:js/js.dart'; +import 'package:react/hooks.dart'; import 'package:react/react.dart' as react; -import 'package:react/react_client.dart'; -import 'package:react/react_client/react_interop.dart'; import 'package:react/react_dom.dart' as react_dom; -import 'package:react/src/js_interop_util.dart'; import './simple_component.dart' deferred as simple; -@JS('React.lazy') -external ReactClass jsLazy(Promise Function() factory); - -// Only intended for testing purposes, Please do not copy/paste this into repo. -// This will most likely be added to the PUBLIC api in the future, -// but needs more testing and Typing decisions to be made first. -ReactJsComponentFactoryProxy lazy(Future Function() factory) => - ReactJsComponentFactoryProxy( - jsLazy( - allowInterop( - () => futureToPromise( - // React.lazy only supports "default exports" from a module. - // This `{default: yourExport}` workaround can be found in the React.lazy RFC comments. - // See: https://github.com/reactjs/rfcs/pull/64#issuecomment-431507924 - (() async => jsify({'default': (await factory()).type}))(), - ), - ), - ), - ); - main() { final content = wrapper({}); react_dom.render(content, querySelector('#content')); } -final lazyComponent = lazy(() async { - await simple.loadLibrary(); +final lazyComponent = react.lazy(() async { await Future.delayed(Duration(seconds: 5)); + await simple.loadLibrary(); + return simple.SimpleComponent; }); var wrapper = react.registerFunctionComponent(WrapperComponent, displayName: 'wrapper'); WrapperComponent(Map props) { + final showComponent = useState(false); return react.div({ 'id': 'lazy-wrapper' }, [ - react.Suspense({'fallback': 'Loading...'}, [lazyComponent({})]) + react.button({ + 'onClick': (_) { + showComponent.set(!showComponent.value); + } + }, 'Toggle component'), + react.Suspense({'fallback': 'Loading...'}, showComponent.value ? lazyComponent({}) : null) ]); } diff --git a/lib/react.dart b/lib/react.dart index c4596e70..d7884ed6 100644 --- a/lib/react.dart +++ b/lib/react.dart @@ -22,6 +22,7 @@ export 'package:react/src/context.dart'; export 'package:react/src/prop_validator.dart'; export 'package:react/src/react_client/event_helpers.dart'; export 'package:react/react_client/react_interop.dart' show forwardRef2, createRef, memo2; +export 'package:react/src/react_client/lazy.dart' show lazy; export 'package:react/src/react_client/synthetic_event_wrappers.dart' hide NonNativeDataTransfer; export 'package:react/src/react_client/synthetic_data_transfer.dart' show SyntheticDataTransfer; diff --git a/lib/react_client/react_interop.dart b/lib/react_client/react_interop.dart index 9ded7771..61d9af8a 100644 --- a/lib/react_client/react_interop.dart +++ b/lib/react_client/react_interop.dart @@ -18,6 +18,7 @@ import 'package:react/react_client/js_backed_map.dart'; import 'package:react/react_client/component_factory.dart' show ReactDartWrappedComponentFactoryProxy; import 'package:react/src/react_client/dart2_interop_workaround_bindings.dart'; import 'package:react/src/typedefs.dart'; +import 'package:react/src/js_interop_util.dart'; typedef ReactJsComponentFactory = ReactElement Function(dynamic props, dynamic children); @@ -42,6 +43,7 @@ abstract class React { dynamic wrapperFunction, [ bool Function(JsMap prevProps, JsMap nextProps)? areEqual, ]); + external static ReactClass lazy(Promise Function() load); external static bool isValidElement(dynamic object); diff --git a/lib/src/react_client/lazy.dart b/lib/src/react_client/lazy.dart new file mode 100644 index 00000000..65ba9e5a --- /dev/null +++ b/lib/src/react_client/lazy.dart @@ -0,0 +1,81 @@ +import 'dart:js'; +import 'dart:js_util'; + +import 'package:react/react.dart'; +import 'package:react/react_client/component_factory.dart'; +import 'package:react/react_client/react_interop.dart'; +import 'package:react/src/js_interop_util.dart'; + +/// Defer loading a component's code until it is rendered for the first time. +/// +/// The `lazy` function is used to create lazy components in react-dart. Lazy components are able to run asynchronous code only when they are trying to be rendered for the first time, allowing for deferred loading of the component's code. +/// +/// To use the `lazy` function, you need to wrap the lazy component with a `Suspense` component. The `Suspense` component allows you to specify what should be displayed while the lazy component is loading, such as a loading spinner or a placeholder. +/// +/// Example usage: +/// ```dart +/// import 'package:react/react.dart' show lazy, Suspense; +/// import './simple_component.dart' deferred as simple; +/// +/// final lazyComponent = lazy(() async { +/// await simple.loadLibrary(); +/// return simple.SimpleComponent; +/// }); +/// +/// // Wrap the lazy component with Suspense +/// final app = Suspense( +/// { +/// fallback: 'Loading...', +/// }, +/// lazyComponent({}), +/// ); +/// ``` +/// +/// Defer loading a component’s code until it is rendered for the first time. +/// +/// Lazy components need to be wrapped with `Suspense` to render. +/// `Suspense` also allows you to specify what should be displayed while the lazy component is loading. +ReactComponentFactoryProxy lazy(Future Function() load) { + final hoc = React.lazy( + allowInterop( + () => futureToPromise( + Future.sync(() async { + final factory = await load(); + // By using a wrapper uiForwardRef it ensures that we have a matching factory proxy type given to react-dart's lazy, + // a `ReactDartWrappedComponentFactoryProxy`. This is necessary to have consistent prop conversions since we don't + // have access to the original factory proxy outside of this async block. + final wrapper = forwardRef2((props, ref) { + final children = props['children']; + return factory.build( + {...props, 'ref': ref}, + [ + if (children != null && !(children is List && children.isEmpty)) children, + ], + ); + }, displayName: 'LazyWrapper(${_getComponentName(factory.type) ?? 'Anonymous'})'); + return jsify({'default': wrapper.type}); + }), + ), + ), + ); + + // Setting this version and wrapping with ReactDartWrappedComponentFactoryProxy + // is only okay because it matches the version and factory proxy of the wrapperFactory above. + // ignore: invalid_use_of_protected_member + setProperty(hoc, 'dartComponentVersion', ReactDartComponentVersion.component2); + return ReactDartWrappedComponentFactoryProxy(hoc); +} + +String? _getComponentName(Object? type) { + if (type == null) return null; + + if (type is String) return type; + + final name = getProperty(type, 'name'); + if (name is String) return name; + + final displayName = getProperty(type, 'displayName'); + if (displayName is String) return displayName; + + return null; +} diff --git a/test/factory/common_factory_tests.dart b/test/factory/common_factory_tests.dart index 57f90307..d0d800ba 100644 --- a/test/factory/common_factory_tests.dart +++ b/test/factory/common_factory_tests.dart @@ -26,10 +26,13 @@ import '../util.dart'; /// [dartComponentVersion] should be specified for all components with Dart render code in order to /// properly test `props.children`, forwardRef compatibility, etc. void commonFactoryTests(ReactComponentFactoryProxy factory, - {String? dartComponentVersion, bool skipPropValuesTest = false}) { + {String? dartComponentVersion, + bool skipPropValuesTest = false, + bool isNonDartComponentWithDartWrapper = false, + ReactElement Function(dynamic children)? renderWrapper}) { _childKeyWarningTests( factory, - renderWithUniqueOwnerName: _renderWithUniqueOwnerName, + renderWithUniqueOwnerName: (ReactElement Function() render) => _renderWithUniqueOwnerName(render, renderWrapper), ); test('renders an instance with the corresponding `type`', () { @@ -113,7 +116,7 @@ void commonFactoryTests(ReactComponentFactoryProxy factory, shouldAlwaysBeList: isDartComponent2(factory({}))); }); - if (isDartComponent(factory({}))) { + if (isDartComponent(factory({})) && !isNonDartComponentWithDartWrapper) { group('passes children to the Dart component when specified as', () { final notCalledSentinelValue = Object(); dynamic childrenFromLastRender; @@ -171,7 +174,7 @@ void commonFactoryTests(ReactComponentFactoryProxy factory, } } - if (isDartComponent2(factory({}))) { + if (isDartComponent2(factory({})) && !isNonDartComponentWithDartWrapper) { test('executes Dart render code in the component zone', () { final oldComponentZone = componentZone; addTearDown(() => componentZone = oldComponentZone); @@ -191,7 +194,10 @@ void commonFactoryTests(ReactComponentFactoryProxy factory, } } -void domEventHandlerWrappingTests(ReactComponentFactoryProxy factory) { +void domEventHandlerWrappingTests( + ReactComponentFactoryProxy factory, { + bool isNonDartComponentWithDartWrapper = false, +}) { Element renderAndGetRootNode(ReactElement content) { final mountNode = Element.div(); react_dom.render(content, mountNode); @@ -268,22 +274,31 @@ void domEventHandlerWrappingTests(ReactComponentFactoryProxy factory) { } }); - if (isDartComponent(factory({}))) { + if (isDartComponent(factory({})) && !isNonDartComponentWithDartWrapper) { group('in a way that the handlers are callable from within the Dart component:', () { setUpAll(() { expect(propsFromDartRender, isNotNull, reason: 'test setup: component must pass props into props.onDartRender'); }); - late react.SyntheticMouseEvent event; - final divRef = react.createRef(); - render(react.div({ - 'ref': divRef, - 'onClick': (react.SyntheticMouseEvent e) => event = e, - })); - rtu.Simulate.click(divRef); + late react.SyntheticMouseEvent dummyEvent; + setUpAll(() { + final mountNode = DivElement(); + document.body!.append(mountNode); + addTearDown(() { + react_dom.unmountComponentAtNode(mountNode); + mountNode.remove(); + }); - final dummyEvent = event; + final divRef = react.createRef(); + react_dom.render( + react.div({ + 'ref': divRef, + 'onClick': (react.SyntheticMouseEvent e) => dummyEvent = e, + }), + mountNode); + divRef.current!.click(); + }); for (final eventCase in eventCases.where((helper) => helper.isDart)) { test(eventCase.description, () { @@ -532,7 +547,7 @@ void _childKeyWarningTests(ReactComponentFactoryProxy factory, }); test('warns when a single child is passed as a list', () { - _renderWithUniqueOwnerName(() => factory({}, [react.span({})])); + renderWithUniqueOwnerName(() => factory({}, [react.span({})])); expect(consoleErrorCalled, isTrue, reason: 'should have outputted a warning'); expect(consoleErrorMessage, contains('Each child in a list should have a unique "key" prop.')); @@ -577,12 +592,12 @@ int _nextFactoryId = 0; /// Renders the provided [render] function with a Component2 owner that will have a unique name. /// /// This prevents React JS from not printing key warnings it deems as "duplicates". -void _renderWithUniqueOwnerName(ReactElement Function() render) { +void _renderWithUniqueOwnerName(ReactElement Function() render, [ReactElement Function(dynamic)? wrapper]) { final factory = react.registerComponent2(() => _UniqueOwnerHelperComponent()); factory.reactClass.displayName = 'OwnerHelperComponent_$_nextFactoryId'; _nextFactoryId++; - rtu.renderIntoDocument(factory({'render': render})); + rtu.renderIntoDocument(factory({'render': wrapper != null ? () => wrapper(render()) : render})); } class _UniqueOwnerHelperComponent extends react.Component2 { diff --git a/test/react_lazy_test.dart b/test/react_lazy_test.dart new file mode 100644 index 00000000..6fc9fa16 --- /dev/null +++ b/test/react_lazy_test.dart @@ -0,0 +1,125 @@ +@TestOn('browser') +@JS() +library react.react_lazy_test; + +import 'dart:async'; +import 'dart:js_util'; + +import 'package:js/js.dart'; +import 'package:react/hooks.dart'; +import 'package:react/react.dart' as react; +import 'package:react/react_test_utils.dart' as rtu; +import 'package:react/react_client/component_factory.dart'; +import 'package:react/react_client/react_interop.dart'; +import 'package:test/test.dart'; + +import 'factory/common_factory_tests.dart'; + +main() { + group('lazy', () { + // Event more lazy behavior is tested in `react_suspense_test.dart` + + test('correctly throws errors from within load function to the closest error boundary', () async { + const errorString = 'intentional future error'; + final errors = []; + final errorCompleter = Completer(); + final ThrowingLazyTest = react.lazy(() async { + throw Exception(errorString); + }); + onError(error, info) { + errors.add([error, info]); + errorCompleter.complete(); + } + + expect( + () => rtu.renderIntoDocument(_ErrorBoundary( + {'onComponentDidCatch': onError}, react.Suspense({'fallback': 'Loading...'}, ThrowingLazyTest({})))), + returnsNormally); + await expectLater(errorCompleter.future, completes); + expect(errors, hasLength(1)); + expect(errors.first.first, isA().having((e) => e.toString(), 'message', contains(errorString))); + expect(errors.first.last, isA()); + }); + + group('Dart component', () { + final LazyTest = react.lazy(() async => react.forwardRef2((props, ref) { + useImperativeHandle(ref, () => TestImperativeHandle()); + props['onDartRender']?.call(props); + return react.div({...props}); + })); + + group('- common factory behavior -', () { + commonFactoryTests( + LazyTest, + // ignore: invalid_use_of_protected_member + dartComponentVersion: ReactDartComponentVersion.component2, + renderWrapper: (child) => react.Suspense({'fallback': 'Loading...'}, child), + ); + }); + + group('- dom event handler wrapping -', () { + domEventHandlerWrappingTests(LazyTest); + }); + + group('- refs -', () { + refTests(LazyTest, verifyRefValue: (ref) { + expect(ref, isA()); + }); + }); + }); + + group('JS component', () { + final LazyJsTest = react.lazy(() async => ReactJsComponentFactoryProxy(_JsFoo)); + + group('- common factory behavior -', () { + commonFactoryTests( + LazyJsTest, + // ignore: invalid_use_of_protected_member + dartComponentVersion: ReactDartComponentVersion.component2, + // This isn't a Dart component, but it's detected as one by tests due to the factory's dartComponentVersion + isNonDartComponentWithDartWrapper: true, + renderWrapper: (child) => react.Suspense({'fallback': 'Loading...'}, child), + ); + }); + + group('- dom event handler wrapping -', () { + domEventHandlerWrappingTests( + LazyJsTest, + // This isn't a Dart component, but it's detected as one by tests due to the factory's dartComponentVersion + isNonDartComponentWithDartWrapper: true, + ); + }); + + group('- refs -', () { + refTests(LazyJsTest, verifyRefValue: (ref) { + expect(getProperty(ref as Object, 'constructor'), same(_JsFoo)); + }); + }); + }); + }); +} + +class TestImperativeHandle {} + +@JS() +external ReactClass get _JsFoo; + +final _ErrorBoundary = react.registerComponent2(() => _ErrorBoundaryComponent(), skipMethods: []); + +class _ErrorBoundaryComponent extends react.Component2 { + @override + get initialState => {'hasError': false}; + + @override + getDerivedStateFromError(dynamic error) => {'hasError': true}; + + @override + componentDidCatch(dynamic error, ReactErrorInfo info) { + props['onComponentDidCatch'](error, info); + } + + @override + render() { + return (state['hasError'] as bool) ? null : props['children']; + } +} diff --git a/test/react_lazy_test.html b/test/react_lazy_test.html new file mode 100644 index 00000000..2f98b084 --- /dev/null +++ b/test/react_lazy_test.html @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/test/react_suspense_test.dart b/test/react_suspense_test.dart index 50ad0c22..ec6b1c9b 100644 --- a/test/react_suspense_test.dart +++ b/test/react_suspense_test.dart @@ -3,43 +3,18 @@ library react_test_utils_test; import 'dart:html'; -import 'dart:js_util'; import 'package:js/js.dart'; import 'package:react/react.dart' as react; -import 'package:react/react.dart'; -import 'package:react/react_client/component_factory.dart'; -import 'package:react/react_client/react_interop.dart'; import 'package:react/react_dom.dart' as react_dom; -import 'package:react/src/js_interop_util.dart'; import 'package:test/test.dart'; import './react_suspense_lazy_component.dart' deferred as simple; -@JS('React.lazy') -external ReactClass jsLazy(Promise Function() factory); - -// Only intended for testing purposes, Please do not copy/paste this into repo. -// This will most likely be added to the PUBLIC api in the future, -// but needs more testing and Typing decisions to be made first. -ReactJsComponentFactoryProxy lazy(Future Function() factory) => - ReactJsComponentFactoryProxy( - jsLazy( - allowInterop( - () => futureToPromise( - // React.lazy only supports "default exports" from a module. - // This `{default: yourExport}` workaround can be found in the React.lazy RFC comments. - // See: https://github.com/reactjs/rfcs/pull/64#issuecomment-431507924 - (() async => jsify({'default': (await factory()).type}))(), - ), - ), - ), - ); - main() { group('Suspense', () { test('renders fallback UI first followed by the real component', () async { - final lazyComponent = lazy(() async { + final lazyComponent = react.lazy(() async { await simple.loadLibrary(); await Future.delayed(Duration(seconds: 1)); return simple.SimpleFunctionComponent; @@ -72,7 +47,7 @@ main() { }); test('is instant after the lazy component has been loaded once', () async { - final lazyComponent = lazy(() async { + final lazyComponent = react.lazy(() async { await simple.loadLibrary(); await Future.delayed(Duration(seconds: 1)); return simple.SimpleFunctionComponent;