Skip to content

Commit 2da1b25

Browse files
committed
refactor: get upstream references instead of variables
better low-level API - we can still do our variable stuff via `ref.resolved`, but now have the refs available to analyze too
1 parent 80506b7 commit 2da1b25

File tree

5 files changed

+92
-83
lines changed

5 files changed

+92
-83
lines changed

src/no-derived-state.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import {
55
isStateSetter,
66
getUseStateNode,
77
isProp,
8-
getUpstreamReactVariables,
98
isState,
109
hasCleanup,
1110
isUseEffect,
11+
getUpstreamRefs,
1212
} from "./util/react.js";
1313
import { getCallExpr, getDownstreamRefs } from "./util/ast.js";
1414

@@ -52,14 +52,16 @@ export default {
5252
(ref) => isState(context, ref) || isProp(context, ref),
5353
);
5454

55-
const argsUpstreamVars = argsRefs.flatMap((ref) =>
56-
getUpstreamReactVariables(context, ref.resolved),
55+
const argsUpstreamRefs = argsRefs.flatMap((ref) =>
56+
getUpstreamRefs(context, ref),
5757
);
58-
const depsUpstreamVars = depsRefs.flatMap((ref) =>
59-
getUpstreamReactVariables(context, ref.resolved),
58+
const depsUpstreamRefs = depsRefs.flatMap((ref) =>
59+
getUpstreamRefs(context, ref),
6060
);
61-
const isAllArgsInDeps = argsUpstreamVars.notEmptyEvery((argVar) =>
62-
depsUpstreamVars.some((depVar) => argVar.name === depVar.name),
61+
const isAllArgsInDeps = argsUpstreamRefs.notEmptyEvery((argRef) =>
62+
depsUpstreamRefs.some(
63+
(depRef) => argRef.resolved.name === depRef.resolved.name,
64+
),
6365
);
6466
const isValueAlwaysInSync = isAllArgsInDeps && countCalls(ref) === 1;
6567

src/util/ast.js

Lines changed: 10 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { findVariable } from "eslint-utils";
22

3+
/**
4+
* @import {Scope} from 'eslint'
5+
* @import {Rule} from 'eslint'
6+
* @import {AST} from 'eslint'
7+
*/
8+
39
export const traverse = (context, node, visit, visited = new Set()) => {
410
if (visited.has(node)) {
511
return;
@@ -31,42 +37,10 @@ export const findDownstreamNodes = (context, topNode, type) => {
3137
return nodes;
3238
};
3339

34-
export const getUpstreamVariables = (
35-
context,
36-
variable,
37-
filter,
38-
visited = new Set(),
39-
) => {
40-
if (visited.has(variable)) {
41-
return [];
42-
}
43-
44-
visited.add(variable);
45-
46-
const upstreamVariables = variable.defs
47-
// TODO: https://github.com/NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect/issues/34
48-
// `init` covers for arrow functions; also needs `body` to descend into function declarations
49-
// But then for function parameters (including props), `def.node.body` is the body of the function that they belong to,
50-
// so we get *all* the downstream refs in it...
51-
// We only want to descend when we're traversing up the function itself; no its parameters.
52-
// Probably similar logic to in `getUpstreamReactVariables`.
53-
.filter((def) => !!def.node.init)
54-
.filter((def) => filter(def.node))
55-
.flatMap((def) => getDownstreamRefs(context, def.node.init))
56-
.map((ref) => ref.resolved)
57-
// I think this only happens when:
58-
// 1. There's genuinely no variable, i.e. `node` is a literal
59-
// 2. Import statement is missing
60-
// 3. ESLint globals are misconfigured
61-
.filter(Boolean)
62-
.flatMap((variable) =>
63-
getUpstreamVariables(context, variable, filter, visited),
64-
);
65-
66-
// Ultimately return only leaf variables
67-
return upstreamVariables.length === 0 ? [variable] : upstreamVariables;
68-
};
69-
40+
/**
41+
* @param {Rule.RuleContext} context
42+
* @param {Rule.Node} node
43+
*/
7044
export const getDownstreamRefs = (context, node) =>
7145
findDownstreamNodes(context, node, "Identifier")
7246
.map((identifier) => getRef(context, identifier))

src/util/react.js

Lines changed: 55 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { getUpstreamVariables, getDownstreamRefs, getCallExpr } from "./ast.js";
1+
import { getDownstreamRefs, getCallExpr } from "./ast.js";
2+
3+
/**
4+
* @import {Scope} from 'eslint'
5+
* @import {Rule} from 'eslint'
6+
* @import {AST} from 'eslint'
7+
*/
28

39
export const isReactFunctionalComponent = (node) =>
410
(node.type === "FunctionDeclaration" ||
@@ -130,23 +136,24 @@ export const isPropCallback = (context, ref) =>
130136
// NOTE: Global variables (like `JSON` in `JSON.stringify()`) have an empty `defs`; fortunately `[].some() === false`.
131137
// Also, I'm not sure so far when `defs.length > 1`... haven't seen it with shadowed variables or even redeclared variables with `var`.
132138
export const isState = (context, ref) =>
133-
getUpstreamReactVariables(context, ref.resolved).notEmptyEvery((variable) =>
134-
variable.defs.some((def) => isUseState(def.node)),
139+
getUpstreamRefs(context, ref).notEmptyEvery((ref) =>
140+
ref.resolved.defs.some((def) => isUseState(def.node)),
135141
);
136142
// Returns false for props of HOCs like `withRouter` because they usually have side effects.
137143
export const isProp = (context, ref) =>
138-
getUpstreamReactVariables(context, ref.resolved).notEmptyEvery((variable) =>
139-
variable.defs.some((def) => isPropDef(def)),
144+
getUpstreamRefs(context, ref).notEmptyEvery((ref) =>
145+
ref.resolved.defs.some((def) => isPropDef(def)),
140146
);
141147
export const isRef = (context, ref) =>
142-
getUpstreamReactVariables(context, ref.resolved).notEmptyEvery((variable) =>
143-
variable.defs.some((def) => isUseRef(def.node)),
148+
getUpstreamRefs(context, ref).notEmptyEvery((ref) =>
149+
ref.resolved.defs.some((def) => isUseRef(def.node)),
144150
);
145151

146152
// TODO: Surely can be simplified/re-use other functions.
147153
// Needs a better API too so we can more easily get names etc. for messages.
148154
export const getUseStateNode = (context, ref) => {
149-
return getUpstreamReactVariables(context, ref.resolved)
155+
return getUpstreamRefs(context, ref)
156+
.map((ref) => ref.resolved)
150157
.find((variable) => variable.defs.some((def) => isUseState(def.node)))
151158
?.defs.find((def) => isUseState(def.node))?.node;
152159
};
@@ -186,25 +193,43 @@ export const isImmediateCall = (node) => {
186193
export const isArgsAllLiterals = (context, callExpr) =>
187194
callExpr.arguments
188195
.flatMap((arg) => getDownstreamRefs(context, arg))
189-
.flatMap((ref) => getUpstreamReactVariables(context, ref.resolved))
190-
.length === 0;
191-
192-
export const getUpstreamReactVariables = (context, variable) =>
193-
getUpstreamVariables(
194-
context,
195-
variable,
196-
// Stop at the *usage* of `useState` - don't go up to the `useState` variable.
197-
// Not needed for props - they don't go "too far".
198-
// We could remove this and check for the `useState` variable instead,
199-
// but then all our tests need to import it so we can traverse up to it.
200-
// And would need to change `getUseStateNode()` too?
201-
// TODO: Could probably organize these filters better.
202-
(node) => !isUseState(node),
203-
).filter((variable) =>
204-
variable.defs.every(
205-
(def) =>
206-
isPropDef(def) ||
207-
// Ignore variables declared inside an anonymous function, like in `array.map()`.
208-
def.type !== "Parameter",
209-
),
210-
);
196+
.flatMap((ref) => getUpstreamRefs(context, ref)).length === 0;
197+
198+
/**
199+
* @param {Rule.RuleContext} context
200+
* @param {Scope.Reference} ref
201+
*
202+
* @returns {Scope.Reference[]}
203+
*/
204+
export const getUpstreamRefs = (context, ref) => {
205+
if (!ref.resolved) {
206+
// I think this only happens when:
207+
// 1. Import statement is missing
208+
// 2. ESLint globals are misconfigured
209+
return [];
210+
} else if (
211+
// Ignore references to function parameters, aside from props.
212+
ref.resolved.defs.every(
213+
(def) => def.type === "Parameter" && !isPropDef(def),
214+
)
215+
) {
216+
return [];
217+
}
218+
219+
const upstreamRefs = ref.resolved.defs
220+
// TODO: https://github.com/NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect/issues/34
221+
// `init` covers for arrow functions; also needs `body` to descend into function declarations
222+
// But then for function parameters (including props), `def.node.body` is the body of the function that they belong to,
223+
// so we get *all* the downstream refs in it...
224+
// We only want to descend when we're traversing up the function itself; no its parameters.
225+
// Probably similar logic to in `getUpstreamReactVariables`.
226+
.filter((def) => !!def.node.init)
227+
// Stop before we get to `useState()` - we want references to the state and setter.
228+
// May not be necessary if we adapt the check in `isState()`?
229+
.filter((def) => !isUseState(def.node))
230+
.flatMap((def) => getDownstreamRefs(context, def.node.init))
231+
.flatMap((ref) => getUpstreamRefs(context, ref));
232+
233+
// Ultimately return only leaf refs
234+
return upstreamRefs.length === 0 ? [ref] : upstreamRefs;
235+
};

test/no-derived-state.test.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,11 @@ new MyRuleTester().run("no-derived-state", rule, {
207207
useEffect(() => {
208208
setCountJson(JSON.stringify(count));
209209
}, [count]);
210+
211+
return (
212+
// So single-setter doesn't trigger
213+
<button onClick={() => setCountJson(undefined)}>reset</button>
214+
)
210215
}
211216
`,
212217
},
@@ -217,9 +222,13 @@ new MyRuleTester().run("no-derived-state", rule, {
217222
const [multipliedCount, setMultipliedCount] = useState();
218223
219224
useEffect(() => {
220-
const multipler = fetch('/multipler');
221-
setMultipliedCount(count * multipler);
225+
setMultipliedCount(count * fetch('/multipler'));
222226
}, [count]);
227+
228+
return (
229+
// So single-setter doesn't trigger
230+
<button onClick={() => setMultipliedCount(0)}>reset</button>
231+
)
223232
}
224233
`,
225234
},
@@ -826,7 +835,7 @@ new MyRuleTester().run("no-derived-state", rule, {
826835
827836
useEffect(() => {
828837
setFullName(computeName(firstName, lastName));
829-
}, [firstName, lastName]);
838+
}, [firstName, lastName, computeName]);
830839
}
831840
`,
832841
errors: [

test/syntax.test.js

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -479,18 +479,17 @@ new MyRuleTester().run("syntax", noDerivedState, {
479479
code: js`
480480
import { useState, useEffect } from 'react';
481481
482-
function CountAccumulator({ count }) {
483-
const [total, setTotal] = useState(count);
482+
function DoubleCounter() {
483+
const [count, setCount] = useState(0);
484+
const [doubleCount, setDoubleCount] = useState(0);
484485
485-
useEffect(() => {
486-
setTotal((prev) => prev + count);
487-
}, [count]);
488-
}
486+
useEffect(() => setDoubleCount(count * 2), [count]);
487+
}
489488
`,
490489
errors: [
491490
{
492491
messageId: "avoidDerivedState",
493-
data: { state: "total" },
492+
data: { state: "doubleCount" },
494493
},
495494
],
496495
},

0 commit comments

Comments
 (0)