Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FED-3207 Add react lazy #406

Merged
merged 15 commits into from
Oct 7, 2024
Merged
40 changes: 12 additions & 28 deletions example/suspense/suspense.dart
Original file line number Diff line number Diff line change
@@ -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<ReactComponentFactoryProxy> 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)
]);
}
1 change: 1 addition & 0 deletions lib/react.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions lib/react_client/react_interop.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);

Expand Down
67 changes: 67 additions & 0 deletions lib/src/react_client/lazy.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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<ReactComponentFactoryProxy> 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,
],
);
});
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);
}
49 changes: 32 additions & 17 deletions test/factory/common_factory_tests.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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`', () {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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<DivElement>();
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<DivElement>();
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, () {
Expand Down Expand Up @@ -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.'));
Expand Down Expand Up @@ -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 {
Expand Down
125 changes: 125 additions & 0 deletions test/react_lazy_test.dart
Original file line number Diff line number Diff line change
@@ -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', () {
kealjones-wk marked this conversation as resolved.
Show resolved Hide resolved
kealjones-wk marked this conversation as resolved.
Show resolved Hide resolved
// 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<Exception>().having((e) => e.toString(), 'message', contains(errorString)));
expect(errors.first.last, isA<ReactErrorInfo>());
});

group('Dart component', () {
final LazyTest = react.lazy(() async => react.forwardRef2((props, ref) {
useImperativeHandle(ref, () => TestImperativeHandle());
props['onDartRender']?.call(props);
return react.div({...props});
}));
kealjones-wk marked this conversation as resolved.
Show resolved Hide resolved

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<TestImperativeHandle>(LazyTest, verifyRefValue: (ref) {
expect(ref, isA<TestImperativeHandle>());
});
});
});

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<ReactComponent>(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'];
}
}
Loading
Loading