From e3bdc7ae00162204d0925840d9e79777ee0188fe Mon Sep 17 00:00:00 2001 From: Aaron Lademann Date: Fri, 24 Jan 2020 13:35:20 -0700 Subject: [PATCH] Add support for React.memo --- example/test/function_component_test.dart | 65 +++++++++ lib/react.dart | 2 +- lib/react_client/react_interop.dart | 67 +++++++++ test/react_memo_test.dart | 163 ++++++++++++++++++++++ test/react_memo_test.html | 13 ++ 5 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 test/react_memo_test.dart create mode 100644 test/react_memo_test.html diff --git a/example/test/function_component_test.dart b/example/test/function_component_test.dart index 6a8acad3..5ba83d8a 100644 --- a/example/test/function_component_test.dart +++ b/example/test/function_component_test.dart @@ -34,6 +34,64 @@ HookTestComponent(Map props) { ]); } +final MemoTestDemoWrapper = react.registerComponent(() => _MemoTestDemoWrapper()); + +class _MemoTestDemoWrapper extends react.Component2 { + @override + get initialState => {'localCount': 0, 'someKeyThatMemoShouldIgnore': 0}; + + @override + render() { + return react.div( + {'key': 'mtdw'}, + MemoTest({ + 'localCount': this.state['localCount'], + 'someKeyThatMemoShouldIgnore': this.state['someKeyThatMemoShouldIgnore'], + }), + react.button({ + 'type': 'button', + 'className': 'btn btn-primary', + 'style': {'marginRight': '10px'}, + 'onClick': (_) { + this.setState({'localCount': this.state['localCount'] + 1}); + }, + }, 'Update MemoTest props.localCount value (${this.state['localCount']})'), + react.button({ + 'type': 'button', + 'className': 'btn btn-primary', + 'onClick': (_) { + this.setState({'someKeyThatMemoShouldIgnore': this.state['someKeyThatMemoShouldIgnore'] + 1}); + }, + }, 'Update prop value that MemoTest will ignore (${this.state['someKeyThatMemoShouldIgnore']})'), + ); + } +} + +final MemoTest = react.memo((Map props) { + final context = useContext(TestNewContext); + return react.div( + {}, + react.p( + {}, + 'useContext counter value: ', + react.strong({}, context['renderCount']), + ), + react.p( + {}, + 'props.localCount value: ', + react.strong({}, props['localCount']), + ), + react.p( + {}, + 'props.someKeyThatMemoShouldIgnore value: ', + react.strong({}, props['someKeyThatMemoShouldIgnore']), + ' (should never update)', + ), + ); +}, areEqual: (prevProps, nextProps) { + return prevProps['localCount'] == nextProps['localCount']; +}, displayName: 'MemoTest'); + var useReducerTestFunctionComponent = react.registerFunctionComponent(UseReducerTestComponent, displayName: 'useReducerTest'); @@ -265,6 +323,13 @@ void main() { useRefTestFunctionComponent({ 'key': 'useRefTest', }, []), + react.h2({'key': 'memoTestLabel'}, ['memo Test']), + newContextProviderComponent( + { + 'key': 'memoContextProvider', + }, + MemoTestDemoWrapper({}), + ), react.h2({'key': 'useMemoTestLabel'}, ['useMemo Hook Test']), react.h6({'key': 'h61'}, ['With useMemo:']), useMemoTestFunctionComponent({ diff --git a/lib/react.dart b/lib/react.dart index 3478fe77..01a38cd5 100644 --- a/lib/react.dart +++ b/lib/react.dart @@ -17,7 +17,7 @@ import 'package:react/src/context.dart'; export 'package:react/src/context.dart'; export 'package:react/src/prop_validator.dart'; -export 'package:react/react_client/react_interop.dart' show forwardRef, createRef; +export 'package:react/react_client/react_interop.dart' show forwardRef, createRef, memo; typedef Error PropValidator(TProps props, PropValidatorInfo info); diff --git a/lib/react_client/react_interop.dart b/lib/react_client/react_interop.dart index 1ae79083..394b3843 100644 --- a/lib/react_client/react_interop.dart +++ b/lib/react_client/react_interop.dart @@ -42,6 +42,10 @@ abstract class React { external static ReactElement createElement(dynamic type, props, [dynamic children]); external static JsRef createRef(); external static ReactClass forwardRef(Function(JsMap props, JsRef ref) wrapperFunction); + external static ReactClass memo( + dynamic Function(JsMap props, [JsMap legacyContext]) wrapperFunction, [ + bool Function(JsMap prevProps, JsMap nextProps) areEqual, + ]); external static bool isValidElement(dynamic object); @@ -221,6 +225,69 @@ ReactJsComponentFactoryProxy forwardRef(Function(Map props, Ref ref) wrapperFunc return new ReactJsComponentFactoryProxy(hoc, shouldConvertDomProps: false); } +/// A [higher order component](https://reactjs.org/docs/higher-order-components.html) for function components +/// that behaves similar to the way [`React.PureComponent`](https://reactjs.org/docs/react-api.html#reactpurecomponent) +/// does for class-based components. +/// +/// If your function component renders the same result given the same props, you can wrap it in a call to +/// `memo` for a performance boost in some cases by memoizing the result. This means that React will skip +/// rendering the component, and reuse the last rendered result. +/// +/// ```dart +/// import 'package:react/react.dart' as react; +/// +/// final MyComponent = react.memo((props) { +/// /* render using props */ +/// }); +/// ``` +/// +/// `memo` only affects props changes. If your function component wrapped in `memo` has a +/// [useState] or [useContext] Hook in its implementation, it will still rerender when `state` or `context` change. +/// +/// By default it will only shallowly compare complex objects in the props map. +/// If you want control over the comparison, you can also provide a custom comparison +/// function to the [areEqual] argument as shown in the example below. +/// +/// ```dart +/// import 'package:react/react.dart' as react; +/// +/// final MyComponent = react.memo((props) { +/// // render using props +/// }, areEqual: (prevProps, nextProps) { +/// // Do some custom comparison logic to return a bool based on prevProps / nextProps +/// }); +/// ``` +/// +/// > __This method only exists as a performance optimization.__ +/// > +/// > Do not rely on it to “prevent” a render, as this can lead to bugs. +/// +/// See: . +ReactJsComponentFactoryProxy memo( + dynamic Function(Map props) wrapperFunction, { + bool Function(Map prevProps, Map nextProps) areEqual, + String displayName = 'Anonymous', +}) { + final _areEqual = areEqual == null + ? null + : allowInterop((JsMap prevProps, JsMap nextProps) { + final dartPrevProps = JsBackedMap.backedBy(prevProps); + final dartNextProps = JsBackedMap.backedBy(nextProps); + return areEqual(dartPrevProps, dartNextProps); + }); + + final wrappedComponent = allowInterop((JsMap props, [JsMap _]) { + final dartProps = JsBackedMap.backedBy(props); + return wrapperFunction(dartProps); + }); + + defineProperty(wrappedComponent, 'displayName', jsify({'value': displayName})); + + final hoc = React.memo(wrappedComponent, _areEqual); + + return ReactJsComponentFactoryProxy(hoc); +} + 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/react_memo_test.dart b/test/react_memo_test.dart new file mode 100644 index 00000000..4620a3ae --- /dev/null +++ b/test/react_memo_test.dart @@ -0,0 +1,163 @@ +import 'dart:async'; +import 'dart:developer'; +@TestOn('browser') +import 'dart:html'; +import 'dart:js_util'; + +import 'package:react/react.dart' as react; +import 'package:react/react_client.dart'; +import 'package:react/react_test_utils.dart' as rtu; +import 'package:test/test.dart'; + +main() { + setClientConfiguration(); + + Ref<_MemoTestWrapperComponent> memoTestWrapperComponentRef; + Ref localCountDisplayRef; + Ref valueMemoShouldIgnoreViaAreEqualDisplayRef; + int childMemoRenderCount; + + void renderMemoTest({ + bool testAreEqual = false, + String displayName, + }) { + expect(memoTestWrapperComponentRef, isNotNull, reason: 'test setup sanity check'); + expect(localCountDisplayRef, isNotNull, reason: 'test setup sanity check'); + expect(valueMemoShouldIgnoreViaAreEqualDisplayRef, isNotNull, reason: 'test setup sanity check'); + + final customAreEqualFn = !testAreEqual + ? null + : (prevProps, nextProps) { + return prevProps['localCount'] == nextProps['localCount']; + }; + + final MemoTest = react.memo((Map props) { + childMemoRenderCount++; + return react.div( + {}, + react.p( + {'ref': localCountDisplayRef}, + props['localCount'], + ), + react.p( + {'ref': valueMemoShouldIgnoreViaAreEqualDisplayRef}, + props['valueMemoShouldIgnoreViaAreEqual'], + ), + ); + }, areEqual: customAreEqualFn, displayName: displayName); + + rtu.renderIntoDocument(MemoTestWrapper({ + 'ref': memoTestWrapperComponentRef, + 'memoComponentFactory': MemoTest, + })); + + expect(localCountDisplayRef.current, isNotNull, reason: 'test setup sanity check'); + expect(valueMemoShouldIgnoreViaAreEqualDisplayRef.current, isNotNull, reason: 'test setup sanity check'); + expect(memoTestWrapperComponentRef.current.redrawCount, 0, reason: 'test setup sanity check'); + expect(childMemoRenderCount, 1, reason: 'test setup sanity check'); + expect(memoTestWrapperComponentRef.current.state['localCount'], 0, reason: 'test setup sanity check'); + expect(memoTestWrapperComponentRef.current.state['valueMemoShouldIgnoreViaAreEqual'], 0, + reason: 'test setup sanity check'); + expect(memoTestWrapperComponentRef.current.state['valueMemoShouldNotKnowAbout'], 0, + reason: 'test setup sanity check'); + } + + group('memo', () { + setUp(() { + memoTestWrapperComponentRef = react.createRef<_MemoTestWrapperComponent>(); + localCountDisplayRef = react.createRef(); + valueMemoShouldIgnoreViaAreEqualDisplayRef = react.createRef(); + childMemoRenderCount = 0; + }); + + tearDown(() { + memoTestWrapperComponentRef = null; + localCountDisplayRef = null; + valueMemoShouldIgnoreViaAreEqualDisplayRef = null; + }); + + group('renders its child component when props change', () { + test('', () { + renderMemoTest(); + + memoTestWrapperComponentRef.current.increaseLocalCount(); + expect(memoTestWrapperComponentRef.current.state['localCount'], 1, reason: 'test setup sanity check'); + expect(memoTestWrapperComponentRef.current.redrawCount, 1, reason: 'test setup sanity check'); + + expect(childMemoRenderCount, 2); + expect(localCountDisplayRef.current.text, '1'); + + memoTestWrapperComponentRef.current.increaseValueMemoShouldIgnoreViaAreEqual(); + expect(memoTestWrapperComponentRef.current.state['valueMemoShouldIgnoreViaAreEqual'], 1, + reason: 'test setup sanity check'); + expect(memoTestWrapperComponentRef.current.redrawCount, 2, reason: 'test setup sanity check'); + + expect(childMemoRenderCount, 3); + expect(valueMemoShouldIgnoreViaAreEqualDisplayRef.current.text, '1'); + }); + + test('unless the areEqual argument is set to a function that customizes when re-renders occur', () { + renderMemoTest(testAreEqual: true); + + memoTestWrapperComponentRef.current.increaseValueMemoShouldIgnoreViaAreEqual(); + expect(memoTestWrapperComponentRef.current.state['valueMemoShouldIgnoreViaAreEqual'], 1, + reason: 'test setup sanity check'); + expect(memoTestWrapperComponentRef.current.redrawCount, 1, reason: 'test setup sanity check'); + + expect(childMemoRenderCount, 1); + expect(valueMemoShouldIgnoreViaAreEqualDisplayRef.current.text, '0'); + }); + }); + + test('does not re-render its child component when parent updates and props remain the same', () { + renderMemoTest(); + + memoTestWrapperComponentRef.current.increaseValueMemoShouldNotKnowAbout(); + expect(memoTestWrapperComponentRef.current.state['valueMemoShouldNotKnowAbout'], 1, + reason: 'test setup sanity check'); + expect(memoTestWrapperComponentRef.current.redrawCount, 1, reason: 'test setup sanity check'); + + expect(childMemoRenderCount, 1); + }); + }); +} + +final MemoTestWrapper = react.registerComponent(() => _MemoTestWrapperComponent()); + +class _MemoTestWrapperComponent extends react.Component2 { + int redrawCount = 0; + + get initialState => { + 'localCount': 0, + 'valueMemoShouldIgnoreViaAreEqual': 0, + 'valueMemoShouldNotKnowAbout': 0, + }; + + @override + void componentDidUpdate(Map prevProps, Map prevState, [dynamic snapshot]) { + redrawCount++; + } + + void increaseLocalCount() { + this.setState({'localCount': this.state['localCount'] + 1}); + } + + void increaseValueMemoShouldIgnoreViaAreEqual() { + this.setState({'valueMemoShouldIgnoreViaAreEqual': this.state['valueMemoShouldIgnoreViaAreEqual'] + 1}); + } + + void increaseValueMemoShouldNotKnowAbout() { + this.setState({'valueMemoShouldNotKnowAbout': this.state['valueMemoShouldNotKnowAbout'] + 1}); + } + + @override + render() { + return react.div( + {}, + props['memoComponentFactory']({ + 'localCount': this.state['localCount'], + 'valueMemoShouldIgnoreViaAreEqual': this.state['valueMemoShouldIgnoreViaAreEqual'], + }), + ); + } +} diff --git a/test/react_memo_test.html b/test/react_memo_test.html new file mode 100644 index 00000000..2305897d --- /dev/null +++ b/test/react_memo_test.html @@ -0,0 +1,13 @@ + + + + + + + + + + + + +