Skip to content

Commit 9eb37e6

Browse files
feat: new rule - mapStateToProps-prefer-hoisted (#21)
* feat: new rule - mapStateToProps-prefer-hoisted * fix: corner case with returning an empty object * fix: fixing the logic
1 parent aa3a5ff commit 9eb37e6

File tree

5 files changed

+326
-0
lines changed

5 files changed

+326
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,6 @@ To configure individual rules:
5151
* [react-redux/mapDispatchToProps-prefer-object](docs/rules/mapDispatchToProps-prefer-object.md) Enforces that mapDispatchToProps returns an object.
5252
* [react-redux/mapDispatchToProps-prefer-parameters-names](docs/rules/mapDispatchToProps-prefer-parameters-names.md) Enforces that all mapDispatchToProps parameters have specific names.
5353
* [react-redux/mapStateToProps-no-store](docs/rules/mapStateToProps-no-store.md) Prohibits binding a whole store object to a component.
54+
* [react-redux/mapStateToProps-prefer-hoisted](docs/rules/mapStateToProps-prefer-hoisted.md) Flags generation of copies of same-by-value but different-by-reference props.
5455
* [react-redux/mapStateToProps-prefer-parameters-names](docs/rules/mapStateToProps-prefer-parameters-names.md) Enforces that all mapStateToProps parameters have specific names.
5556
* [react-redux/prefer-separate-component-file](docs/rules/prefer-separate-component-file.md) Enforces that all connected components are defined in a separate file.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Flags generation of copies of same-by-value but different-by-reference props (react-redux/mapStateToProps-prefer-hoisted)
2+
3+
Primitives props like strings and numbers are compared by their value, while objects like arrays, dates, and plain objects are compared by their reference.
4+
5+
In case when mapStateToProps creates a new "constant" (i.e. independent of `state` and `ownProps`) object inside of it, React will trigger a re-render of connected component even if actual prop value didn't change.
6+
7+
8+
## Rule details
9+
10+
The following patterns are considered incorrect:
11+
12+
```js
13+
const mapStateToProps = state => {
14+
return {
15+
foo: [1, 2, 3] // this array should be defined outside of mapStateToProps
16+
};
17+
};
18+
```
19+
20+
21+
```js
22+
const mapStateToProps = state => {
23+
return {
24+
foo: { // this object should be defined outside of mapStateToProps
25+
a: 1
26+
}
27+
};
28+
};
29+
```
30+
31+
32+
The following patterns are correct
33+
34+
```js
35+
const mapStateToProps = state => {
36+
return {
37+
a: 1
38+
};
39+
};
40+
```
41+
42+
```js
43+
const mapStateToProps = state => {
44+
const a = state.a;
45+
return {
46+
a
47+
};
48+
};
49+
```
50+
51+
```js
52+
const mapStateToProps = state => ({
53+
user: state.user,
54+
// this is still a bad design because the list prop will be considered
55+
// updated on every store change but the rule will not flag this.
56+
list: [1, 2, state.count]
57+
});
58+
```
59+
60+
61+
## Limitations
62+
63+
Below case wouldn't be flagged by the rule:
64+
65+
```js
66+
const mapStateToProps = state => {
67+
const foo = [];
68+
return {
69+
foo
70+
};
71+
};
72+
```

index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const rules = {
55
'mapDispatchToProps-returns-object': require('./lib/rules/mapDispatchToProps-returns-object'),
66
'mapDispatchToProps-prefer-parameters-names': require('./lib/rules/mapDispatchToProps-prefer-parameters-names'),
77
'mapStateToProps-no-store': require('./lib/rules/mapStateToProps-no-store'),
8+
'mapStateToProps-prefer-hoisted': require('./lib/rules/mapStateToProps-prefer-hoisted'),
89
'mapStateToProps-prefer-parameters-names': require('./lib/rules/mapStateToProps-prefer-parameters-names'),
910
'prefer-separate-component-file': require('./lib/rules/prefer-separate-component-file'),
1011
};
@@ -31,6 +32,7 @@ module.exports = {
3132
'react-redux/mapDispatchToProps-prefer-shorthand': 2,
3233
'react-redux/mapDispatchToProps-returns-object': 2,
3334
'react-redux/mapStateToProps-no-store': 2,
35+
'react-redux/mapStateToProps-prefer-hoisted': 2,
3436
'react-redux/mapStateToProps-prefer-parameters-names': 2,
3537
'react-redux/prefer-separate-component-file': 1,
3638
},
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
const utils = require('../utils');
2+
const isReactReduxConnect = require('../isReactReduxConnect');
3+
4+
const report = function (context, node) {
5+
context.report({
6+
message: 'constant arrays and objects should be initialized outside of mapStateToProps',
7+
node,
8+
});
9+
};
10+
11+
const isConstArrayOrObj = (node, nested) => {
12+
if (node && node.type === 'ObjectExpression') {
13+
return node.properties.reduce((acc, prop) =>
14+
(acc && isConstArrayOrObj(prop.value, (nested + 1))), true);
15+
}
16+
if (node && node.type === 'ArrayExpression') {
17+
return node.elements.reduce((acc, el) =>
18+
(acc && isConstArrayOrObj(el, (nested + 1))), true);
19+
}
20+
if (node && node.type === 'Literal' && nested > 0) {
21+
return true;
22+
}
23+
return false;
24+
};
25+
26+
const checkProp = (node, context) => {
27+
if (isConstArrayOrObj(node, 0)) {
28+
report(context, node);
29+
}
30+
};
31+
32+
33+
const checkFunction = function (context, body) {
34+
const returnNode = utils.getReturnNode(body);
35+
if (returnNode && returnNode.type === 'ObjectExpression') {
36+
returnNode.properties.forEach(prop => checkProp(prop.value, context));
37+
}
38+
};
39+
40+
module.exports = function (context) {
41+
return {
42+
VariableDeclaration(node) {
43+
node.declarations.forEach((decl) => {
44+
if (decl.id && decl.id.name === 'mapStateToProps') {
45+
const body = decl.init.body;
46+
checkFunction(context, body);
47+
}
48+
});
49+
},
50+
FunctionDeclaration(node) {
51+
if (node.id && node.id.name === 'mapStateToProps') {
52+
checkFunction(context, node.body);
53+
}
54+
},
55+
CallExpression(node) {
56+
if (isReactReduxConnect(node)) {
57+
const mapStateToProps = node.arguments && node.arguments[0];
58+
if (mapStateToProps && mapStateToProps.body) {
59+
checkFunction(context, mapStateToProps.body);
60+
}
61+
}
62+
},
63+
};
64+
};
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
require('babel-eslint');
2+
3+
const rule = require('../../../lib/rules/mapStateToProps-prefer-hoisted');
4+
const RuleTester = require('eslint').RuleTester;
5+
6+
const parserOptions = {
7+
ecmaVersion: 6,
8+
sourceType: 'module',
9+
ecmaFeatures: {
10+
experimentalObjectRestSpread: true,
11+
},
12+
};
13+
14+
const errorMessage = 'constant arrays and objects should be initialized outside of mapStateToProps';
15+
16+
const ruleTester = new RuleTester({ parserOptions });
17+
18+
ruleTester.run('mapStateToProps-prefer-hoisted', rule, {
19+
valid: [
20+
`function mapStateToProps(state) {
21+
return {};
22+
}`,
23+
`const mapStateToProps = state => {
24+
return {
25+
a : 1
26+
};
27+
};`,
28+
`const mapStateToProps = state => {
29+
const a = state.a
30+
return {
31+
a
32+
};
33+
};`,
34+
`const mapStateToProps = state => ({
35+
user: state.user,
36+
list: [1, 2, state.count]
37+
});
38+
`,
39+
`const mapStateToProps = state => {
40+
return {
41+
a: 1,
42+
b: [state.b, 2]
43+
};
44+
};
45+
`,
46+
`const mapStateToProps = state => {
47+
const foo = 'hello';
48+
return {
49+
a: 1,
50+
b: [foo, 2]
51+
};
52+
};
53+
`,
54+
'export default connect(null, null)(Alert)',
55+
'connect((state) => ({isActive: state.isActive}), null)(App)',
56+
'connect(null, null)(App)',
57+
`connect(
58+
(state) => {
59+
return {
60+
isActive: state.isActive
61+
}
62+
},
63+
null
64+
)(App)
65+
`,
66+
`connect(function(state){
67+
return {
68+
isActive: state.isActive
69+
}
70+
},
71+
null
72+
)(App)
73+
`,
74+
`const mapStateToProps = function(state) {
75+
return {
76+
a: x
77+
};
78+
}`,
79+
'const mapStateToProps = (state, ownProps) => {}',
80+
'const mapStateToProps = (state) => {set: [1, 2, 3, state.a]}',
81+
`const mapStateToProps = (state, ownProps) => {};
82+
connect(mapStateToProps, null)(Alert);`,
83+
`const mapStateToProps = ({ header }) => ({
84+
isLoggedIn: header.user && header.user.isLoggedIn,
85+
}); `,
86+
'const mapStateToProps = ({header}, ownProps) => {header};',
87+
'connect(({header}, ownProps) => {header})(App);',
88+
'connect(({header}, {ownProp1}) => {header, ownProp1})(App);',
89+
`const mapStateToProps = ({header}, ownProps) => {
90+
return {
91+
props: {
92+
header,
93+
}
94+
}
95+
};`,
96+
],
97+
invalid: [{
98+
code: `const mapStateToProps = (state) => {
99+
return {
100+
foo: {
101+
a: 1
102+
}
103+
}
104+
}`,
105+
errors: [
106+
{
107+
message: errorMessage,
108+
},
109+
],
110+
}, {
111+
code: `const mapStateToProps = state => {
112+
return {
113+
foo: [1, 2, 3]
114+
}
115+
}`,
116+
errors: [
117+
{
118+
message: errorMessage,
119+
},
120+
],
121+
}, {
122+
code: `function mapStateToProps(state) {
123+
return {
124+
a: []
125+
};
126+
}`,
127+
errors: [
128+
{
129+
message: errorMessage,
130+
},
131+
],
132+
}, {
133+
code: `export default connect(
134+
(state) => {
135+
return {
136+
a: {
137+
z: 1
138+
}
139+
}
140+
},
141+
(dispatch) => {
142+
return {
143+
actions: bindActionCreators(actions, dispatch)
144+
}
145+
}
146+
)(App)`,
147+
errors: [
148+
{
149+
message: errorMessage,
150+
},
151+
],
152+
}, {
153+
code: `const mapStateToProps = state => {
154+
return {
155+
a: [1, 2, 3],
156+
};
157+
};
158+
`,
159+
errors: [
160+
{
161+
message: errorMessage,
162+
},
163+
],
164+
}, {
165+
code: `function mapStateToProps(state) {
166+
return {a : {}};
167+
}`,
168+
errors: [
169+
{
170+
message: errorMessage,
171+
},
172+
],
173+
}, {
174+
code: `function mapStateToProps(state) {
175+
return {
176+
aProp: state.aProp,
177+
aConstProp: [1, 2, 3]
178+
};
179+
}`,
180+
errors: [
181+
{
182+
message: errorMessage,
183+
},
184+
],
185+
},
186+
],
187+
});

0 commit comments

Comments
 (0)