Update
This commit is contained in:
@ -6,10 +6,43 @@
|
||||
*/
|
||||
|
||||
/* eslint-disable no-for-of-loops/no-for-of-loops */
|
||||
import type { Rule, Scope } from 'eslint';
|
||||
import type {
|
||||
FunctionDeclaration,
|
||||
CallExpression,
|
||||
Expression,
|
||||
Super,
|
||||
Node,
|
||||
ArrowFunctionExpression,
|
||||
FunctionExpression,
|
||||
SpreadElement,
|
||||
Identifier,
|
||||
VariableDeclarator,
|
||||
MemberExpression,
|
||||
ChainExpression,
|
||||
Pattern,
|
||||
OptionalMemberExpression,
|
||||
} from 'estree';
|
||||
import type { FromSchema } from 'json-schema-to-ts';
|
||||
import { __EXPERIMENTAL__ } from './index';
|
||||
|
||||
'use strict';
|
||||
const schema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
enableDangerousAutofixThisMayCauseInfiniteLoops: false,
|
||||
properties: {
|
||||
additionalHooks: {
|
||||
type: 'string',
|
||||
},
|
||||
enableDangerousAutofixThisMayCauseInfiniteLoops: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default {
|
||||
type Config = FromSchema<typeof schema>;
|
||||
|
||||
const rule: Rule.RuleModule = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
@ -20,41 +53,24 @@ export default {
|
||||
},
|
||||
fixable: 'code',
|
||||
hasSuggestions: true,
|
||||
schema: [
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
enableDangerousAutofixThisMayCauseInfiniteLoops: false,
|
||||
properties: {
|
||||
additionalHooks: {
|
||||
type: 'string',
|
||||
},
|
||||
enableDangerousAutofixThisMayCauseInfiniteLoops: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
schema: [schema],
|
||||
},
|
||||
create(context) {
|
||||
create(context): Rule.RuleListener {
|
||||
const contextOptions = (context.options[0] || {}) as Config;
|
||||
// Parse the `additionalHooks` regex.
|
||||
const additionalHooks =
|
||||
context.options && context.options[0] && context.options[0].additionalHooks
|
||||
? new RegExp(context.options[0].additionalHooks)
|
||||
: undefined;
|
||||
const additionalHooks = contextOptions?.additionalHooks
|
||||
? new RegExp(context.options[0].additionalHooks)
|
||||
: undefined;
|
||||
|
||||
const enableDangerousAutofixThisMayCauseInfiniteLoops =
|
||||
(context.options &&
|
||||
context.options[0] &&
|
||||
context.options[0].enableDangerousAutofixThisMayCauseInfiniteLoops) ||
|
||||
false;
|
||||
contextOptions?.enableDangerousAutofixThisMayCauseInfiniteLoops || false;
|
||||
|
||||
const options = {
|
||||
additionalHooks,
|
||||
enableDangerousAutofixThisMayCauseInfiniteLoops,
|
||||
};
|
||||
|
||||
function reportProblem(problem) {
|
||||
function reportProblem(problem: Rule.ReportDescriptor): void {
|
||||
if (enableDangerousAutofixThisMayCauseInfiniteLoops) {
|
||||
// Used to enable legacy behavior. Dangerous.
|
||||
// Keep this as an option until major IDEs upgrade (including VSCode FB ESLint extension).
|
||||
@ -65,20 +81,23 @@ export default {
|
||||
context.report(problem);
|
||||
}
|
||||
|
||||
const scopeManager = context.getSourceCode().scopeManager;
|
||||
const scopeManager = context.sourceCode.scopeManager;
|
||||
|
||||
// Should be shared between visitors.
|
||||
const setStateCallSites = new WeakMap();
|
||||
const stateVariables = new WeakSet();
|
||||
const stableKnownValueCache = new WeakMap();
|
||||
const functionWithoutCapturedValueCache = new WeakMap();
|
||||
const setStateCallSites = new WeakMap<Expression, Pattern>();
|
||||
const stateVariables = new WeakSet<Identifier>();
|
||||
const stableKnownValueCache = new WeakMap<Scope.Variable, boolean>();
|
||||
const functionWithoutCapturedValueCache = new WeakMap<Scope.Variable, boolean>();
|
||||
const useEffectEventVariables = new WeakSet();
|
||||
function memoizeWithWeakMap(fn, map) {
|
||||
function memoizeWithWeakMap<T extends object, R>(
|
||||
fn: (v: T) => R,
|
||||
map: WeakMap<T, R>,
|
||||
): (arg: T) => R {
|
||||
return function (arg) {
|
||||
if (map.has(arg)) {
|
||||
// to verify cache hits:
|
||||
// console.log(arg.name)
|
||||
return map.get(arg);
|
||||
return map.get(arg)!;
|
||||
}
|
||||
const result = fn(arg);
|
||||
map.set(arg, result);
|
||||
@ -89,12 +108,12 @@ export default {
|
||||
* Visitor for both function expressions and arrow function expressions.
|
||||
*/
|
||||
function visitFunctionWithDependencies(
|
||||
node,
|
||||
declaredDependenciesNode,
|
||||
reactiveHook,
|
||||
reactiveHookName,
|
||||
isEffect,
|
||||
) {
|
||||
node: ArrowFunctionExpression | FunctionExpression | FunctionDeclaration,
|
||||
declaredDependenciesNode: SpreadElement | Expression,
|
||||
reactiveHook: Super | Expression,
|
||||
reactiveHookName: string,
|
||||
isEffect: boolean,
|
||||
): void {
|
||||
if (isEffect && node.async) {
|
||||
reportProblem({
|
||||
node: node,
|
||||
@ -114,7 +133,7 @@ export default {
|
||||
}
|
||||
|
||||
// Get the current scope.
|
||||
const scope = scopeManager.acquire(node);
|
||||
const scope = scopeManager.acquire(node)!;
|
||||
|
||||
// Find all our "pure scopes". On every re-render of a component these
|
||||
// pure scopes may have changes to the variables declared within. So all
|
||||
@ -124,8 +143,8 @@ export default {
|
||||
// According to the rules of React you can't read a mutable value in pure
|
||||
// scope. We can't enforce this in a lint so we trust that all variables
|
||||
// declared outside of pure scope are indeed frozen.
|
||||
const pureScopes = new Set();
|
||||
let componentScope = null;
|
||||
const pureScopes = new Set<Scope.Scope>();
|
||||
let componentScope: Scope.Scope;
|
||||
{
|
||||
let currentScope = scope.upper;
|
||||
while (currentScope) {
|
||||
@ -159,7 +178,7 @@ export default {
|
||||
// const onStuff = useEffectEvent(() => {})
|
||||
// ^^^ true for this reference
|
||||
// False for everything else.
|
||||
function isStableKnownHookValue(resolved) {
|
||||
function isStableKnownHookValue(resolved: Scope.Variable): boolean {
|
||||
if (!isArray(resolved.defs)) {
|
||||
return false;
|
||||
}
|
||||
@ -171,7 +190,7 @@ export default {
|
||||
if (def.node.type !== 'VariableDeclarator') {
|
||||
return false;
|
||||
}
|
||||
let init = def.node.init;
|
||||
let init = (def.node as VariableDeclarator).init;
|
||||
if (init == null) {
|
||||
return false;
|
||||
}
|
||||
@ -206,10 +225,11 @@ export default {
|
||||
if (init.type !== 'CallExpression') {
|
||||
return false;
|
||||
}
|
||||
let callee = init.callee;
|
||||
let callee: Node = init.callee;
|
||||
// Step into `= React.something` initializer.
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
callee.object.type === 'Identifier' &&
|
||||
callee.object.name === 'React' &&
|
||||
callee.property != null &&
|
||||
!callee.computed
|
||||
@ -219,14 +239,14 @@ export default {
|
||||
if (callee.type !== 'Identifier') {
|
||||
return false;
|
||||
}
|
||||
const id = def.node.id;
|
||||
const id = (def.node as VariableDeclarator).id;
|
||||
const { name } = callee;
|
||||
if (name === 'useRef' && id.type === 'Identifier') {
|
||||
// useRef() return value is stable.
|
||||
return true;
|
||||
} else if (isUseEffectEventIdentifier(callee) && id.type === 'Identifier') {
|
||||
for (const ref of resolved.references) {
|
||||
if (ref !== id) {
|
||||
if (ref.identifier !== id) {
|
||||
useEffectEventVariables.add(ref.identifier);
|
||||
}
|
||||
}
|
||||
@ -251,7 +271,7 @@ export default {
|
||||
if (writeCount > 1) {
|
||||
return false;
|
||||
}
|
||||
setStateCallSites.set(references[i].identifier, id.elements[0]);
|
||||
setStateCallSites.set(references[i].identifier, id.elements[0]!);
|
||||
}
|
||||
}
|
||||
// Setter is stable.
|
||||
@ -286,7 +306,7 @@ export default {
|
||||
}
|
||||
|
||||
// Some are just functions that don't reference anything dynamic.
|
||||
function isFunctionWithoutCapturedValues(resolved) {
|
||||
function isFunctionWithoutCapturedValues(resolved: Scope.Variable): boolean {
|
||||
if (!isArray(resolved.defs)) {
|
||||
return false;
|
||||
}
|
||||
@ -353,32 +373,33 @@ export default {
|
||||
);
|
||||
|
||||
// These are usually mistaken. Collect them.
|
||||
const currentRefsInEffectCleanup = new Map();
|
||||
const currentRefsInEffectCleanup = new Map<
|
||||
string,
|
||||
{ reference: Scope.Reference; dependencyNode: Identifier }
|
||||
>();
|
||||
|
||||
// Is this reference inside a cleanup function for this effect node?
|
||||
// We can check by traversing scopes upwards from the reference, and checking
|
||||
// if the last "return () => " we encounter is located directly inside the effect.
|
||||
function isInsideEffectCleanup(reference) {
|
||||
let curScope = reference.from;
|
||||
function isInsideEffectCleanup(reference: Scope.Reference): boolean {
|
||||
let curScope: Scope.Scope = reference.from;
|
||||
let isInReturnedFunction = false;
|
||||
while (curScope.block !== node) {
|
||||
if (curScope.type === 'function') {
|
||||
isInReturnedFunction =
|
||||
curScope.block.parent != null &&
|
||||
curScope.block.parent.type === 'ReturnStatement';
|
||||
isInReturnedFunction = curScope.block.parent?.type === 'ReturnStatement';
|
||||
}
|
||||
curScope = curScope.upper;
|
||||
curScope = curScope.upper!;
|
||||
}
|
||||
return isInReturnedFunction;
|
||||
}
|
||||
|
||||
// Get dependencies from all our resolved references in pure scopes.
|
||||
// Key is dependency string, value is whether it's stable.
|
||||
const dependencies = new Map();
|
||||
const optionalChains = new Map();
|
||||
const dependencies = new Map<string, Dependencies>();
|
||||
const optionalChains = new Map<string, boolean>();
|
||||
gatherDependenciesRecursively(scope);
|
||||
|
||||
function gatherDependenciesRecursively(currentScope) {
|
||||
function gatherDependenciesRecursively(currentScope: Scope.Scope): void {
|
||||
for (const reference of currentScope.references) {
|
||||
// If this reference is not resolved or it is not declared in a pure
|
||||
// scope then we don't care about this reference.
|
||||
@ -391,9 +412,9 @@ export default {
|
||||
|
||||
// Narrow the scope of a dependency if it is, say, a member expression.
|
||||
// Then normalize the narrowed dependency.
|
||||
const referenceNode = fastFindReferenceWithParent(node, reference.identifier);
|
||||
const dependencyNode = getDependency(referenceNode);
|
||||
const dependency = analyzePropertyChain(dependencyNode, optionalChains);
|
||||
const referenceNode = fastFindReferenceWithParent(node, reference.identifier)!;
|
||||
const dependencyNode: Node = getDependency(referenceNode);
|
||||
const dependency: string = analyzePropertyChain(dependencyNode, optionalChains);
|
||||
|
||||
// Accessing ref.current inside effect cleanup is bad.
|
||||
if (
|
||||
@ -401,11 +422,11 @@ export default {
|
||||
isEffect &&
|
||||
// ... and this look like accessing .current...
|
||||
dependencyNode.type === 'Identifier' &&
|
||||
(dependencyNode.parent.type === 'MemberExpression' ||
|
||||
dependencyNode.parent.type === 'OptionalMemberExpression') &&
|
||||
!dependencyNode.parent.computed &&
|
||||
dependencyNode.parent.property.type === 'Identifier' &&
|
||||
dependencyNode.parent.property.name === 'current' &&
|
||||
(dependencyNode.parent!.type === 'MemberExpression' ||
|
||||
dependencyNode.parent!.type === 'OptionalMemberExpression') &&
|
||||
!dependencyNode.parent!.computed &&
|
||||
dependencyNode.parent!.property.type === 'Identifier' &&
|
||||
dependencyNode.parent!.property.name === 'current' &&
|
||||
// ...in a cleanup function or below...
|
||||
isInsideEffectCleanup(reference)
|
||||
) {
|
||||
@ -416,8 +437,8 @@ export default {
|
||||
}
|
||||
|
||||
if (
|
||||
dependencyNode.parent.type === 'TSTypeQuery' ||
|
||||
dependencyNode.parent.type === 'TSTypeReference'
|
||||
dependencyNode.parent!.type === 'TSTypeQuery' ||
|
||||
dependencyNode.parent!.type === 'TSTypeReference'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@ -438,7 +459,7 @@ export default {
|
||||
// Add the dependency to a map so we can make sure it is referenced
|
||||
// again in our dependencies array. Remember whether it's stable.
|
||||
if (!dependencies.has(dependency)) {
|
||||
const resolved = reference.resolved;
|
||||
const resolved: Scope.Variable = reference.resolved;
|
||||
const isStable =
|
||||
memoizedIsStableKnownHookValue(resolved) ||
|
||||
memoizedIsFunctionWithoutCapturedValues(resolved);
|
||||
@ -447,7 +468,7 @@ export default {
|
||||
references: [reference],
|
||||
});
|
||||
} else {
|
||||
dependencies.get(dependency).references.push(reference);
|
||||
dependencies.get(dependency)!.references.push(reference);
|
||||
}
|
||||
}
|
||||
|
||||
@ -458,7 +479,7 @@ export default {
|
||||
|
||||
// Warn about accessing .current in cleanup effects.
|
||||
currentRefsInEffectCleanup.forEach(({ reference, dependencyNode }, dependency) => {
|
||||
const references = reference.resolved.references;
|
||||
const references: Scope.Reference[] = reference.resolved!.references;
|
||||
// Is React managing this ref or us?
|
||||
// Let's see if we can find a .current assignment.
|
||||
let foundCurrentAssignment = false;
|
||||
@ -474,8 +495,8 @@ export default {
|
||||
parent.property.type === 'Identifier' &&
|
||||
parent.property.name === 'current' &&
|
||||
// ref.current = <something>
|
||||
parent.parent.type === 'AssignmentExpression' &&
|
||||
parent.parent.left === parent
|
||||
parent.parent!.type === 'AssignmentExpression' &&
|
||||
parent.parent!.left === parent
|
||||
) {
|
||||
foundCurrentAssignment = true;
|
||||
break;
|
||||
@ -486,7 +507,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
reportProblem({
|
||||
node: dependencyNode.parent.property,
|
||||
node: (dependencyNode.parent as MemberExpression).property,
|
||||
message:
|
||||
`The ref value '${dependency}.current' will likely have ` +
|
||||
`changed by the time this effect cleanup function runs. If ` +
|
||||
@ -498,8 +519,8 @@ export default {
|
||||
|
||||
// Warn about assigning to variables in the outer scope.
|
||||
// Those are usually bugs.
|
||||
const staleAssignments = new Set();
|
||||
function reportStaleAssignment(writeExpr, key) {
|
||||
const staleAssignments = new Set<string>();
|
||||
function reportStaleAssignment(writeExpr: Node, key: string): void {
|
||||
if (staleAssignments.has(key)) {
|
||||
return;
|
||||
}
|
||||
@ -517,7 +538,7 @@ export default {
|
||||
}
|
||||
|
||||
// Remember which deps are stable and report bad usage first.
|
||||
const stableDependencies = new Set();
|
||||
const stableDependencies = new Set<string>();
|
||||
dependencies.forEach(({ isStable, references }, key) => {
|
||||
if (isStable) {
|
||||
stableDependencies.add(key);
|
||||
@ -537,8 +558,8 @@ export default {
|
||||
if (!declaredDependenciesNode) {
|
||||
// Check if there are any top-level setState() calls.
|
||||
// Those tend to lead to infinite loops.
|
||||
let setStateInsideEffectWithoutDeps = null;
|
||||
dependencies.forEach(({ isStable, references }, key) => {
|
||||
let setStateInsideEffectWithoutDeps: string | null = null;
|
||||
dependencies.forEach(({ references }, key) => {
|
||||
if (setStateInsideEffectWithoutDeps) {
|
||||
return;
|
||||
}
|
||||
@ -548,14 +569,14 @@ export default {
|
||||
}
|
||||
|
||||
const id = reference.identifier;
|
||||
const isSetState = setStateCallSites.has(id);
|
||||
const isSetState: boolean = setStateCallSites.has(id);
|
||||
if (!isSetState) {
|
||||
return;
|
||||
}
|
||||
|
||||
let fnScope = reference.from;
|
||||
let fnScope: Scope.Scope = reference.from;
|
||||
while (fnScope.type !== 'function') {
|
||||
fnScope = fnScope.upper;
|
||||
fnScope = fnScope.upper!;
|
||||
}
|
||||
const isDirectlyInsideEffect = fnScope.block === node;
|
||||
if (isDirectlyInsideEffect) {
|
||||
@ -564,6 +585,7 @@ export default {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (setStateInsideEffectWithoutDeps) {
|
||||
const { suggestedDependencies } = collectRecommendations({
|
||||
dependencies,
|
||||
@ -596,8 +618,8 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
const declaredDependencies = [];
|
||||
const externalDependencies = new Set();
|
||||
const declaredDependencies: DeclaredDependency[] = [];
|
||||
const externalDependencies = new Set<string>();
|
||||
if (declaredDependenciesNode.type !== 'ArrayExpression') {
|
||||
// If the declared dependencies are not an array expression then we
|
||||
// can't verify that the user provided the correct dependencies. Tell
|
||||
@ -640,7 +662,7 @@ export default {
|
||||
declaredDependencyNode,
|
||||
)}\``,
|
||||
fix(fixer) {
|
||||
return fixer.removeRange(declaredDependencyNode.range);
|
||||
return fixer.removeRange(declaredDependencyNode.range!);
|
||||
},
|
||||
},
|
||||
],
|
||||
@ -648,13 +670,13 @@ export default {
|
||||
}
|
||||
// Try to normalize the declared dependency. If we can't then an error
|
||||
// will be thrown. We will catch that error and report an error.
|
||||
let declaredDependency;
|
||||
let declaredDependency: string;
|
||||
try {
|
||||
declaredDependency = analyzePropertyChain(declaredDependencyNode, null);
|
||||
} catch (error) {
|
||||
if (/Unsupported node type/.test(error.message)) {
|
||||
if (declaredDependencyNode.type === 'Literal') {
|
||||
if (dependencies.has(declaredDependencyNode.value)) {
|
||||
if (dependencies.has(declaredDependencyNode.value as any)) {
|
||||
reportProblem({
|
||||
node: declaredDependencyNode,
|
||||
message:
|
||||
@ -686,13 +708,15 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
let maybeID = declaredDependencyNode;
|
||||
let maybeID: Expression | Super = declaredDependencyNode;
|
||||
while (
|
||||
maybeID.type === 'MemberExpression' ||
|
||||
maybeID.type === 'OptionalMemberExpression' ||
|
||||
maybeID.type === 'ChainExpression'
|
||||
) {
|
||||
maybeID = maybeID.object || maybeID.expression.object;
|
||||
maybeID =
|
||||
(maybeID as MemberExpression | OptionalMemberExpression).object ||
|
||||
((maybeID as ChainExpression).expression as MemberExpression).object;
|
||||
}
|
||||
const isDeclaredInComponent = !componentScope.through.some(
|
||||
ref => ref.identifier === maybeID,
|
||||
@ -758,10 +782,12 @@ export default {
|
||||
|
||||
const message =
|
||||
`The '${construction.name.name}' ${depType} ${causation} the dependencies of ` +
|
||||
`${reactiveHookName} Hook (at line ${declaredDependenciesNode.loc.start.line}) ` +
|
||||
`${reactiveHookName} Hook (at line ${
|
||||
declaredDependenciesNode.loc!.start.line
|
||||
}) ` +
|
||||
`change on every render. ${advice}`;
|
||||
|
||||
let suggest;
|
||||
let suggest: Rule.SuggestionReportDescriptor[] | undefined;
|
||||
// Only handle the simple case of variable assignments.
|
||||
// Wrapping function declarations can mess up hoisting.
|
||||
if (
|
||||
@ -782,17 +808,18 @@ export default {
|
||||
: ['useCallback(', ')'];
|
||||
return [
|
||||
// TODO: also add an import?
|
||||
fixer.insertTextBefore(construction.node.init, before),
|
||||
fixer.insertTextBefore(construction.node.init!, before),
|
||||
// TODO: ideally we'd gather deps here but it would require
|
||||
// restructuring the rule code. This will cause a new lint
|
||||
// error to appear immediately for useCallback. Note we're
|
||||
// not adding [] because would that changes semantics.
|
||||
fixer.insertTextAfter(construction.node.init, after),
|
||||
fixer.insertTextAfter(construction.node.init!, after),
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// TODO: What if the function needs to change on every render anyway?
|
||||
// Should we suggest removing effect deps as an appropriate fix too?
|
||||
reportProblem({
|
||||
@ -811,7 +838,7 @@ export default {
|
||||
// in some extra deduplication. We can't do this
|
||||
// for effects though because those have legit
|
||||
// use cases for over-specifying deps.
|
||||
if (!isEffect && missingDependencies.size > 0) {
|
||||
if (!isEffect && missingDependencies.size) {
|
||||
suggestedDeps = collectRecommendations({
|
||||
dependencies,
|
||||
declaredDependencies: [], // Pretend we don't know
|
||||
@ -822,7 +849,7 @@ export default {
|
||||
}
|
||||
|
||||
// Alphabetize the suggestions, but only if deps were already alphabetized.
|
||||
function areDeclaredDepsAlphabetized() {
|
||||
function areDeclaredDepsAlphabetized(): boolean {
|
||||
if (declaredDependencies.length === 0) {
|
||||
return true;
|
||||
}
|
||||
@ -830,6 +857,7 @@ export default {
|
||||
const sortedDeclaredDepKeys = declaredDepKeys.slice().sort();
|
||||
return declaredDepKeys.join(',') === sortedDeclaredDepKeys.join(',');
|
||||
}
|
||||
|
||||
if (areDeclaredDepsAlphabetized()) {
|
||||
suggestedDeps.sort();
|
||||
}
|
||||
@ -838,7 +866,7 @@ export default {
|
||||
// This function is the last step before printing a dependency, so now is a good time to
|
||||
// check whether any members in our path are always used as optional-only. In that case,
|
||||
// we will use ?. instead of . to concatenate those parts of the path.
|
||||
function formatDependency(path) {
|
||||
function formatDependency(path: string): string {
|
||||
const members = path.split('.');
|
||||
let finalPath = '';
|
||||
for (let i = 0; i < members.length; i++) {
|
||||
@ -852,7 +880,12 @@ export default {
|
||||
return finalPath;
|
||||
}
|
||||
|
||||
function getWarningMessage(deps, singlePrefix, label, fixVerb) {
|
||||
function getWarningMessage(
|
||||
deps: Set<string>,
|
||||
singlePrefix: string,
|
||||
label: string,
|
||||
fixVerb: string,
|
||||
): string | null {
|
||||
if (deps.size === 0) {
|
||||
return null;
|
||||
}
|
||||
@ -875,7 +908,7 @@ export default {
|
||||
|
||||
let extraWarning = '';
|
||||
if (unnecessaryDependencies.size > 0) {
|
||||
let badRef = null;
|
||||
let badRef: string | null = null;
|
||||
Array.from(unnecessaryDependencies.keys()).forEach(key => {
|
||||
if (badRef !== null) {
|
||||
return;
|
||||
@ -908,7 +941,7 @@ export default {
|
||||
if (propDep == null) {
|
||||
return;
|
||||
}
|
||||
const refs = propDep.references;
|
||||
const refs: Scope.Reference[] = propDep.references;
|
||||
if (!Array.isArray(refs)) {
|
||||
return;
|
||||
}
|
||||
@ -942,17 +975,17 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
if (!extraWarning && missingDependencies.size > 0) {
|
||||
if (!extraWarning && missingDependencies.size) {
|
||||
// See if the user is trying to avoid specifying a callable prop.
|
||||
// This usually means they're unaware of useCallback.
|
||||
let missingCallbackDep = null;
|
||||
let missingCallbackDep: string | null = null;
|
||||
missingDependencies.forEach(missingDep => {
|
||||
if (missingCallbackDep) {
|
||||
return;
|
||||
}
|
||||
// Is this a variable from top scope?
|
||||
const topScopeRef = componentScope.set.get(missingDep);
|
||||
const usedDep = dependencies.get(missingDep);
|
||||
const usedDep = dependencies.get(missingDep)!;
|
||||
if (usedDep.references[0].resolved !== topScopeRef) {
|
||||
return;
|
||||
}
|
||||
@ -963,9 +996,9 @@ export default {
|
||||
}
|
||||
// Was it called in at least one case? Then it's a function.
|
||||
let isFunctionCall = false;
|
||||
let id;
|
||||
let id: Identifier;
|
||||
for (let i = 0; i < usedDep.references.length; i++) {
|
||||
id = usedDep.references[i].identifier;
|
||||
id = usedDep.references[i].identifier as Identifier;
|
||||
if (
|
||||
id != null &&
|
||||
id.parent != null &&
|
||||
@ -994,37 +1027,41 @@ export default {
|
||||
}
|
||||
|
||||
if (!extraWarning && missingDependencies.size > 0) {
|
||||
let setStateRecommendation = null;
|
||||
missingDependencies.forEach(missingDep => {
|
||||
let setStateRecommendation: {
|
||||
missingDep: string;
|
||||
setter: string;
|
||||
form: 'updater' | 'inlineReducer' | 'reducer';
|
||||
} | null = null;
|
||||
for (const missingDep of missingDependencies) {
|
||||
if (setStateRecommendation !== null) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
const usedDep = dependencies.get(missingDep);
|
||||
const usedDep = dependencies.get(missingDep)!;
|
||||
const references = usedDep.references;
|
||||
let id;
|
||||
let maybeCall;
|
||||
let id: Identifier;
|
||||
let maybeCall: Node | null;
|
||||
for (let i = 0; i < references.length; i++) {
|
||||
id = references[i].identifier;
|
||||
maybeCall = id.parent;
|
||||
id = references[i].identifier as Identifier;
|
||||
maybeCall = id.parent!;
|
||||
// Try to see if we have setState(someExpr(missingDep)).
|
||||
while (maybeCall != null && maybeCall !== componentScope.block) {
|
||||
if (maybeCall.type === 'CallExpression') {
|
||||
const correspondingStateVariable = setStateCallSites.get(
|
||||
maybeCall.callee,
|
||||
maybeCall.callee as Expression,
|
||||
);
|
||||
if (correspondingStateVariable != null) {
|
||||
if (correspondingStateVariable.name === missingDep) {
|
||||
if ((correspondingStateVariable as Identifier).name === missingDep) {
|
||||
// setCount(count + 1)
|
||||
setStateRecommendation = {
|
||||
missingDep,
|
||||
setter: maybeCall.callee.name,
|
||||
setter: (maybeCall.callee as Identifier).name,
|
||||
form: 'updater',
|
||||
};
|
||||
} else if (stateVariables.has(id)) {
|
||||
// setCount(count + increment)
|
||||
setStateRecommendation = {
|
||||
missingDep,
|
||||
setter: maybeCall.callee.name,
|
||||
setter: (maybeCall.callee as Identifier).name,
|
||||
form: 'reducer',
|
||||
};
|
||||
} else {
|
||||
@ -1037,7 +1074,7 @@ export default {
|
||||
if (def != null && def.type === 'Parameter') {
|
||||
setStateRecommendation = {
|
||||
missingDep,
|
||||
setter: maybeCall.callee.name,
|
||||
setter: (maybeCall.callee as Identifier).name,
|
||||
form: 'inlineReducer',
|
||||
};
|
||||
}
|
||||
@ -1046,13 +1083,14 @@ export default {
|
||||
break;
|
||||
}
|
||||
}
|
||||
maybeCall = maybeCall.parent;
|
||||
maybeCall = maybeCall.parent!;
|
||||
}
|
||||
if (setStateRecommendation !== null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (setStateRecommendation !== null) {
|
||||
switch (setStateRecommendation.form) {
|
||||
case 'reducer':
|
||||
@ -1110,15 +1148,16 @@ export default {
|
||||
});
|
||||
}
|
||||
|
||||
function visitCallExpression(node) {
|
||||
function visitCallExpression(node: CallExpression): void {
|
||||
const callbackIndex = getReactiveHookCallbackIndex(node.callee, options);
|
||||
if (callbackIndex === -1) {
|
||||
// Not a React Hook call that needs deps.
|
||||
return;
|
||||
}
|
||||
const callback = node.arguments[callbackIndex];
|
||||
const reactiveHook = node.callee;
|
||||
const reactiveHookName = getNodeWithoutReactNamespace(reactiveHook).name;
|
||||
const reactiveHook = node.callee as Identifier | MemberExpression;
|
||||
const reactiveHookName = (getNodeWithoutReactNamespace(reactiveHook) as Identifier)
|
||||
.name;
|
||||
const declaredDependenciesNode = node.arguments[callbackIndex + 1];
|
||||
const isEffect = /Effect($|[^a-z])/g.test(reactiveHookName);
|
||||
|
||||
@ -1172,8 +1211,8 @@ export default {
|
||||
// The function passed as a callback is not written inline.
|
||||
// But perhaps it's in the dependencies array?
|
||||
if (
|
||||
declaredDependenciesNode.elements &&
|
||||
declaredDependenciesNode.elements.some(
|
||||
declaredDependenciesNode.type === 'ArrayExpression' &&
|
||||
declaredDependenciesNode.elements?.some(
|
||||
el => el && el.type === 'Identifier' && el.name === callback.name,
|
||||
)
|
||||
) {
|
||||
@ -1266,6 +1305,27 @@ export default {
|
||||
},
|
||||
};
|
||||
|
||||
interface Dependencies {
|
||||
isStable: boolean;
|
||||
references: Scope.Reference[];
|
||||
}
|
||||
|
||||
interface DeclaredDependency {
|
||||
key: string;
|
||||
node: Expression;
|
||||
}
|
||||
|
||||
interface DepTree {
|
||||
/** True if used in code */
|
||||
isUsed: boolean;
|
||||
/** True if specified in deps */
|
||||
isSatisfiedRecursively: boolean;
|
||||
/** True if something deeper is used by code */
|
||||
isSubtreeUsed: boolean;
|
||||
/** Nodes for properties */
|
||||
children: Map<string, DepTree>;
|
||||
}
|
||||
|
||||
// The meat of the logic.
|
||||
function collectRecommendations({
|
||||
dependencies,
|
||||
@ -1273,7 +1333,18 @@ function collectRecommendations({
|
||||
stableDependencies,
|
||||
externalDependencies,
|
||||
isEffect,
|
||||
}) {
|
||||
}: {
|
||||
dependencies: Map<string, Dependencies>;
|
||||
declaredDependencies: DeclaredDependency[];
|
||||
stableDependencies: Set<string>;
|
||||
externalDependencies: Set<string>;
|
||||
isEffect: boolean;
|
||||
}): {
|
||||
suggestedDependencies: string[];
|
||||
unnecessaryDependencies: Set<string>;
|
||||
duplicateDependencies: Set<string>;
|
||||
missingDependencies: Set<string>;
|
||||
} {
|
||||
// Our primary data structure.
|
||||
// It is a logical representation of property chains:
|
||||
// `props` -> `props.foo` -> `props.foo.bar` -> `props.foo.bar.baz`
|
||||
@ -1284,12 +1355,13 @@ function collectRecommendations({
|
||||
// and the nodes that were *declared* as deps. Then we will
|
||||
// traverse it to learn which deps are missing or unnecessary.
|
||||
const depTree = createDepTree();
|
||||
function createDepTree() {
|
||||
|
||||
function createDepTree(): DepTree {
|
||||
return {
|
||||
isUsed: false, // True if used in code
|
||||
isSatisfiedRecursively: false, // True if specified in deps
|
||||
isSubtreeUsed: false, // True if something deeper is used by code
|
||||
children: new Map(), // Nodes for properties
|
||||
isUsed: false,
|
||||
isSatisfiedRecursively: false,
|
||||
isSubtreeUsed: false,
|
||||
children: new Map<string, never>(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1315,7 +1387,7 @@ function collectRecommendations({
|
||||
});
|
||||
|
||||
// Tree manipulation helpers.
|
||||
function getOrCreateNodeByPath(rootNode, path) {
|
||||
function getOrCreateNodeByPath(rootNode: DepTree, path: string): DepTree {
|
||||
const keys = path.split('.');
|
||||
let node = rootNode;
|
||||
for (const key of keys) {
|
||||
@ -1328,7 +1400,12 @@ function collectRecommendations({
|
||||
}
|
||||
return node;
|
||||
}
|
||||
function markAllParentsByPath(rootNode, path, fn) {
|
||||
|
||||
function markAllParentsByPath(
|
||||
rootNode: DepTree,
|
||||
path: string,
|
||||
fn: (depTree: DepTree) => void,
|
||||
): void {
|
||||
const keys = path.split('.');
|
||||
let node = rootNode;
|
||||
for (const key of keys) {
|
||||
@ -1342,10 +1419,15 @@ function collectRecommendations({
|
||||
}
|
||||
|
||||
// Now we can learn which dependencies are missing or necessary.
|
||||
const missingDependencies = new Set();
|
||||
const satisfyingDependencies = new Set();
|
||||
const missingDependencies = new Set<string>();
|
||||
const satisfyingDependencies = new Set<string>();
|
||||
scanTreeRecursively(depTree, missingDependencies, satisfyingDependencies, key => key);
|
||||
function scanTreeRecursively(node, missingPaths, satisfyingPaths, keyToPath) {
|
||||
function scanTreeRecursively(
|
||||
node: DepTree,
|
||||
missingPaths: Set<string>,
|
||||
satisfyingPaths: Set<string>,
|
||||
keyToPath: (key: string) => string,
|
||||
): void {
|
||||
node.children.forEach((child, key) => {
|
||||
const path = keyToPath(key);
|
||||
if (child.isSatisfiedRecursively) {
|
||||
@ -1375,13 +1457,13 @@ function collectRecommendations({
|
||||
}
|
||||
|
||||
// Collect suggestions in the order they were originally specified.
|
||||
const suggestedDependencies = [];
|
||||
const unnecessaryDependencies = new Set();
|
||||
const duplicateDependencies = new Set();
|
||||
const suggestedDependencies: string[] = [];
|
||||
const unnecessaryDependencies = new Set<string>();
|
||||
const duplicateDependencies = new Set<string>();
|
||||
declaredDependencies.forEach(({ key }) => {
|
||||
// Does this declared dep satisfy a real need?
|
||||
if (satisfyingDependencies.has(key)) {
|
||||
if (suggestedDependencies.indexOf(key) === -1) {
|
||||
if (!suggestedDependencies.includes(key)) {
|
||||
// Good one.
|
||||
suggestedDependencies.push(key);
|
||||
} else {
|
||||
@ -1419,7 +1501,7 @@ function collectRecommendations({
|
||||
|
||||
// If the node will result in constructing a referentially unique value, return
|
||||
// its human readable type name, else return null.
|
||||
function getConstructionExpressionType(node) {
|
||||
function getConstructionExpressionType(node: Node) {
|
||||
switch (node.type) {
|
||||
case 'ObjectExpression':
|
||||
return 'object';
|
||||
@ -1477,6 +1559,11 @@ function scanForConstructions({
|
||||
declaredDependenciesNode,
|
||||
componentScope,
|
||||
scope,
|
||||
}: {
|
||||
declaredDependencies: DeclaredDependency[];
|
||||
declaredDependenciesNode: Node;
|
||||
componentScope: Scope.Scope;
|
||||
scope: Scope.Scope;
|
||||
}) {
|
||||
const constructions = declaredDependencies
|
||||
.map(({ key }) => {
|
||||
@ -1502,23 +1589,23 @@ function scanForConstructions({
|
||||
) {
|
||||
const constantExpressionType = getConstructionExpressionType(node.node.init);
|
||||
if (constantExpressionType != null) {
|
||||
return [ref, constantExpressionType];
|
||||
return [ref, constantExpressionType] as const;
|
||||
}
|
||||
}
|
||||
// function handleChange() {}
|
||||
if (node.type === 'FunctionName' && node.node.type === 'FunctionDeclaration') {
|
||||
return [ref, 'function'];
|
||||
return [ref, 'function'] as const;
|
||||
}
|
||||
|
||||
// class Foo {}
|
||||
if (node.type === 'ClassName' && node.node.type === 'ClassDeclaration') {
|
||||
return [ref, 'class'];
|
||||
return [ref, 'class'] as const;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
function isUsedOutsideOfHook(ref) {
|
||||
function isUsedOutsideOfHook(ref: Scope.Variable): boolean {
|
||||
let foundWriteExpr = false;
|
||||
for (let i = 0; i < ref.references.length; i++) {
|
||||
const reference = ref.references[i];
|
||||
@ -1534,7 +1621,7 @@ function scanForConstructions({
|
||||
}
|
||||
let currentScope = reference.from;
|
||||
while (currentScope !== scope && currentScope != null) {
|
||||
currentScope = currentScope.upper;
|
||||
currentScope = currentScope.upper!;
|
||||
}
|
||||
if (currentScope !== scope) {
|
||||
// This reference is outside the Hook callback.
|
||||
@ -1561,21 +1648,22 @@ function scanForConstructions({
|
||||
* props.foo.(bar) => (props).foo.bar
|
||||
* props.foo.bar.(baz) => (props).foo.bar.baz
|
||||
*/
|
||||
function getDependency(node) {
|
||||
function getDependency(node: Node): Node {
|
||||
const parent = node.parent!;
|
||||
if (
|
||||
(node.parent.type === 'MemberExpression' ||
|
||||
node.parent.type === 'OptionalMemberExpression') &&
|
||||
node.parent.object === node &&
|
||||
node.parent.property.name !== 'current' &&
|
||||
!node.parent.computed &&
|
||||
(parent.type === 'MemberExpression' || parent.type === 'OptionalMemberExpression') &&
|
||||
parent.object === node &&
|
||||
parent.property.type === 'Identifier' &&
|
||||
parent.property.name !== 'current' &&
|
||||
!parent.computed &&
|
||||
!(
|
||||
node.parent.parent != null &&
|
||||
(node.parent.parent.type === 'CallExpression' ||
|
||||
node.parent.parent.type === 'OptionalCallExpression') &&
|
||||
node.parent.parent.callee === node.parent
|
||||
parent.parent != null &&
|
||||
(parent.parent.type === 'CallExpression' ||
|
||||
parent.parent.type === 'OptionalCallExpression') &&
|
||||
parent.parent.callee === parent
|
||||
)
|
||||
) {
|
||||
return getDependency(node.parent);
|
||||
return getDependency(parent);
|
||||
} else if (
|
||||
// Note: we don't check OptionalMemberExpression because it can't be LHS.
|
||||
node.type === 'MemberExpression' &&
|
||||
@ -1595,9 +1683,13 @@ function getDependency(node) {
|
||||
* It just means there is an optional member somewhere inside.
|
||||
* This particular node might still represent a required member, so check .optional field.
|
||||
*/
|
||||
function markNode(node, optionalChains, result) {
|
||||
function markNode(
|
||||
node: Node,
|
||||
optionalChains: Map<string, boolean> | null,
|
||||
result: string,
|
||||
): void {
|
||||
if (optionalChains) {
|
||||
if (node.optional) {
|
||||
if ((node as OptionalMemberExpression).optional) {
|
||||
// We only want to consider it optional if *all* usages were optional.
|
||||
if (!optionalChains.has(result)) {
|
||||
// Mark as (maybe) optional. If there's a required usage, this will be overridden.
|
||||
@ -1617,7 +1709,10 @@ function markNode(node, optionalChains, result) {
|
||||
* foo.bar(.)baz -> 'foo.bar.baz'
|
||||
* Otherwise throw.
|
||||
*/
|
||||
function analyzePropertyChain(node, optionalChains) {
|
||||
function analyzePropertyChain(
|
||||
node: Node,
|
||||
optionalChains: Map<string, boolean> | null,
|
||||
): string {
|
||||
if (node.type === 'Identifier' || node.type === 'JSXIdentifier') {
|
||||
const result = node.name;
|
||||
if (optionalChains) {
|
||||
@ -1654,7 +1749,7 @@ function analyzePropertyChain(node, optionalChains) {
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeWithoutReactNamespace(node, options) {
|
||||
function getNodeWithoutReactNamespace(node: Identifier | MemberExpression) {
|
||||
if (
|
||||
node.type === 'MemberExpression' &&
|
||||
node.object.type === 'Identifier' &&
|
||||
@ -1672,7 +1767,13 @@ function getNodeWithoutReactNamespace(node, options) {
|
||||
// 0 for useEffect/useMemo/useCallback(fn).
|
||||
// 1 for useImperativeHandle(ref, fn).
|
||||
// For additionally configured Hooks, assume that they're like useEffect (0).
|
||||
function getReactiveHookCallbackIndex(calleeNode, options) {
|
||||
function getReactiveHookCallbackIndex(
|
||||
calleeNode: Expression | Super,
|
||||
options: {
|
||||
additionalHooks?: RegExp;
|
||||
enableDangerousAutofixThisMayCauseInfiniteLoops: boolean;
|
||||
},
|
||||
): 0 | 1 | -1 {
|
||||
const node = getNodeWithoutReactNamespace(calleeNode);
|
||||
if (node.type !== 'Identifier') {
|
||||
return -1;
|
||||
@ -1718,12 +1819,12 @@ function getReactiveHookCallbackIndex(calleeNode, options) {
|
||||
* - optimized by only searching nodes with a range surrounding our target node
|
||||
* - agnostic to AST node types, it looks for `{ type: string, ... }`
|
||||
*/
|
||||
function fastFindReferenceWithParent(start, target) {
|
||||
function fastFindReferenceWithParent(start: Node, target: Node): Node | null {
|
||||
const queue = [start];
|
||||
let item = null;
|
||||
let item: Node;
|
||||
|
||||
while (queue.length) {
|
||||
item = queue.shift();
|
||||
item = queue.shift()!;
|
||||
|
||||
if (isSameIdentifier(item, target)) {
|
||||
return item;
|
||||
@ -1754,7 +1855,7 @@ function fastFindReferenceWithParent(start, target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function joinEnglish(arr) {
|
||||
function joinEnglish(arr: string[]): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
s += arr[i];
|
||||
@ -1769,7 +1870,7 @@ function joinEnglish(arr) {
|
||||
return s;
|
||||
}
|
||||
|
||||
function isNodeLike(val) {
|
||||
function isNodeLike(val: any): boolean {
|
||||
return (
|
||||
typeof val === 'object' &&
|
||||
val !== null &&
|
||||
@ -1778,23 +1879,25 @@ function isNodeLike(val) {
|
||||
);
|
||||
}
|
||||
|
||||
function isSameIdentifier(a, b) {
|
||||
function isSameIdentifier(a: Node, b: Node): boolean {
|
||||
return (
|
||||
(a.type === 'Identifier' || a.type === 'JSXIdentifier') &&
|
||||
a.type === b.type &&
|
||||
a.name === b.name &&
|
||||
a.range[0] === b.range[0] &&
|
||||
a.range[1] === b.range[1]
|
||||
a.range![0] === b.range![0] &&
|
||||
a.range![1] === b.range![1]
|
||||
);
|
||||
}
|
||||
|
||||
function isAncestorNodeOf(a, b) {
|
||||
return a.range[0] <= b.range[0] && a.range[1] >= b.range[1];
|
||||
function isAncestorNodeOf(a: Node, b: Node): boolean {
|
||||
return a.range![0] <= b.range![0] && a.range![1] >= b.range![1];
|
||||
}
|
||||
|
||||
function isUseEffectEventIdentifier(node) {
|
||||
function isUseEffectEventIdentifier(node: Node): boolean {
|
||||
if (__EXPERIMENTAL__) {
|
||||
return node.type === 'Identifier' && node.name === 'useEffectEvent';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default rule;
|
||||
|
@ -7,15 +7,23 @@
|
||||
|
||||
/* global BigInt */
|
||||
/* eslint-disable no-for-of-loops/no-for-of-loops */
|
||||
|
||||
'use strict';
|
||||
import type { Rule, Scope } from 'eslint';
|
||||
import type {
|
||||
CallExpression,
|
||||
Expression,
|
||||
Super,
|
||||
Node,
|
||||
Identifier,
|
||||
BaseFunction,
|
||||
} from 'estree';
|
||||
import { __EXPERIMENTAL__ } from './index';
|
||||
|
||||
/**
|
||||
* Catch all identifiers that begin with "use" followed by an uppercase Latin
|
||||
* character to exclude identifiers like "user".
|
||||
*/
|
||||
|
||||
function isHookName(s) {
|
||||
function isHookName(s: string) {
|
||||
if (__EXPERIMENTAL__) {
|
||||
return s === 'use' || /^use[A-Z0-9]/.test(s);
|
||||
}
|
||||
@ -26,8 +34,7 @@ function isHookName(s) {
|
||||
* We consider hooks to be a hook name identifier or a member expression
|
||||
* containing a hook name.
|
||||
*/
|
||||
|
||||
function isHook(node) {
|
||||
function isHook(node: Node) {
|
||||
if (node.type === 'Identifier') {
|
||||
return isHookName(node.name);
|
||||
} else if (
|
||||
@ -48,16 +55,16 @@ function isHook(node) {
|
||||
* always start with an uppercase letter.
|
||||
*/
|
||||
|
||||
function isComponentName(node) {
|
||||
function isComponentName(node: Node) {
|
||||
return node.type === 'Identifier' && /^[A-Z]/.test(node.name);
|
||||
}
|
||||
|
||||
function isReactFunction(node, functionName) {
|
||||
function isReactFunction(node: Expression | Super, functionName: string) {
|
||||
return (
|
||||
node.name === functionName ||
|
||||
(node as Identifier).name === functionName ||
|
||||
(node.type === 'MemberExpression' &&
|
||||
node.object.name === 'React' &&
|
||||
node.property.name === functionName)
|
||||
(node.object as Identifier).name === 'React' &&
|
||||
(node.property as Identifier).name === functionName)
|
||||
);
|
||||
}
|
||||
|
||||
@ -65,12 +72,10 @@ function isReactFunction(node, functionName) {
|
||||
* Checks if the node is a callback argument of forwardRef. This render function
|
||||
* should follow the rules of hooks.
|
||||
*/
|
||||
|
||||
function isForwardRefCallback(node) {
|
||||
function isForwardRefCallback(node: Rule.Node) {
|
||||
return !!(
|
||||
node.parent &&
|
||||
node.parent.callee &&
|
||||
isReactFunction(node.parent.callee, 'forwardRef')
|
||||
(node.parent as CallExpression)?.callee &&
|
||||
isReactFunction((node.parent as CallExpression).callee, 'forwardRef')
|
||||
);
|
||||
}
|
||||
|
||||
@ -79,15 +84,14 @@ function isForwardRefCallback(node) {
|
||||
* functional component should follow the rules of hooks.
|
||||
*/
|
||||
|
||||
function isMemoCallback(node) {
|
||||
function isMemoCallback(node: Rule.Node) {
|
||||
return !!(
|
||||
node.parent &&
|
||||
node.parent.callee &&
|
||||
isReactFunction(node.parent.callee, 'memo')
|
||||
(node.parent as CallExpression)?.callee &&
|
||||
isReactFunction((node.parent as CallExpression).callee, 'memo')
|
||||
);
|
||||
}
|
||||
|
||||
function isInsideComponentOrHook(node) {
|
||||
function isInsideComponentOrHook(node: Rule.Node) {
|
||||
while (node) {
|
||||
const functionName = getFunctionName(node);
|
||||
if (functionName) {
|
||||
@ -103,21 +107,21 @@ function isInsideComponentOrHook(node) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isUseEffectEventIdentifier(node) {
|
||||
function isUseEffectEventIdentifier(node: Node) {
|
||||
if (__EXPERIMENTAL__) {
|
||||
return node.type === 'Identifier' && node.name === 'useEffectEvent';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isUseIdentifier(node) {
|
||||
function isUseIdentifier(node: Node) {
|
||||
if (__EXPERIMENTAL__) {
|
||||
return node.type === 'Identifier' && node.name === 'use';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default {
|
||||
const rule: Rule.RuleModule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
@ -127,17 +131,20 @@ export default {
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
let lastEffect = null;
|
||||
const codePathReactHooksMapStack = [];
|
||||
const codePathSegmentStack = [];
|
||||
const useEffectEventFunctions = new WeakSet();
|
||||
let lastEffect: CallExpression | null = null;
|
||||
const codePathReactHooksMapStack: Map<
|
||||
Rule.CodePathSegment,
|
||||
(Expression | Super)[]
|
||||
>[] = [];
|
||||
const codePathSegmentStack: Rule.CodePathSegment[] = [];
|
||||
const useEffectEventFunctions = new WeakSet<Identifier>();
|
||||
|
||||
// For a given scope, iterate through the references and add all useEffectEvent definitions. We can
|
||||
// do this in non-Program nodes because we can rely on the assumption that useEffectEvent functions
|
||||
// can only be declared within a component or hook at its top level.
|
||||
function recordAllUseEffectEventFunctions(scope) {
|
||||
function recordAllUseEffectEventFunctions(scope: Scope.Scope) {
|
||||
for (const reference of scope.references) {
|
||||
const parent = reference.identifier.parent;
|
||||
const parent = reference.identifier.parent!;
|
||||
if (
|
||||
parent.type === 'VariableDeclarator' &&
|
||||
parent.init &&
|
||||
@ -145,7 +152,7 @@ export default {
|
||||
parent.init.callee &&
|
||||
isUseEffectEventIdentifier(parent.init.callee)
|
||||
) {
|
||||
for (const ref of reference.resolved.references) {
|
||||
for (const ref of reference.resolved!.references) {
|
||||
if (ref !== reference) {
|
||||
useEffectEventFunctions.add(ref.identifier);
|
||||
}
|
||||
@ -167,7 +174,7 @@ export default {
|
||||
// Everything is ok if all React Hooks are both reachable from the initial
|
||||
// segment and reachable from every final segment.
|
||||
onCodePathEnd(codePath, codePathNode) {
|
||||
const reactHooksMap = codePathReactHooksMapStack.pop();
|
||||
const reactHooksMap = codePathReactHooksMapStack.pop()!;
|
||||
if (reactHooksMap.size === 0) {
|
||||
return;
|
||||
}
|
||||
@ -197,7 +204,10 @@ export default {
|
||||
* Populates `cyclic` with cyclic segments.
|
||||
*/
|
||||
|
||||
function countPathsFromStart(segment, pathHistory) {
|
||||
function countPathsFromStart(
|
||||
segment: Rule.CodePathSegment,
|
||||
pathHistory?: Set<string>,
|
||||
) {
|
||||
const { cache } = countPathsFromStart;
|
||||
let paths = cache.get(segment.id);
|
||||
const pathList = new Set(pathHistory);
|
||||
@ -211,7 +221,7 @@ export default {
|
||||
cyclic.add(cyclicSegment);
|
||||
}
|
||||
|
||||
return BigInt('0');
|
||||
return 0n;
|
||||
}
|
||||
|
||||
// add the current segment to pathList
|
||||
@ -223,11 +233,11 @@ export default {
|
||||
}
|
||||
|
||||
if (codePath.thrownSegments.includes(segment)) {
|
||||
paths = BigInt('0');
|
||||
paths = 0n;
|
||||
} else if (segment.prevSegments.length === 0) {
|
||||
paths = BigInt('1');
|
||||
paths = 1n;
|
||||
} else {
|
||||
paths = BigInt('0');
|
||||
paths = 0n;
|
||||
for (const prevSegment of segment.prevSegments) {
|
||||
paths += countPathsFromStart(prevSegment, pathList);
|
||||
}
|
||||
@ -266,7 +276,10 @@ export default {
|
||||
* Populates `cyclic` with cyclic segments.
|
||||
*/
|
||||
|
||||
function countPathsToEnd(segment, pathHistory) {
|
||||
function countPathsToEnd(
|
||||
segment: Rule.CodePathSegment,
|
||||
pathHistory?: Set<string>,
|
||||
): bigint {
|
||||
const { cache } = countPathsToEnd;
|
||||
let paths = cache.get(segment.id);
|
||||
const pathList = new Set(pathHistory);
|
||||
@ -280,7 +293,7 @@ export default {
|
||||
cyclic.add(cyclicSegment);
|
||||
}
|
||||
|
||||
return BigInt('0');
|
||||
return 0n;
|
||||
}
|
||||
|
||||
// add the current segment to pathList
|
||||
@ -292,11 +305,11 @@ export default {
|
||||
}
|
||||
|
||||
if (codePath.thrownSegments.includes(segment)) {
|
||||
paths = BigInt('0');
|
||||
paths = 0n;
|
||||
} else if (segment.nextSegments.length === 0) {
|
||||
paths = BigInt('1');
|
||||
paths = 1n;
|
||||
} else {
|
||||
paths = BigInt('0');
|
||||
paths = 0n;
|
||||
for (const nextSegment of segment.nextSegments) {
|
||||
paths += countPathsToEnd(nextSegment, pathList);
|
||||
}
|
||||
@ -328,7 +341,7 @@ export default {
|
||||
* so we would return that.
|
||||
*/
|
||||
|
||||
function shortestPathLengthToStart(segment) {
|
||||
function shortestPathLengthToStart(segment: Rule.CodePathSegment): number {
|
||||
const { cache } = shortestPathLengthToStart;
|
||||
let length = cache.get(segment.id);
|
||||
|
||||
@ -361,9 +374,9 @@ export default {
|
||||
return length;
|
||||
}
|
||||
|
||||
countPathsFromStart.cache = new Map();
|
||||
countPathsToEnd.cache = new Map();
|
||||
shortestPathLengthToStart.cache = new Map();
|
||||
countPathsFromStart.cache = new Map<string, bigint>();
|
||||
countPathsToEnd.cache = new Map<string, bigint>();
|
||||
shortestPathLengthToStart.cache = new Map<string, number | null>();
|
||||
|
||||
// Count all code paths to the end of our component/hook. Also primes
|
||||
// the `countPathsToEnd` cache.
|
||||
@ -480,7 +493,7 @@ export default {
|
||||
// called in.
|
||||
if (isDirectlyInsideComponentOrHook) {
|
||||
// Report an error if the hook is called inside an async function.
|
||||
const isAsyncFunction = codePathNode.async;
|
||||
const isAsyncFunction = (codePathNode as BaseFunction).async;
|
||||
if (isAsyncFunction) {
|
||||
context.report({
|
||||
node: hook,
|
||||
@ -565,8 +578,8 @@ export default {
|
||||
if (isHook(node.callee)) {
|
||||
// Add the hook node to a map keyed by the code path segment. We will
|
||||
// do full code path analysis at the end of our code path.
|
||||
const reactHooksMap = last(codePathReactHooksMapStack);
|
||||
const codePathSegment = last(codePathSegmentStack);
|
||||
const reactHooksMap = codePathReactHooksMapStack.at(-1)!;
|
||||
const codePathSegment = codePathSegmentStack.at(-1)!;
|
||||
let reactHooks = reactHooksMap.get(codePathSegment);
|
||||
if (!reactHooks) {
|
||||
reactHooks = [];
|
||||
@ -637,8 +650,8 @@ export default {
|
||||
* where JS gives anonymous function expressions names. We roughly detect the
|
||||
* same AST nodes with some exceptions to better fit our use case.
|
||||
*/
|
||||
|
||||
function getFunctionName(node) {
|
||||
function getFunctionName(node: Node) {
|
||||
const parent = node.parent!;
|
||||
if (
|
||||
node.type === 'FunctionDeclaration' ||
|
||||
(node.type === 'FunctionExpression' && node.id)
|
||||
@ -653,24 +666,20 @@ function getFunctionName(node) {
|
||||
node.type === 'FunctionExpression' ||
|
||||
node.type === 'ArrowFunctionExpression'
|
||||
) {
|
||||
if (node.parent.type === 'VariableDeclarator' && node.parent.init === node) {
|
||||
if (parent.type === 'VariableDeclarator' && parent.init === node) {
|
||||
// const useHook = () => {};
|
||||
return node.parent.id;
|
||||
return parent.id;
|
||||
} else if (
|
||||
node.parent.type === 'AssignmentExpression' &&
|
||||
node.parent.right === node &&
|
||||
node.parent.operator === '='
|
||||
parent.type === 'AssignmentExpression' &&
|
||||
parent.right === node &&
|
||||
parent.operator === '='
|
||||
) {
|
||||
// useHook = () => {};
|
||||
return node.parent.left;
|
||||
} else if (
|
||||
node.parent.type === 'Property' &&
|
||||
node.parent.value === node &&
|
||||
!node.parent.computed
|
||||
) {
|
||||
return parent.left;
|
||||
} else if (parent.type === 'Property' && parent.value === node && !parent.computed) {
|
||||
// {useHook: () => {}}
|
||||
// {useHook() {}}
|
||||
return node.parent.key;
|
||||
return parent.key;
|
||||
|
||||
// NOTE: We could also support `ClassProperty` and `MethodDefinition`
|
||||
// here to be pedantic. However, hooks in a class are an anti-pattern. So
|
||||
@ -679,16 +688,16 @@ function getFunctionName(node) {
|
||||
// class {useHook = () => {}}
|
||||
// class {useHook() {}}
|
||||
} else if (
|
||||
node.parent.type === 'AssignmentPattern' &&
|
||||
node.parent.right === node &&
|
||||
!node.parent.computed
|
||||
parent.type === 'AssignmentPattern' &&
|
||||
parent.right === node &&
|
||||
!parent.computed
|
||||
) {
|
||||
// const {useHook = () => {}} = {};
|
||||
// ({useHook = () => {}} = {});
|
||||
//
|
||||
// Kinda clowny, but we'd said we'd follow spec convention for
|
||||
// `IsAnonymousFunctionDefinition()` usage.
|
||||
return node.parent.left;
|
||||
return parent.left;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
@ -697,10 +706,4 @@ function getFunctionName(node) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function for peeking the last item in a stack.
|
||||
*/
|
||||
|
||||
function last(array) {
|
||||
return array[array.length - 1];
|
||||
}
|
||||
export default rule;
|
||||
|
@ -4,10 +4,12 @@
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import type { Linter } from 'eslint';
|
||||
import RulesOfHooks from './RulesOfHooks';
|
||||
import ExhaustiveDeps from './ExhaustiveDeps';
|
||||
|
||||
export const __EXPERIMENTAL__ = false;
|
||||
|
||||
export const configs = {
|
||||
recommended: {
|
||||
plugins: ['react-hooks'],
|
||||
@ -15,7 +17,7 @@ export const configs = {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
},
|
||||
},
|
||||
} as Linter.BaseConfig,
|
||||
};
|
||||
|
||||
export const rules = {
|
||||
|
12
eslint-plugin-react-hooks/package.json
Normal file
12
eslint-plugin-react-hooks/package.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"upstream": {
|
||||
"version": 1,
|
||||
"sources": {
|
||||
"main": {
|
||||
"repository": "git@github.com:facebook/react.git",
|
||||
"commit": "899cb95f52cc83ab5ca1eb1e268c909d3f0961e7",
|
||||
"branch": "main"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
65
eslint-plugin-react-hooks/types.d.ts
vendored
Normal file
65
eslint-plugin-react-hooks/types.d.ts
vendored
Normal file
@ -0,0 +1,65 @@
|
||||
import type { BaseNode } from 'estree';
|
||||
|
||||
declare module 'eslint' {
|
||||
namespace Rule {
|
||||
interface RuleContext {
|
||||
getSource(node: BaseNode): string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'estree' {
|
||||
interface BaseNodeWithoutComments {
|
||||
parent?: Node;
|
||||
}
|
||||
interface NodeMap {
|
||||
OptionalCallExpression: OptionalCallExpression;
|
||||
OptionalMemberExpression: OptionalMemberExpression;
|
||||
TSAsExpression: TSAsExpression;
|
||||
TSTypeQuery: TSTypeQuery;
|
||||
TSTypeReference: TSTypeReference;
|
||||
TypeCastExpression: TypeCastExpression;
|
||||
}
|
||||
interface ExpressionMap {
|
||||
OptionalCallExpression: OptionalCallExpression;
|
||||
OptionalMemberExpression: OptionalMemberExpression;
|
||||
TSAsExpression: TSAsExpression;
|
||||
TypeCastExpression: TypeCastExpression;
|
||||
}
|
||||
interface AssignmentPattern {
|
||||
computed?: boolean;
|
||||
}
|
||||
interface ChainExpression {
|
||||
computed?: boolean;
|
||||
}
|
||||
interface TypeCastExpression extends BaseNode {
|
||||
type: 'TypeCastExpression';
|
||||
expression: Expression;
|
||||
}
|
||||
interface TSAsExpression extends BaseNode {
|
||||
type: 'TSAsExpression';
|
||||
expression: Expression;
|
||||
}
|
||||
interface TSTypeQuery extends BaseNode {
|
||||
type: 'TSTypeQuery';
|
||||
}
|
||||
interface TSTypeReference extends BaseNode {
|
||||
type: 'TSTypeReference';
|
||||
}
|
||||
/** @deprecated flow only */
|
||||
interface TypeParameter extends BaseNode {
|
||||
type: 'TypeParameter';
|
||||
}
|
||||
interface OptionalMemberExpression extends BaseNode {
|
||||
type: 'OptionalMemberExpression';
|
||||
object: Expression | Super;
|
||||
property: Expression | PrivateIdentifier;
|
||||
computed: boolean;
|
||||
optional: boolean;
|
||||
}
|
||||
interface OptionalCallExpression extends BaseNode {
|
||||
type: 'OptionalCallExpression';
|
||||
callee: Expression | Super;
|
||||
arguments: (Expression | SpreadElement)[];
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user