diff --git a/example/test/function_component_test.dart b/example/test/function_component_test.dart index 5243d681..2218618b 100644 --- a/example/test/function_component_test.dart +++ b/example/test/function_component_test.dart @@ -1,31 +1,37 @@ import 'dart:html'; +import 'package:react/hooks.dart'; import 'package:react/react.dart' as react; import 'package:react/react_dom.dart' as react_dom; import 'package:react/react_client.dart'; -import 'react_test_components.dart'; +var useStateTestFunctionComponent = react.registerFunctionComponent(UseStateTestComponent, displayName: 'useStateTest'); + +UseStateTestComponent(Map props) { + final count = useState(0); + + return react.div({}, [ + count.value, + react.button({'onClick': (_) => count.set(0)}, ['Reset']), + react.button({ + 'onClick': (_) => count.setWithUpdater((prev) => prev + 1), + }, [ + '+' + ]), + ]); +} void main() { setClientConfiguration(); - var inputValue = 'World'; - // TODO: replace this with hooks/useState when they are added. + render() { react_dom.render( react.Fragment({}, [ - react.input( - { - 'defaultValue': inputValue, - 'onChange': (event) { - inputValue = event.currentTarget.value; - render(); - } - }, - ), - react.br({}), - helloGregFunctionComponent({'key': 'greg'}), - react.br({}), - helloGregFunctionComponent({'key': 'not greg'}, inputValue) + react.h1({'key': 'functionComponentTestLabel'}, ['Function Component Tests']), + react.h2({'key': 'useStateTestLabel'}, ['useState Hook Test']), + useStateTestFunctionComponent({ + 'key': 'useStateTest', + }, []), ]), querySelector('#content')); } diff --git a/example/test/react_test_components.dart b/example/test/react_test_components.dart index f696038c..38de31e2 100644 --- a/example/test/react_test_components.dart +++ b/example/test/react_test_components.dart @@ -393,16 +393,6 @@ class _NewContextTypeConsumerComponent extends react.Component2 { } } -var helloGregFunctionComponent = react.registerFunctionComponent(HelloGreg); - -HelloGreg(Map props) { - var content = ['Hello Greg!']; - if (props['children'].isNotEmpty) { - content = ['Hello ' + props['children'].join(' ') + '!']; - } - return react.Fragment({}, content); -} - class _Component2TestComponent extends react.Component2 with react.TypedSnapshot { get defaultProps => {'defaultProp': true}; diff --git a/lib/hooks.dart b/lib/hooks.dart new file mode 100644 index 00000000..484de6bb --- /dev/null +++ b/lib/hooks.dart @@ -0,0 +1,103 @@ +@JS() +library hooks; + +import 'package:js/js.dart'; +import 'package:react/react.dart'; +import 'package:react/react_client/react_interop.dart'; + +/// The return value of [useState]. +/// +/// The current value of the state is available via [value] and +/// functions to update it are available via [set] and [setWithUpdater]. +/// +/// Note there are two rules for using Hooks (): +/// +/// * Only call Hooks at the top level. +/// * Only call Hooks from inside a [DartFunctionComponent]. +/// +/// Learn more: . +class StateHook { + /// The first item of the pair returned by [React.useState]. + T _value; + + /// The second item in the pair returned by [React.useState]. + void Function(dynamic) _setValue; + + StateHook(T initialValue) { + final result = React.useState(initialValue); + _value = result[0]; + _setValue = result[1]; + } + + /// Constructor for [useStateLazy], calls lazy version of [React.useState] to + /// initialize [_value] to the return value of [init]. + /// + /// See: . + StateHook.lazy(T init()) { + final result = React.useState(allowInterop(init)); + _value = result[0]; + _setValue = result[1]; + } + + /// The current value of the state. + /// + /// See: . + T get value => _value; + + /// Updates [value] to [newValue]. + /// + /// See: . + void set(T newValue) => _setValue(newValue); + + /// Updates [value] to the return value of [computeNewValue]. + /// + /// See: . + void setWithUpdater(T computeNewValue(T oldValue)) => _setValue(allowInterop(computeNewValue)); +} + +/// Adds local state to a [DartFunctionComponent] +/// by returning a [StateHook] with [StateHook.value] initialized to [initialValue]. +/// +/// > __Note:__ If the [initialValue] is expensive to compute, [useStateLazy] should be used instead. +/// +/// __Example__: +/// +/// ``` +/// UseStateTestComponent(Map props) { +/// final count = useState(0); +/// +/// return react.div({}, [ +/// count.value, +/// react.button({'onClick': (_) => count.set(0)}, ['Reset']), +/// react.button({ +/// 'onClick': (_) => count.setWithUpdater((prev) => prev + 1), +/// }, ['+']), +/// ]); +/// } +/// ``` +/// +/// Learn more: . +StateHook useState(T initialValue) => StateHook(initialValue); + +/// Adds local state to a [DartFunctionComponent] +/// by returning a [StateHook] with [StateHook.value] initialized to the return value of [init]. +/// +/// __Example__: +/// +/// ``` +/// UseStateTestComponent(Map props) { +/// final count = useStateLazy(() { +/// var initialState = someExpensiveComputation(props); +/// return initialState; +/// })); +/// +/// return react.div({}, [ +/// count.value, +/// react.button({'onClick': (_) => count.set(0)}, ['Reset']), +/// react.button({'onClick': (_) => count.set((prev) => prev + 1)}, ['+']), +/// ]); +/// } +/// ``` +/// +/// Learn more: . +StateHook useStateLazy(T init()) => StateHook.lazy(init); diff --git a/lib/react_client/react_interop.dart b/lib/react_client/react_interop.dart index 3567bf15..de35244a 100644 --- a/lib/react_client/react_interop.dart +++ b/lib/react_client/react_interop.dart @@ -44,6 +44,9 @@ abstract class React { external static ReactClass get Fragment; external static JsRef createRef(); + external static ReactClass forwardRef(Function(JsMap props, JsRef ref) wrapperFunction); + + external static List useState(dynamic value); } /// Creates a [Ref] object that can be attached to a [ReactElement] via the ref prop. @@ -108,7 +111,7 @@ class JsRef { /// /// See: . ReactJsComponentFactoryProxy forwardRef(Function(Map props, Ref ref) wrapperFunction) { - var hoc = _jsForwardRef(allowInterop((JsMap props, JsRef ref) { + var hoc = React.forwardRef(allowInterop((JsMap props, JsRef ref) { final dartProps = JsBackedMap.backedBy(props); final dartRef = Ref.fromJs(ref); return wrapperFunction(dartProps, dartRef); @@ -117,9 +120,6 @@ ReactJsComponentFactoryProxy forwardRef(Function(Map props, Ref ref) wrapperFunc return new ReactJsComponentFactoryProxy(hoc, shouldConvertDomProps: false); } -@JS('React.forwardRef') -external ReactClass _jsForwardRef(Function(JsMap props, JsRef ref) wrapperFunction); - abstract class ReactDom { static Element findDOMNode(object) => ReactDOM.findDOMNode(object); static ReactComponent render(ReactElement component, Element element) => ReactDOM.render(component, element); diff --git a/test/hooks_test.dart b/test/hooks_test.dart new file mode 100644 index 00000000..e80492df --- /dev/null +++ b/test/hooks_test.dart @@ -0,0 +1,96 @@ +// ignore_for_file: deprecated_member_use_from_same_package +@TestOn('browser') +@JS() +library hooks_test; + +import 'dart:html'; + +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_dom.dart' as react_dom; +import 'package:react/react_test_utils.dart' as react_test_utils; +import 'package:test/test.dart'; + +main() { + setClientConfiguration(); + + group('React Hooks: ', () { + group('useState -', () { + ReactDartFunctionComponentFactoryProxy UseStateTest; + DivElement textRef; + DivElement countRef; + ButtonElement setButtonRef; + ButtonElement setWithUpdaterButtonRef; + + setUpAll(() { + var mountNode = new DivElement(); + + UseStateTest = react.registerFunctionComponent((Map props) { + final text = useStateLazy(() { + return 'initialValue'; + }); + final count = useState(0); + + return react.div({}, [ + react.div({ + 'ref': (ref) { + textRef = ref; + }, + }, [ + text.value + ]), + react.div({ + 'ref': (ref) { + countRef = ref; + }, + }, [ + count.value + ]), + react.button({ + 'onClick': (_) => text.set('newValue'), + 'ref': (ref) { + setButtonRef = ref; + }, + }, [ + 'Set' + ]), + react.button({ + 'onClick': (_) => count.setWithUpdater((prev) => prev + 1), + 'ref': (ref) { + setWithUpdaterButtonRef = ref; + }, + }, [ + '+' + ]), + ]); + }); + + react_dom.render(UseStateTest({}), mountNode); + }); + + tearDownAll(() { + UseStateTest = null; + }); + + test('initializes state correctly', () { + expect(countRef.text, '0'); + }); + + test('Lazy initializes state correctly', () { + expect(textRef.text, 'initialValue'); + }); + + test('StateHook.set updates state correctly', () { + react_test_utils.Simulate.click(setButtonRef); + expect(textRef.text, 'newValue'); + }); + + test('StateHook.setWithUpdater updates state correctly', () { + react_test_utils.Simulate.click(setWithUpdaterButtonRef); + expect(countRef.text, '1'); + }); + }); + }); +} diff --git a/test/hooks_test.html b/test/hooks_test.html new file mode 100644 index 00000000..92a90257 --- /dev/null +++ b/test/hooks_test.html @@ -0,0 +1,13 @@ + + + + + + + + + + + + +