This commit is contained in:
Alex
2023-07-22 01:00:28 -04:00
parent 77444efbf2
commit 3debdb9e74
27 changed files with 1756 additions and 585 deletions

View File

@ -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;