Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions example/test/function_component_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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'},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#nit this key is unnecessary

MemoTest({
'localCount': this.state['localCount'],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#nit As per the Dart style guide, avoid unnecessary this

'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');

Expand Down Expand Up @@ -498,6 +556,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({
Expand Down
2 changes: 1 addition & 1 deletion lib/react.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import 'package:react/src/react_client/private_utils.dart' show validateJsApiThe

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>(TProps props, PropValidatorInfo info);

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

Expand Down Expand Up @@ -238,6 +242,69 @@ ReactJsComponentFactoryProxy forwardRef(
return ReactJsComponentFactoryProxy(hoc);
}

/// 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: <https://reactjs.org/docs/react-api.html#reactmemo>.
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);
Expand Down
163 changes: 163 additions & 0 deletions test/react_memo_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import 'dart:async';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#nit unused import

Suggested change
import 'dart:async';

import 'dart:developer';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#nit unused import

Suggested change
import 'dart:developer';

@TestOn('browser')
import 'dart:html';
import 'dart:js_util';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#nit unused import

Suggested change
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<Element> localCountDisplayRef;
Ref<Element> 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<Element>();
valueMemoShouldIgnoreViaAreEqualDisplayRef = react.createRef<Element>();
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'],
}),
);
}
}
13 changes: 13 additions & 0 deletions test/react_memo_test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
<title></title>
<script src="packages/react/react_with_addons.js"></script>
<script src="packages/react/react_dom.js"></script>
<link rel="x-dart-test" href="react_memo_test.dart">
<script src="packages/test/dart.js"></script>
</head>
<body>
</body>
</html>