Skip to content

Commit 996f1a1

Browse files
committed
release: 2.0.0-next.35
2 parents a0127df + 7840a88 commit 996f1a1

File tree

20 files changed

+426
-345
lines changed

20 files changed

+426
-345
lines changed

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.0.0-next.34
1+
2.0.0-next.35

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eslint-react/monorepo",
3-
"version": "2.0.0-next.34",
3+
"version": "2.0.0-next.35",
44
"private": true,
55
"description": "Monorepo for eslint-plugin-react-[x, dom, web-api, naming-convention].",
66
"keywords": [

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eslint-react/core",
3-
"version": "2.0.0-next.34",
3+
"version": "2.0.0-next.35",
44
"description": "ESLint React's ESLint utility module for static analysis of React core APIs and patterns.",
55
"homepage": "https://github.com/Rel1cx/eslint-react",
66
"bugs": {

packages/plugins/eslint-plugin-react-debug/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "eslint-plugin-react-debug",
3-
"version": "2.0.0-next.34",
3+
"version": "2.0.0-next.35",
44
"description": "ESLint React's ESLint plugin for debugging related rules.",
55
"keywords": [
66
"react",

packages/plugins/eslint-plugin-react-dom/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "eslint-plugin-react-dom",
3-
"version": "2.0.0-next.34",
3+
"version": "2.0.0-next.35",
44
"description": "ESLint React's ESLint plugin for React DOM related rules.",
55
"keywords": [
66
"react",

packages/plugins/eslint-plugin-react-hooks-extra/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "eslint-plugin-react-hooks-extra",
3-
"version": "2.0.0-next.34",
3+
"version": "2.0.0-next.35",
44
"description": "ESLint React's ESLint plugin for React Hooks related rules.",
55
"keywords": [
66
"react",
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
import type { RuleContext } from "@eslint-react/kit";
2+
import type { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
3+
import type { Scope } from "@typescript-eslint/utils/ts-eslint";
4+
import * as AST from "@eslint-react/ast";
5+
import * as ER from "@eslint-react/core";
6+
import { constVoid, getOrElseUpdate, not } from "@eslint-react/eff";
7+
import { getSettingsFromContext } from "@eslint-react/shared";
8+
import * as VAR from "@eslint-react/var";
9+
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
10+
11+
import { match } from "ts-pattern";
12+
13+
type CallKind =
14+
| "useEffect"
15+
| "useLayoutEffect"
16+
| "useState"
17+
| "setState"
18+
| "then"
19+
| "other";
20+
21+
type FunctionKind =
22+
| "setup"
23+
| "cleanup"
24+
| "deferred"
25+
| "immediate"
26+
| "other";
27+
28+
export declare namespace useNoDirectSetStateInUseEffect {
29+
type Options<Ctx> = {
30+
onViolation: (context: Ctx, node: TSESTree.Node | TSESTree.Token, data: { name: string }) => void;
31+
useEffectKind: "useEffect" | "useLayoutEffect";
32+
};
33+
type ReturnType = ESLintUtils.RuleListener;
34+
}
35+
36+
export function useNoDirectSetStateInUseEffect<Ctx extends RuleContext>(
37+
context: Ctx,
38+
options: useNoDirectSetStateInUseEffect.Options<Ctx>,
39+
): useNoDirectSetStateInUseEffect.ReturnType {
40+
const { onViolation, useEffectKind } = options;
41+
const settings = getSettingsFromContext(context);
42+
const hooks = settings.additionalHooks;
43+
const getText = (n: TSESTree.Node) => context.sourceCode.getText(n);
44+
const isUseEffectLikeCall = ER.isReactHookCallWithNameAlias(context, useEffectKind, hooks[useEffectKind]);
45+
const isUseStateCall = ER.isReactHookCallWithNameAlias(context, "useState", hooks.useState);
46+
const isUseMemoCall = ER.isReactHookCallWithNameAlias(context, "useMemo", hooks.useMemo);
47+
const isUseCallbackCall = ER.isReactHookCallWithNameAlias(context, "useCallback", hooks.useCallback);
48+
49+
const functionEntries: { kind: FunctionKind; node: AST.TSESTreeFunction }[] = [];
50+
const setupFunctionRef: { current: AST.TSESTreeFunction | null } = { current: null };
51+
const setupFunctionIdentifiers: TSESTree.Identifier[] = [];
52+
53+
const indFunctionCalls: TSESTree.CallExpression[] = [];
54+
const indSetStateCalls = new WeakMap<AST.TSESTreeFunction, TSESTree.CallExpression[]>();
55+
const indSetStateCallsInUseEffectArg0 = new WeakMap<TSESTree.CallExpression, TSESTree.Identifier[]>();
56+
const indSetStateCallsInUseEffectSetup = new Map<TSESTree.CallExpression, TSESTree.Identifier[]>();
57+
const indSetStateCallsInUseMemoOrCallback = new WeakMap<TSESTree.Node, TSESTree.CallExpression[]>();
58+
59+
const onSetupFunctionEnter = (node: AST.TSESTreeFunction) => {
60+
setupFunctionRef.current = node;
61+
};
62+
63+
const onSetupFunctionExit = (node: AST.TSESTreeFunction) => {
64+
if (setupFunctionRef.current === node) {
65+
setupFunctionRef.current = null;
66+
}
67+
};
68+
69+
function isFunctionOfUseEffectSetup(node: TSESTree.Node) {
70+
return node.parent?.type === T.CallExpression
71+
&& node.parent.callee !== node
72+
&& isUseEffectLikeCall(node.parent);
73+
}
74+
75+
function getCallName(node: TSESTree.Node) {
76+
if (node.type === T.CallExpression) {
77+
return AST.toStringFormat(node.callee, getText);
78+
}
79+
return AST.toStringFormat(node, getText);
80+
}
81+
82+
function getCallKind(node: TSESTree.CallExpression) {
83+
return match<TSESTree.CallExpression, CallKind>(node)
84+
.when(isUseStateCall, () => "useState")
85+
.when(isUseEffectLikeCall, () => useEffectKind)
86+
.when(isSetStateCall, () => "setState")
87+
.when(AST.isThenCall, () => "then")
88+
.otherwise(() => "other");
89+
}
90+
91+
function getFunctionKind(node: AST.TSESTreeFunction) {
92+
const parent = AST.findParentNode(node, not(AST.isTypeExpression)) ?? node.parent;
93+
switch (true) {
94+
case node.async:
95+
case parent.type === T.CallExpression
96+
&& AST.isThenCall(parent):
97+
return "deferred";
98+
case node.type !== T.FunctionDeclaration
99+
&& parent.type === T.CallExpression
100+
&& parent.callee === node:
101+
return "immediate";
102+
case isFunctionOfUseEffectSetup(node):
103+
return "setup";
104+
default:
105+
return "other";
106+
}
107+
}
108+
109+
function isIdFromUseStateCall(topLevelId: TSESTree.Identifier, at?: number) {
110+
const variable = VAR.findVariable(topLevelId, context.sourceCode.getScope(topLevelId));
111+
const variableNode = VAR.getVariableInitNode(variable, 0);
112+
if (variableNode == null) return false;
113+
if (variableNode.type !== T.CallExpression) return false;
114+
if (!ER.isReactHookCallWithNameAlias(context, "useState", hooks.useState)(variableNode)) return false;
115+
const variableNodeParent = variableNode.parent;
116+
if (!("id" in variableNodeParent) || variableNodeParent.id?.type !== T.ArrayPattern) {
117+
return true;
118+
}
119+
return variableNodeParent
120+
.id
121+
.elements
122+
.findIndex((e) => e?.type === T.Identifier && e.name === topLevelId.name) === at;
123+
}
124+
125+
function isSetStateCall(node: TSESTree.CallExpression) {
126+
switch (node.callee.type) {
127+
// const data = useState();
128+
// data.at(1)();
129+
case T.CallExpression: {
130+
const { callee } = node.callee;
131+
if (callee.type !== T.MemberExpression) {
132+
return false;
133+
}
134+
if (!("name" in callee.object)) {
135+
return false;
136+
}
137+
const isAt = callee.property.type === T.Identifier && callee.property.name === "at";
138+
const [index] = node.callee.arguments;
139+
if (!isAt || index == null) {
140+
return false;
141+
}
142+
const indexScope = context.sourceCode.getScope(node);
143+
const indexValue = VAR.toStaticValue({
144+
kind: "lazy",
145+
node: index,
146+
initialScope: indexScope,
147+
}).value;
148+
return indexValue === 1 && isIdFromUseStateCall(callee.object);
149+
}
150+
// const [data, setData] = useState();
151+
// setData();
152+
case T.Identifier: {
153+
return isIdFromUseStateCall(node.callee, 1);
154+
}
155+
// const data = useState();
156+
// data[1]();
157+
case T.MemberExpression: {
158+
if (!("name" in node.callee.object)) {
159+
return false;
160+
}
161+
const property = node.callee.property;
162+
const propertyScope = context.sourceCode.getScope(node);
163+
const propertyValue = VAR.toStaticValue({
164+
kind: "lazy",
165+
node: property,
166+
initialScope: propertyScope,
167+
}).value;
168+
return propertyValue === 1 && isIdFromUseStateCall(node.callee.object, 1);
169+
}
170+
default: {
171+
return false;
172+
}
173+
}
174+
}
175+
176+
return {
177+
":function"(node: AST.TSESTreeFunction) {
178+
const kind = getFunctionKind(node);
179+
functionEntries.push({ kind, node });
180+
if (kind === "setup") {
181+
onSetupFunctionEnter(node);
182+
}
183+
},
184+
":function:exit"(node: AST.TSESTreeFunction) {
185+
const { kind } = functionEntries.at(-1) ?? {};
186+
if (kind === "setup") {
187+
onSetupFunctionExit(node);
188+
}
189+
functionEntries.pop();
190+
},
191+
CallExpression(node) {
192+
const setupFunction = setupFunctionRef.current;
193+
const pEntry = functionEntries.at(-1);
194+
if (pEntry == null || pEntry.node.async) {
195+
return;
196+
}
197+
match(getCallKind(node))
198+
.with("setState", () => {
199+
switch (true) {
200+
case pEntry.node === setupFunction:
201+
case pEntry.kind === "immediate"
202+
&& AST.findParentNode(pEntry.node, AST.isFunction) === setupFunction: {
203+
onViolation(context, node, {
204+
name: context.sourceCode.getText(node.callee),
205+
});
206+
return;
207+
}
208+
default: {
209+
const vd = AST.findParentNode(node, isVariableDeclaratorFromHookCall);
210+
if (vd == null) getOrElseUpdate(indSetStateCalls, pEntry.node, () => []).push(node);
211+
else getOrElseUpdate(indSetStateCallsInUseMemoOrCallback, vd.init, () => []).push(node);
212+
}
213+
}
214+
})
215+
.with(useEffectKind, () => {
216+
if (AST.isFunction(node.arguments.at(0))) return;
217+
setupFunctionIdentifiers.push(...AST.getNestedIdentifiers(node));
218+
})
219+
.with("other", () => {
220+
if (pEntry.node !== setupFunction) return;
221+
indFunctionCalls.push(node);
222+
})
223+
.otherwise(constVoid);
224+
},
225+
Identifier(node) {
226+
if (node.parent.type === T.CallExpression && node.parent.callee === node) {
227+
return;
228+
}
229+
if (!isIdFromUseStateCall(node, 1)) {
230+
return;
231+
}
232+
switch (node.parent.type) {
233+
case T.ArrowFunctionExpression: {
234+
const parent = node.parent.parent;
235+
if (parent.type !== T.CallExpression) {
236+
break;
237+
}
238+
// const [state, setState] = useState();
239+
// const set = useMemo(() => setState, []);
240+
// useEffect(set, []);
241+
if (!isUseMemoCall(parent)) {
242+
break;
243+
}
244+
const vd = AST.findParentNode(parent, isVariableDeclaratorFromHookCall);
245+
if (vd != null) {
246+
getOrElseUpdate(indSetStateCallsInUseEffectArg0, vd.init, () => []).push(node);
247+
}
248+
break;
249+
}
250+
case T.CallExpression: {
251+
if (node !== node.parent.arguments.at(0)) {
252+
break;
253+
}
254+
// const [state, setState] = useState();
255+
// const set = useCallback(setState, []);
256+
// useEffect(set, []);
257+
if (isUseCallbackCall(node.parent)) {
258+
const vd = AST.findParentNode(node.parent, isVariableDeclaratorFromHookCall);
259+
if (vd != null) {
260+
getOrElseUpdate(indSetStateCallsInUseEffectArg0, vd.init, () => []).push(node);
261+
}
262+
break;
263+
}
264+
// const [state, setState] = useState();
265+
// useEffect(setState);
266+
if (isUseEffectLikeCall(node.parent)) {
267+
getOrElseUpdate(indSetStateCallsInUseEffectSetup, node.parent, () => []).push(node);
268+
}
269+
}
270+
}
271+
},
272+
"Program:exit"() {
273+
const getSetStateCalls = (
274+
id: string | TSESTree.Identifier,
275+
initialScope: Scope.Scope,
276+
): TSESTree.CallExpression[] | TSESTree.Identifier[] => {
277+
const node = VAR.getVariableInitNode(VAR.findVariable(id, initialScope), 0);
278+
switch (node?.type) {
279+
case T.ArrowFunctionExpression:
280+
case T.FunctionDeclaration:
281+
case T.FunctionExpression:
282+
return indSetStateCalls.get(node) ?? [];
283+
case T.CallExpression:
284+
return indSetStateCallsInUseMemoOrCallback.get(node) ?? indSetStateCallsInUseEffectArg0.get(node) ?? [];
285+
}
286+
return [];
287+
};
288+
for (const [, calls] of indSetStateCallsInUseEffectSetup) {
289+
for (const call of calls) {
290+
onViolation(context, call, { name: call.name });
291+
}
292+
}
293+
for (const { callee } of indFunctionCalls) {
294+
if (!("name" in callee)) {
295+
continue;
296+
}
297+
const { name } = callee;
298+
const setStateCalls = getSetStateCalls(name, context.sourceCode.getScope(callee));
299+
for (const setStateCall of setStateCalls) {
300+
onViolation(context, setStateCall, {
301+
name: getCallName(setStateCall),
302+
});
303+
}
304+
}
305+
for (const id of setupFunctionIdentifiers) {
306+
const setStateCalls = getSetStateCalls(id.name, context.sourceCode.getScope(id));
307+
for (const setStateCall of setStateCalls) {
308+
onViolation(context, setStateCall, {
309+
name: getCallName(setStateCall),
310+
});
311+
}
312+
}
313+
},
314+
};
315+
}
316+
317+
function isInitFromHookCall(init: TSESTree.Expression | null) {
318+
if (init?.type !== T.CallExpression) return false;
319+
switch (init.callee.type) {
320+
case T.Identifier:
321+
return ER.isReactHookName(init.callee.name);
322+
case T.MemberExpression:
323+
return init.callee.property.type === T.Identifier
324+
&& ER.isReactHookName(init.callee.property.name);
325+
default:
326+
return false;
327+
}
328+
}
329+
330+
function isVariableDeclaratorFromHookCall(node: TSESTree.Node): node is
331+
& TSESTree.VariableDeclarator
332+
& { init: TSESTree.VariableDeclarator["init"] & {} }
333+
{
334+
if (node.type !== T.VariableDeclarator) return false;
335+
if (node.id.type !== T.Identifier) return false;
336+
return isInitFromHookCall(node.init);
337+
}

0 commit comments

Comments
 (0)