diff --git a/example/test/function_component_test.dart b/example/test/function_component_test.dart index b750ca19..fd038705 100644 --- a/example/test/function_component_test.dart +++ b/example/test/function_component_test.dart @@ -5,15 +5,32 @@ import 'package:react/react.dart' as react; import 'package:react/react_dom.dart' as react_dom; import 'package:react/react_client.dart'; -var useStateTestFunctionComponent = react.registerFunctionComponent(UseStateTestComponent, displayName: 'useStateTest'); - -UseStateTestComponent(Map props) { - final count = useState(0); +var hookTestFunctionComponent = react.registerFunctionComponent(HookTestComponent, displayName: 'useStateTest'); + +HookTestComponent(Map props) { + final count = useState(1); + final evenOdd = useState('even'); + + useEffect(() { + if (count.value % 2 == 0) { + print('count changed to ' + count.value.toString()); + evenOdd.set('even'); + } else { + print('count changed to ' + count.value.toString()); + evenOdd.set('odd'); + } + return () { + print('count is changing... do some cleanup if you need to'); + }; + + /// This dependency prevents the effect from running every time [evenOdd.value] changes. + }, [count.value]); return react.div({}, [ - count.value, - react.button({'onClick': (_) => count.set(0), 'key': 'ust1'}, ['Reset']), + react.button({'onClick': (_) => count.set(1), 'key': 'ust1'}, ['Reset']), react.button({'onClick': (_) => count.setWithUpdater((prev) => prev + 1), 'key': 'ust2'}, ['+']), + react.br({'key': 'ust3'}), + react.p({'key': 'ust4'}, ['${count.value} is ${evenOdd.value}']), ]); } @@ -107,8 +124,8 @@ void main() { 'key': 'fctf' }, [ react.h1({'key': 'functionComponentTestLabel'}, ['Function Component Tests']), - react.h2({'key': 'useStateTestLabel'}, ['useState Hook Test']), - useStateTestFunctionComponent({ + react.h2({'key': 'useStateTestLabel'}, ['useState & useEffect Hook Test']), + hookTestFunctionComponent({ 'key': 'useStateTest', }, []), react.br({'key': 'br'}), diff --git a/lib/hooks.dart b/lib/hooks.dart index dc24e9f6..31a904c6 100644 --- a/lib/hooks.dart +++ b/lib/hooks.dart @@ -102,6 +102,62 @@ StateHook useState(T initialValue) => StateHook(initialValue); /// Learn more: . StateHook useStateLazy(T init()) => StateHook.lazy(init); +/// Runs [sideEffect] after every completed render of a [DartFunctionComponent]. +/// +/// If [dependencies] are given, [sideEffect] will only run if one of the [dependencies] have changed. +/// [sideEffect] may return a cleanup function that is run before the component unmounts or re-renders. +/// +/// > __Note:__ there are two [rules for using Hooks](https://reactjs.org/docs/hooks-rules.html): +/// > +/// > * Only call Hooks at the top level. +/// > * Only call Hooks from inside a [DartFunctionComponent]. +/// +/// __Example__: +/// +/// ``` +/// UseEffectTestComponent(Map props) { +/// final count = useState(1); +/// final evenOdd = useState('even'); +/// +/// useEffect(() { +/// if (count.value % 2 == 0) { +/// evenOdd.set('even'); +/// } else { +/// evenOdd.set('odd'); +/// } +/// return () { +/// print('count is changing... do some cleanup if you need to'); +/// }; +/// +/// // This dependency prevents the effect from running every time [evenOdd.value] changes. +/// }, [count.value]); +/// +/// return react.div({}, [ +/// react.p({}, ['${count.value} is ${evenOdd.value}']), +/// react.button({'onClick': (_) => count.set(count.value + 1)}, ['+']), +/// ]); +/// } +/// ``` +/// +/// See: . +void useEffect(dynamic Function() sideEffect, [List dependencies]) { + var wrappedSideEffect = allowInterop(() { + var result = sideEffect(); + if (result is Function) { + return allowInterop(result); + } + + /// When no cleanup function is returned, [sideEffect] returns undefined. + return jsUndefined; + }); + + if (dependencies != null) { + return React.useEffect(wrappedSideEffect, dependencies); + } else { + return React.useEffect(wrappedSideEffect); + } +} + /// Returns a memoized version of [callback] that only changes if one of the [dependencies] has changed. /// /// > __Note:__ there are two [rules for using Hooks](https://reactjs.org/docs/hooks-rules.html): diff --git a/lib/react_client/react_interop.dart b/lib/react_client/react_interop.dart index 9ae66bb3..d135b600 100644 --- a/lib/react_client/react_interop.dart +++ b/lib/react_client/react_interop.dart @@ -47,6 +47,7 @@ abstract class React { external static ReactClass forwardRef(Function(JsMap props, JsRef ref) wrapperFunction); external static List useState(dynamic value); + external static void useEffect(dynamic Function() sideEffect, [List dependencies]); external static Function useCallback(Function callback, List dependencies); external static ReactContext useContext(ReactContext context); } @@ -488,6 +489,11 @@ class JsError { @JS('_jsNull') external get jsNull; +/// A JS variable that can be used with Dart interop in order to force returning a JavaScript `undefined`. +/// Use this if dart2js is possibly converting Dart `undefined` into `null`. +@JS('_jsUndefined') +external get jsUndefined; + /// Throws the error passed to it from Javascript. /// This allows us to catch the error in dart which re-dartifies the js errors/exceptions. @alwaysThrows diff --git a/test/hooks_test.dart b/test/hooks_test.dart index 3d9628a2..6328e7bf 100644 --- a/test/hooks_test.dart +++ b/test/hooks_test.dart @@ -71,10 +71,6 @@ main() { react_dom.render(UseStateTest({}), mountNode); }); - tearDownAll(() { - UseStateTest = null; - }); - test('initializes state correctly', () { expect(countRef.text, '0'); }); @@ -94,6 +90,162 @@ main() { }); }); + group('useEffect -', () { + ReactDartFunctionComponentFactoryProxy UseEffectTest; + ButtonElement countButtonRef; + DivElement countRef; + DivElement mountNode; + int useEffectCallCount; + int useEffectCleanupCallCount; + int useEffectWithDepsCallCount; + int useEffectCleanupWithDepsCallCount; + int useEffectWithDepsCallCount2; + int useEffectCleanupWithDepsCallCount2; + int useEffectWithEmptyDepsCallCount; + int useEffectCleanupWithEmptyDepsCallCount; + + setUpAll(() { + mountNode = new DivElement(); + useEffectCallCount = 0; + useEffectCleanupCallCount = 0; + useEffectWithDepsCallCount = 0; + useEffectCleanupWithDepsCallCount = 0; + useEffectWithDepsCallCount2 = 0; + useEffectCleanupWithDepsCallCount2 = 0; + useEffectWithEmptyDepsCallCount = 0; + useEffectCleanupWithEmptyDepsCallCount = 0; + + UseEffectTest = react.registerFunctionComponent((Map props) { + final count = useState(0); + final countDown = useState(0); + + useEffect(() { + useEffectCallCount++; + return () { + useEffectCleanupCallCount++; + }; + }); + + useEffect(() { + useEffectWithDepsCallCount++; + return () { + useEffectCleanupWithDepsCallCount++; + }; + }, [count.value]); + + useEffect(() { + useEffectWithDepsCallCount2++; + return () { + useEffectCleanupWithDepsCallCount2++; + }; + }, [countDown.value]); + + useEffect(() { + useEffectWithEmptyDepsCallCount++; + return () { + useEffectCleanupWithEmptyDepsCallCount++; + }; + }, []); + + return react.div({}, [ + react.div({ + 'ref': (ref) { + countRef = ref; + }, + }, [ + count.value + ]), + react.button({ + 'onClick': (_) { + count.set(count.value + 1); + }, + 'ref': (ref) { + countButtonRef = ref; + }, + }, [ + '+' + ]), + ]); + }); + + react_dom.render(UseEffectTest({}), mountNode); + }); + + test('side effect (no dependency list) is called after the first render', () { + expect(countRef.text, '0'); + + expect(useEffectCallCount, 1); + expect(useEffectCleanupCallCount, 0, reason: 'component has not been unmounted or re-rendered'); + }); + + test('side effect (with dependency list) is called after the first render', () { + expect(useEffectWithDepsCallCount, 1); + expect(useEffectCleanupWithDepsCallCount, 0, reason: 'component has not been unmounted or re-rendered'); + + expect(useEffectWithDepsCallCount2, 1); + expect(useEffectCleanupWithDepsCallCount2, 0, reason: 'component has not been unmounted or re-rendered'); + }); + + test('side effect (with empty dependency list) is called after the first render', () { + expect(useEffectWithEmptyDepsCallCount, 1); + expect(useEffectCleanupWithEmptyDepsCallCount, 0, reason: 'component has not been unmounted or re-rendered'); + }); + + group('after state change,', () { + setUpAll(() { + react_test_utils.Simulate.click(countButtonRef); + }); + + test('side effect (no dependency list) is called again', () { + expect(countRef.text, '1'); + + expect(useEffectCallCount, 2); + expect(useEffectCleanupCallCount, 1, reason: 'cleanup called before re-render'); + }); + + test('side effect (with dependency list) is called again if one of its dependencies changed', () { + expect(useEffectWithDepsCallCount, 2, reason: 'count.value changed'); + expect(useEffectCleanupWithDepsCallCount, 1, reason: 'cleanup called before re-render'); + }); + + test('side effect (with dependency list) is not called again if none of its dependencies changed', () { + expect(useEffectWithDepsCallCount2, 1, reason: 'countDown.value did not change'); + expect(useEffectCleanupWithDepsCallCount2, 0, + reason: 'cleanup not called because countDown.value did not change'); + }); + + test('side effect (with empty dependency list) is not called again', () { + expect(useEffectWithEmptyDepsCallCount, 1, + reason: 'side effect is only called once for empty dependency list'); + expect(useEffectCleanupWithEmptyDepsCallCount, 0, reason: 'component has not been unmounted'); + }); + }); + + group('after component is unmounted,', () { + setUpAll(() { + react_dom.unmountComponentAtNode(mountNode); + }); + + test('cleanup (no dependency list) is called', () { + expect(useEffectCallCount, 2, reason: 'side effect not called on unmount'); + expect(useEffectCleanupCallCount, 2); + }); + + test('cleanup (with dependency list) is called', () { + expect(useEffectWithDepsCallCount, 2, reason: 'side effect not called on unmount'); + expect(useEffectCleanupWithDepsCallCount, 2); + + expect(useEffectWithDepsCallCount2, 1, reason: 'side effect not called on unmount'); + expect(useEffectCleanupWithDepsCallCount2, 1); + }); + + test('cleanup (with empty dependency list) is called', () { + expect(useEffectWithEmptyDepsCallCount, 1, reason: 'side effect not called on unmount'); + expect(useEffectCleanupWithEmptyDepsCallCount, 1); + }); + }); + }); + group('useCallback -', () { ReactDartFunctionComponentFactoryProxy UseCallbackTest; DivElement deltaRef; @@ -166,10 +318,6 @@ main() { react_dom.render(UseCallbackTest({}), mountNode); }); - tearDownAll(() { - UseCallbackTest = null; - }); - test('callback is called correctly', () { expect(countRef.text, '0'); expect(deltaRef.text, '1');