Skip to content

Commit e63bee1

Browse files
Add support for React.memo
1 parent 5c19d8d commit e63bee1

File tree

5 files changed

+309
-1
lines changed

5 files changed

+309
-1
lines changed

example/test/function_component_test.dart

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,64 @@ HookTestComponent(Map props) {
3434
]);
3535
}
3636

37+
final MemoTestDemoWrapper = react.registerComponent(() => _MemoTestDemoWrapper());
38+
39+
class _MemoTestDemoWrapper extends react.Component2 {
40+
@override
41+
get initialState => {'localCount': 0, 'someKeyThatMemoShouldIgnore': 0};
42+
43+
@override
44+
render() {
45+
return react.div(
46+
{'key': 'mtdw'},
47+
MemoTest({
48+
'localCount': this.state['localCount'],
49+
'someKeyThatMemoShouldIgnore': this.state['someKeyThatMemoShouldIgnore'],
50+
}),
51+
react.button({
52+
'type': 'button',
53+
'className': 'btn btn-primary',
54+
'style': {'marginRight': '10px'},
55+
'onClick': (_) {
56+
this.setState({'localCount': this.state['localCount'] + 1});
57+
},
58+
}, 'Update MemoTest props.localCount value (${this.state['localCount']})'),
59+
react.button({
60+
'type': 'button',
61+
'className': 'btn btn-primary',
62+
'onClick': (_) {
63+
this.setState({'someKeyThatMemoShouldIgnore': this.state['someKeyThatMemoShouldIgnore'] + 1});
64+
},
65+
}, 'Update prop value that MemoTest will ignore (${this.state['someKeyThatMemoShouldIgnore']})'),
66+
);
67+
}
68+
}
69+
70+
final MemoTest = react.memo((Map props) {
71+
final context = useContext(TestNewContext);
72+
return react.div(
73+
{},
74+
react.p(
75+
{},
76+
'useContext counter value: ',
77+
react.strong({}, context['renderCount']),
78+
),
79+
react.p(
80+
{},
81+
'props.localCount value: ',
82+
react.strong({}, props['localCount']),
83+
),
84+
react.p(
85+
{},
86+
'props.someKeyThatMemoShouldIgnore value: ',
87+
react.strong({}, props['someKeyThatMemoShouldIgnore']),
88+
' (should never update)',
89+
),
90+
);
91+
}, areEqual: (prevProps, nextProps) {
92+
return prevProps['localCount'] == nextProps['localCount'];
93+
}, displayName: 'MemoTest');
94+
3795
var useReducerTestFunctionComponent =
3896
react.registerFunctionComponent(UseReducerTestComponent, displayName: 'useReducerTest');
3997

@@ -218,6 +276,13 @@ void main() {
218276
useRefTestFunctionComponent({
219277
'key': 'useRefTest',
220278
}, []),
279+
react.h2({'key': 'memoTestLabel'}, ['memo Test']),
280+
newContextProviderComponent(
281+
{
282+
'key': 'memoContextProvider',
283+
},
284+
MemoTestDemoWrapper({}),
285+
),
221286
]),
222287
querySelector('#content'));
223288
}

lib/react.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import 'package:react/src/context.dart';
1717

1818
export 'package:react/src/context.dart';
1919
export 'package:react/src/prop_validator.dart';
20-
export 'package:react/react_client/react_interop.dart' show forwardRef, createRef;
20+
export 'package:react/react_client/react_interop.dart' show forwardRef, createRef, memo;
2121

2222
typedef Error PropValidator<TProps>(TProps props, PropValidatorInfo info);
2323

lib/react_client/react_interop.dart

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ abstract class React {
4242
external static ReactElement createElement(dynamic type, props, [dynamic children]);
4343
external static JsRef createRef();
4444
external static ReactClass forwardRef(Function(JsMap props, JsRef ref) wrapperFunction);
45+
external static ReactClass memo(
46+
dynamic Function(JsMap props, [JsMap legacyContext]) wrapperFunction, [
47+
bool Function(JsMap prevProps, JsMap nextProps) areEqual,
48+
]);
4549

4650
external static bool isValidElement(dynamic object);
4751

@@ -220,6 +224,69 @@ ReactJsComponentFactoryProxy forwardRef(Function(Map props, Ref ref) wrapperFunc
220224
return new ReactJsComponentFactoryProxy(hoc, shouldConvertDomProps: false);
221225
}
222226

227+
/// A [higher order component](https://reactjs.org/docs/higher-order-components.html) for function components
228+
/// that behaves similar to the way [`React.PureComponent`](https://reactjs.org/docs/react-api.html#reactpurecomponent)
229+
/// does for class-based components.
230+
///
231+
/// If your function component renders the same result given the same props, you can wrap it in a call to
232+
/// `memo` for a performance boost in some cases by memoizing the result. This means that React will skip
233+
/// rendering the component, and reuse the last rendered result.
234+
///
235+
/// ```dart
236+
/// import 'package:react/react.dart' as react;
237+
///
238+
/// final MyComponent = react.memo((props) {
239+
/// /* render using props */
240+
/// });
241+
/// ```
242+
///
243+
/// `memo` only affects props changes. If your function component wrapped in `memo` has a
244+
/// [useState] or [useContext] Hook in its implementation, it will still rerender when `state` or `context` change.
245+
///
246+
/// By default it will only shallowly compare complex objects in the props map.
247+
/// If you want control over the comparison, you can also provide a custom comparison
248+
/// function to the [areEqual] argument as shown in the example below.
249+
///
250+
/// ```dart
251+
/// import 'package:react/react.dart' as react;
252+
///
253+
/// final MyComponent = react.memo((props) {
254+
/// // render using props
255+
/// }, areEqual: (prevProps, nextProps) {
256+
/// // Do some custom comparison logic to return a bool based on prevProps / nextProps
257+
/// });
258+
/// ```
259+
///
260+
/// > __This method only exists as a performance optimization.__
261+
/// >
262+
/// > Do not rely on it to “prevent” a render, as this can lead to bugs.
263+
///
264+
/// See: <https://reactjs.org/docs/react-api.html#reactmemo>.
265+
ReactJsComponentFactoryProxy memo(
266+
dynamic Function(Map props) wrapperFunction, {
267+
bool Function(Map prevProps, Map nextProps) areEqual,
268+
String displayName = 'Anonymous',
269+
}) {
270+
final _areEqual = areEqual == null
271+
? null
272+
: allowInterop((JsMap prevProps, JsMap nextProps) {
273+
final dartPrevProps = JsBackedMap.backedBy(prevProps);
274+
final dartNextProps = JsBackedMap.backedBy(nextProps);
275+
return areEqual(dartPrevProps, dartNextProps);
276+
});
277+
278+
final wrappedComponent = allowInterop((JsMap props, [JsMap _]) {
279+
final dartProps = JsBackedMap.backedBy(props);
280+
return wrapperFunction(dartProps);
281+
});
282+
283+
defineProperty(wrappedComponent, 'displayName', jsify({'value': displayName}));
284+
285+
final hoc = React.memo(wrappedComponent, _areEqual);
286+
287+
return ReactJsComponentFactoryProxy(hoc);
288+
}
289+
223290
abstract class ReactDom {
224291
static Element findDOMNode(object) => ReactDOM.findDOMNode(object);
225292
static ReactComponent render(ReactElement component, Element element) => ReactDOM.render(component, element);

test/react_memo_test.dart

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import 'dart:async';
2+
import 'dart:developer';
3+
@TestOn('browser')
4+
import 'dart:html';
5+
import 'dart:js_util';
6+
7+
import 'package:react/react.dart' as react;
8+
import 'package:react/react_client.dart';
9+
import 'package:react/react_test_utils.dart' as rtu;
10+
import 'package:test/test.dart';
11+
12+
main() {
13+
setClientConfiguration();
14+
15+
Ref<_MemoTestWrapperComponent> memoTestWrapperComponentRef;
16+
Ref<Element> localCountDisplayRef;
17+
Ref<Element> valueMemoShouldIgnoreViaAreEqualDisplayRef;
18+
int childMemoRenderCount;
19+
20+
void renderMemoTest({
21+
bool testAreEqual = false,
22+
String displayName,
23+
}) {
24+
expect(memoTestWrapperComponentRef, isNotNull, reason: 'test setup sanity check');
25+
expect(localCountDisplayRef, isNotNull, reason: 'test setup sanity check');
26+
expect(valueMemoShouldIgnoreViaAreEqualDisplayRef, isNotNull, reason: 'test setup sanity check');
27+
28+
final customAreEqualFn = !testAreEqual
29+
? null
30+
: (prevProps, nextProps) {
31+
return prevProps['localCount'] == nextProps['localCount'];
32+
};
33+
34+
final MemoTest = react.memo((Map props) {
35+
childMemoRenderCount++;
36+
return react.div(
37+
{},
38+
react.p(
39+
{'ref': localCountDisplayRef},
40+
props['localCount'],
41+
),
42+
react.p(
43+
{'ref': valueMemoShouldIgnoreViaAreEqualDisplayRef},
44+
props['valueMemoShouldIgnoreViaAreEqual'],
45+
),
46+
);
47+
}, areEqual: customAreEqualFn, displayName: displayName);
48+
49+
rtu.renderIntoDocument(MemoTestWrapper({
50+
'ref': memoTestWrapperComponentRef,
51+
'memoComponentFactory': MemoTest,
52+
}));
53+
54+
expect(localCountDisplayRef.current, isNotNull, reason: 'test setup sanity check');
55+
expect(valueMemoShouldIgnoreViaAreEqualDisplayRef.current, isNotNull, reason: 'test setup sanity check');
56+
expect(memoTestWrapperComponentRef.current.redrawCount, 0, reason: 'test setup sanity check');
57+
expect(childMemoRenderCount, 1, reason: 'test setup sanity check');
58+
expect(memoTestWrapperComponentRef.current.state['localCount'], 0, reason: 'test setup sanity check');
59+
expect(memoTestWrapperComponentRef.current.state['valueMemoShouldIgnoreViaAreEqual'], 0,
60+
reason: 'test setup sanity check');
61+
expect(memoTestWrapperComponentRef.current.state['valueMemoShouldNotKnowAbout'], 0,
62+
reason: 'test setup sanity check');
63+
}
64+
65+
group('memo', () {
66+
setUp(() {
67+
memoTestWrapperComponentRef = react.createRef<_MemoTestWrapperComponent>();
68+
localCountDisplayRef = react.createRef<Element>();
69+
valueMemoShouldIgnoreViaAreEqualDisplayRef = react.createRef<Element>();
70+
childMemoRenderCount = 0;
71+
});
72+
73+
tearDown(() {
74+
memoTestWrapperComponentRef = null;
75+
localCountDisplayRef = null;
76+
valueMemoShouldIgnoreViaAreEqualDisplayRef = null;
77+
});
78+
79+
group('renders its child component when props change', () {
80+
test('', () {
81+
renderMemoTest();
82+
83+
memoTestWrapperComponentRef.current.increaseLocalCount();
84+
expect(memoTestWrapperComponentRef.current.state['localCount'], 1, reason: 'test setup sanity check');
85+
expect(memoTestWrapperComponentRef.current.redrawCount, 1, reason: 'test setup sanity check');
86+
87+
expect(childMemoRenderCount, 2);
88+
expect(localCountDisplayRef.current.text, '1');
89+
90+
memoTestWrapperComponentRef.current.increaseValueMemoShouldIgnoreViaAreEqual();
91+
expect(memoTestWrapperComponentRef.current.state['valueMemoShouldIgnoreViaAreEqual'], 1,
92+
reason: 'test setup sanity check');
93+
expect(memoTestWrapperComponentRef.current.redrawCount, 2, reason: 'test setup sanity check');
94+
95+
expect(childMemoRenderCount, 3);
96+
expect(valueMemoShouldIgnoreViaAreEqualDisplayRef.current.text, '1');
97+
});
98+
99+
test('unless the areEqual argument is set to a function that customizes when re-renders occur', () {
100+
renderMemoTest(testAreEqual: true);
101+
102+
memoTestWrapperComponentRef.current.increaseValueMemoShouldIgnoreViaAreEqual();
103+
expect(memoTestWrapperComponentRef.current.state['valueMemoShouldIgnoreViaAreEqual'], 1,
104+
reason: 'test setup sanity check');
105+
expect(memoTestWrapperComponentRef.current.redrawCount, 1, reason: 'test setup sanity check');
106+
107+
expect(childMemoRenderCount, 1);
108+
expect(valueMemoShouldIgnoreViaAreEqualDisplayRef.current.text, '0');
109+
});
110+
});
111+
112+
test('does not re-render its child component when parent updates and props remain the same', () {
113+
renderMemoTest();
114+
115+
memoTestWrapperComponentRef.current.increaseValueMemoShouldNotKnowAbout();
116+
expect(memoTestWrapperComponentRef.current.state['valueMemoShouldNotKnowAbout'], 1,
117+
reason: 'test setup sanity check');
118+
expect(memoTestWrapperComponentRef.current.redrawCount, 1, reason: 'test setup sanity check');
119+
120+
expect(childMemoRenderCount, 1);
121+
});
122+
});
123+
}
124+
125+
final MemoTestWrapper = react.registerComponent(() => _MemoTestWrapperComponent());
126+
127+
class _MemoTestWrapperComponent extends react.Component2 {
128+
int redrawCount = 0;
129+
130+
get initialState => {
131+
'localCount': 0,
132+
'valueMemoShouldIgnoreViaAreEqual': 0,
133+
'valueMemoShouldNotKnowAbout': 0,
134+
};
135+
136+
@override
137+
void componentDidUpdate(Map prevProps, Map prevState, [dynamic snapshot]) {
138+
redrawCount++;
139+
}
140+
141+
void increaseLocalCount() {
142+
this.setState({'localCount': this.state['localCount'] + 1});
143+
}
144+
145+
void increaseValueMemoShouldIgnoreViaAreEqual() {
146+
this.setState({'valueMemoShouldIgnoreViaAreEqual': this.state['valueMemoShouldIgnoreViaAreEqual'] + 1});
147+
}
148+
149+
void increaseValueMemoShouldNotKnowAbout() {
150+
this.setState({'valueMemoShouldNotKnowAbout': this.state['valueMemoShouldNotKnowAbout'] + 1});
151+
}
152+
153+
@override
154+
render() {
155+
return react.div(
156+
{},
157+
props['memoComponentFactory']({
158+
'localCount': this.state['localCount'],
159+
'valueMemoShouldIgnoreViaAreEqual': this.state['valueMemoShouldIgnoreViaAreEqual'],
160+
}),
161+
);
162+
}
163+
}

test/react_memo_test.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head lang="en">
4+
<meta charset="UTF-8">
5+
<title></title>
6+
<script src="packages/react/react_with_addons.js"></script>
7+
<script src="packages/react/react_dom.js"></script>
8+
<link rel="x-dart-test" href="react_memo_test.dart">
9+
<script src="packages/test/dart.js"></script>
10+
</head>
11+
<body>
12+
</body>
13+
</html>

0 commit comments

Comments
 (0)