Skip to content

Commit 6a42f6b

Browse files
committed
Detect chained state in non-internal effect. Clarify internal vs external.
Update isHOCProp to support React.memo fix: allow isHOCProp to accept both React.memo and memo feat: add isReactFunctionalHOC utility function for HOC detection simplify
1 parent 70f322d commit 6a42f6b

File tree

4 files changed

+146
-65
lines changed

4 files changed

+146
-65
lines changed

src/rule.js

Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import { messageIds, messages } from "./messages.js";
2-
import { getCallExpr } from "./util/ast.js";
2+
import { getCallExpr, getDownstreamRefs } from "./util/ast.js";
33
import {
44
isFnRef,
55
findPropUsedToResetAllState,
66
isUseEffect,
77
getUseStateNode,
88
getEffectFnRefs,
99
getDependenciesRefs,
10-
isArgsInternal,
1110
isStateSetter,
1211
isPropCallback,
13-
isInternal,
1412
getEffectFn,
1513
isDirectCall,
14+
getUpstreamReactVariables,
15+
isState,
16+
isProp,
17+
isHOCProp,
1618
} from "./util/react.js";
1719

1820
export const name = "you-might-not-need-an-effect";
@@ -49,7 +51,11 @@ export const rule = {
4951
.filter((ref) => isFnRef(ref))
5052
.filter((ref) => isDirectCall(getEffectFn(node), ref))
5153
.concat(depsRefs)
52-
.every((ref) => isInternal(context, ref));
54+
.flatMap((ref) => getUpstreamReactVariables(context, ref.identifier))
55+
.notEmptyEvery(
56+
(variable) =>
57+
isState(variable) || (isProp(variable) && !isHOCProp(variable)),
58+
);
5359

5460
if (isInternalEffect) {
5561
context.report({
@@ -80,7 +86,11 @@ export const rule = {
8086

8187
effectFnRefs
8288
.filter(
83-
(ref) => isStateSetter(context, ref) || isPropCallback(context, ref),
89+
(ref) =>
90+
isStateSetter(context, ref) ||
91+
(isPropCallback(context, ref) &&
92+
// Don't analyze HOC prop callbacks -- we don't have control over them to lift state or logic
93+
!isHOCProp(ref.resolved)),
8494
)
8595
.filter((ref) => isDirectCall(getEffectFn(node), ref))
8696
.forEach((ref) => {
@@ -100,7 +110,38 @@ export const rule = {
100110
});
101111
}
102112

103-
if (isArgsInternal(context, callExpr.arguments)) {
113+
// TODO: Make more readable
114+
const isArgsInternal = callExpr.arguments
115+
.flatMap((arg) => getDownstreamRefs(context, arg))
116+
.flatMap((ref) =>
117+
getUpstreamReactVariables(context, ref.identifier),
118+
)
119+
.notEmptyEvery(
120+
(variable) =>
121+
isState(variable) ||
122+
(isProp(variable) && !isHOCProp(variable)),
123+
);
124+
const isArgsExternal = callExpr.arguments
125+
.flatMap((arg) => getDownstreamRefs(context, arg))
126+
.flatMap((ref) =>
127+
getUpstreamReactVariables(context, ref.identifier),
128+
)
129+
.some(
130+
(variable) =>
131+
(!isState(variable) && !isProp(variable)) ||
132+
isHOCProp(variable),
133+
);
134+
const isDepsInternal = depsRefs
135+
.flatMap((ref) =>
136+
getUpstreamReactVariables(context, ref.identifier),
137+
)
138+
.notEmptyEvery(
139+
(variable) =>
140+
isState(variable) ||
141+
(isProp(variable) && !isHOCProp(variable)),
142+
);
143+
144+
if (isArgsInternal) {
104145
// TODO: Can also warn if this is the only call to the setter,
105146
// even if the arg is external (and not retrieved in the effect)...
106147
// Does it matter whether the args are in the deps array?
@@ -111,30 +152,28 @@ export const rule = {
111152
messageId: messageIds.avoidDerivedState,
112153
data: { state: stateName },
113154
});
114-
} else if (isInternalEffect) {
115-
if (depsRefs.some((ref) => isInternal(context, ref))) {
116-
context.report({
117-
node: callExpr,
118-
messageId: messageIds.avoidChainingState,
119-
});
120-
}
121155
}
122-
}
123156

124-
// I'm pretty sure we can flag this regardless of the arguments, including none...
125-
//
126-
// Because we are either:
127-
// 1. Passing live state updates to the parent
128-
// 2. Using state as an event handler to pass final state to the parent
129-
//
130-
// Both are bad. However I'm not yet sure how we could differentiate #2 to give a better warning.
131-
//
132-
// TODO: Can we thus safely assume that state is used as an event handler when the ref is a prop?
133-
// Normally we can't warn about that because we don't know what the event handler does externally.
134-
// But when it's a prop, it's internal.
135-
// I guess it could still be valid when the dep is external state? Or in that case,
136-
// the issue is the state should be lifted to the parent?
137-
if (isPropCallback(context, ref)) {
157+
if (!isArgsInternal && !isArgsExternal && isDepsInternal) {
158+
context.report({
159+
node: callExpr,
160+
messageId: messageIds.avoidChainingState,
161+
});
162+
}
163+
} else if (isPropCallback(context, ref)) {
164+
// I'm pretty sure we can flag this regardless of the arguments, including none...
165+
//
166+
// Because we are either:
167+
// 1. Passing live state updates to the parent
168+
// 2. Using state as an event handler to pass final state to the parent
169+
//
170+
// Both are bad. However I'm not yet sure how we could differentiate #2 to give a better warning.
171+
//
172+
// TODO: Can we thus safely assume that state is used as an event handler when the ref is a prop?
173+
// Normally we can't warn about that because we don't know what the event handler does externally.
174+
// But when it's a prop, it's internal.
175+
// I guess it could still be valid when the dep is external state? Or in that case,
176+
// the issue is the state should be lifted to the parent?
138177
context.report({
139178
node: callExpr,
140179
messageId: messageIds.avoidParentChildCoupling,

src/util/react.js

Lines changed: 37 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,26 @@ import {
55
getCallExpr,
66
} from "./ast.js";
77

8-
// NOTE: Returns false for HOC'd components aside from `memo`.
9-
// Which is good? Because the developer may not have control over that to e.g. lift state.
10-
// So we should treat it as external state.
11-
// TODO: Will not detect when they define the component normally and then export it wrapped in the HOC.
128
export const isReactFunctionalComponent = (node) =>
139
(node.type === "FunctionDeclaration" ||
1410
(node.type === "VariableDeclarator" &&
1511
(node.init.type === "ArrowFunctionExpression" ||
16-
(node.init.type === "CallExpression" &&
17-
node.init.callee.type === "Identifier" &&
18-
node.init.callee.name === "memo")))) &&
12+
node.init.type === "CallExpression"))) &&
13+
node.id.type === "Identifier" &&
14+
node.id.name[0].toUpperCase() === node.id.name[0];
15+
16+
// NOTE: Returns false for known pure HOCs -- `memo` and `forwardRef`.
17+
// TODO: Will not detect when they define the component normally and then export it wrapped in the HOC.
18+
// e.g. `const MyComponent = (props) => {...}; export default memo(MyComponent);`
19+
export const isReactFunctionalHOC = (node) =>
20+
node.type === "VariableDeclarator" &&
21+
node.init &&
22+
node.init.type === "CallExpression" &&
23+
node.init.callee.type === "Identifier" &&
24+
!["memo", "forwardRef"].includes(node.init.callee.name) &&
25+
node.init.arguments.length > 0 &&
26+
(node.init.arguments[0].type === "ArrowFunctionExpression" ||
27+
node.init.arguments[0].type === "FunctionExpression") &&
1928
node.id.type === "Identifier" &&
2029
node.id.name[0].toUpperCase() === node.id.name[0];
2130

@@ -97,38 +106,29 @@ export const isPropCallback = (context, ref) =>
97106
isProp(variable),
98107
);
99108

100-
// NOTE: Literals are discarded (because they have no variable) and thus do not count against this.
101-
// TODO: Not entirely sure where in all these every or notEmptyEvery is best...
102-
export const isInternal = (context, ref) =>
103-
getUpstreamReactVariables(context, ref.identifier).notEmptyEvery(
104-
(variable) => isState(variable) || isProp(variable),
105-
);
106-
107-
export const isArgsInternal = (context, args) =>
108-
args.notEmptyEvery((arg) =>
109-
getDownstreamRefs(context, arg)
110-
// TODO: Why do we need to filter this out prior?
111-
// isInternal uses getUpstreamReactVariables which also does.
112-
.filter(
113-
(ref) =>
114-
isProp(ref.resolved) ||
115-
ref.resolved.defs.every(
116-
// Discount non-prop parameters
117-
(def) => def.type !== "Parameter",
118-
),
119-
)
120-
.notEmptyEvery((ref) => isInternal(context, ref)),
121-
);
122-
123109
// NOTE: Global variables (like `JSON` in `JSON.stringify()`) have an empty `defs`; fortunately `[].some() === false`.
124110
// Also, I'm not sure so far when `defs.length > 1`... haven't seen it with shadowed variables or even redeclared variables with `var`.
125-
const isState = (variable) => variable.defs.some((def) => isUseState(def.node));
126-
const isProp = (variable) =>
111+
export const isState = (variable) =>
112+
variable.defs.some((def) => isUseState(def.node));
113+
export const isProp = (variable) =>
127114
variable.defs.some(
128115
(def) =>
129116
def.type === "Parameter" &&
130117
isReactFunctionalComponent(
131-
// TODO: Simplify this
118+
// TODO: Simplify
119+
def.node.type === "ArrowFunctionExpression"
120+
? def.node.parent.type === "CallExpression"
121+
? def.node.parent.parent
122+
: def.node.parent
123+
: def.node,
124+
),
125+
);
126+
export const isHOCProp = (variable) =>
127+
variable.defs.some(
128+
(def) =>
129+
def.type === "Parameter" &&
130+
isReactFunctionalHOC(
131+
// TODO: Simplify
132132
def.node.type === "ArrowFunctionExpression"
133133
? def.node.parent.type === "CallExpression"
134134
? def.node.parent.parent
@@ -232,19 +232,19 @@ const findContainingNode = (useEffectNode) => {
232232
return useEffectNode.parent.parent;
233233
};
234234

235-
const getUpstreamReactVariables = (context, ref) =>
235+
export const getUpstreamReactVariables = (context, node) =>
236236
getUpstreamVariables(
237237
context,
238-
ref,
238+
node,
239239
// Stop at the *usage* of `useState` - don't go up to the `useState` variable.
240240
// Not needed for props - they don't go "too far".
241241
// We could remove this and check for the `useState` variable instead,
242242
// but then all our tests need to import it so we can traverse up to it.
243-
// TODO: Probably some better way to combine these filters.
243+
// And would need to change `getUseStateNode()` too?
244+
// TODO: Could probably organize these filters better.
244245
(node) => !isUseState(node),
245246
).filter(
246247
(variable) =>
247-
// Discount non-prop parameters
248248
isProp(variable) ||
249249
variable.defs.every((def) => def.type !== "Parameter"),
250250
);

test/chaining-state.test.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,25 @@ new MyRuleTester().run("/chaining-state", {
5959
},
6060
],
6161
},
62+
{
63+
name: "In an otherwise valid effect",
64+
code: js`
65+
function MyComponent() {
66+
const [state, setState] = useState();
67+
const [otherState, setOtherState] = useState('Meow');
68+
69+
useEffect(() => {
70+
console.log('Meow');
71+
setState('Hello World');
72+
}, [otherState]);
73+
}
74+
`,
75+
errors: [
76+
{
77+
messageId: messageIds.avoidChainingState,
78+
data: { state: "state" },
79+
},
80+
],
81+
}
6282
],
6383
});

test/deriving-state.test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ new MyRuleTester().run("/deriving-state", {
477477
// thus they will always be in sync and it could be computed during render
478478
// Difficult bit is that a single state setter call is legit when the
479479
// external state is initialized inside the effect (i.e. retrieved from external system)
480+
// Hopefully 'isDirectCall' will mostly catch that now.
480481
setSelectedPost(posts[0]);
481482
}, [posts]);
482483
}
@@ -509,6 +510,27 @@ new MyRuleTester().run("/deriving-state", {
509510
},
510511
],
511512
},
513+
{
514+
name: "From HOC prop with single setter call",
515+
todo: true,
516+
code: js`
517+
import { withRouter } from 'react-router-dom';
518+
519+
const MyComponent = withRouter(({ history }) => {
520+
const [location, setLocation] = useState();
521+
522+
useEffect(() => {
523+
setLocation(history.location);
524+
}, [history.location]);
525+
});
526+
`,
527+
errors: [
528+
{
529+
messageId: messageIds.avoidDerivedState,
530+
data: { state: "fullName" },
531+
},
532+
],
533+
},
512534
{
513535
name: "From props via callback setter",
514536
code: js`

0 commit comments

Comments
 (0)