eslint-plugin-sonarjs
Version:
295 lines (294 loc) • 12.6 kB
JavaScript
"use strict";
/*
* SonarQube JavaScript Plugin
* Copyright (C) SonarSource Sàrl
* mailto:info AT sonarsource DOT com
*
* You can redistribute and/or modify this program under the terms of
* the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the Sonar Source-Available License for more details.
*
* You should have received a copy of the Sonar Source-Available License
* along with this program; if not, see https://sonarsource.com/license/ssal/
*/
// https://sonarsource.github.io/rspec/#/rspec/S9114/javascript
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.rule = void 0;
const ancestor_js_1 = require("../helpers/ancestor.js");
const ast_js_1 = require("../helpers/ast.js");
const generate_meta_js_1 = require("../helpers/generate-meta.js");
const module_js_1 = require("../helpers/module.js");
const component_analysis_js_1 = require("../helpers/react/component-analysis.js");
const meta = __importStar(require("./generated-meta.js"));
const supportedModules = new Set(['lodash', 'lodash-es', 'underscore']);
const methodNames = new Set(['debounce', 'throttle']);
const componentNamePattern = /^[A-Z]/;
const customHookNamePattern = /^use[A-Z]/;
exports.rule = {
meta: (0, generate_meta_js_1.generateMeta)(meta, {
messages: {
recreatedPerRender: 'This {{kind}} function is recreated on every render, which resets its timer and defeats {{purpose}}. Move it outside the component or hook, or wrap it in useMemo.',
recreatedInClassRender: 'This {{kind}} function is recreated on every render, which resets its timer and defeats {{purpose}}. Initialize it in the constructor or as an instance property.',
},
}),
create(context) {
return {
CallExpression(node) {
const call = node;
const syntacticMethod = getSyntacticMethodName(call.callee);
if (syntacticMethod === undefined) {
return;
}
const fullyQualifiedName = (0, module_js_1.getFullyQualifiedName)(context, call);
const methodName = fullyQualifiedName === null ? undefined : getSupportedMethodName(fullyQualifiedName);
if (methodName === undefined) {
return;
}
const enclosingFunction = findFirstEnclosingFunction(call);
if (enclosingFunction === undefined) {
return;
}
const componentType = getComponentType(enclosingFunction);
if (componentType === undefined) {
return;
}
if (isWrappedInMemoHook(context, call, enclosingFunction) ||
isRefLazyInitialization(context, call) ||
isDirectRefInitializer(context, call)) {
return;
}
context.report({
node: getReportNode(call.callee),
messageId: componentType === 'function' ? 'recreatedPerRender' : 'recreatedInClassRender',
data: {
kind: methodName === 'debounce' ? 'debounced' : 'throttled',
purpose: methodName === 'debounce' ? 'debouncing' : 'throttling',
},
});
},
};
},
};
/**
* Returns the debounce/throttle method name when `fullyQualifiedName` points to
* a supported module (`lodash`, `lodash-es`, or `underscore`), or undefined
* otherwise.
*/
function getSupportedMethodName(fullyQualifiedName) {
const parts = fullyQualifiedName.replaceAll('/', '.').split('.');
if (parts.length !== 2) {
return undefined;
}
const [moduleName, qualifier] = parts;
if (supportedModules.has(moduleName) && isMethodName(qualifier)) {
return qualifier;
}
return undefined;
}
/**
* Returns the debounce/throttle name carried by the callee syntax, null when
* the callee is an identifier or call result without that name (the fully
* qualified name must then decide), or undefined when the callee cannot be a
* supported call.
*/
function getSyntacticMethodName(callee) {
if (callee.type === 'ChainExpression') {
return getSyntacticMethodName(callee.expression);
}
if (callee.type === 'MemberExpression') {
if (callee.computed || !(0, ast_js_1.isIdentifier)(callee.property)) {
return undefined;
}
if ((0, ast_js_1.isCallResult)(callee.object) && !(0, module_js_1.isRequire)(callee.object)) {
return undefined;
}
const propertyName = callee.property.name;
return isMethodName(propertyName) ? propertyName : undefined;
}
if (callee.type === 'Identifier' || callee.type === 'CallExpression') {
return null;
}
return undefined;
}
function isMethodName(name) {
return methodNames.has(name);
}
/**
* Returns the innermost function that encloses `node`, or undefined when the
* node sits at module scope. When that function is not the React component
* itself (a hook callback, an event handler, ...), the call is not made
* directly in the component body and must not be reported.
*/
function findFirstEnclosingFunction(node) {
return (0, ancestor_js_1.findFirstMatchingLocalAncestor)(node, ancestor => (0, ast_js_1.isFunctionNode)(ancestor));
}
function getComponentType(functionNode) {
if (isFunctionComponent(functionNode)) {
return 'function';
}
return isClassComponentRenderMethod(functionNode) ? 'class' : undefined;
}
function isFunctionComponent(functionNode) {
const identifier = (0, component_analysis_js_1.getComponentIdentifier)(functionNode);
return (identifier !== undefined &&
(componentNamePattern.test(identifier.name) || customHookNamePattern.test(identifier.name)));
}
function isRefLazyInitialization(context, call) {
const assignment = (0, ancestor_js_1.getNodeParent)(call);
if (assignment.type !== 'AssignmentExpression' ||
assignment.operator !== '=' ||
!isRefCurrentMember(assignment.left)) {
return false;
}
const refIdentifier = assignment.left.object;
if (!isReactUseRef(context, refIdentifier)) {
return false;
}
const statement = (0, ancestor_js_1.getNodeParent)(assignment);
if (statement.type !== 'ExpressionStatement') {
return false;
}
const ifStatement = getGuardingIfStatement(statement);
return ifStatement !== undefined && isRefInitializationGuard(ifStatement.test, refIdentifier);
}
function getGuardingIfStatement(statement) {
const parent = (0, ancestor_js_1.getNodeParent)(statement);
if (parent.type === 'IfStatement' && parent.consequent === statement) {
return parent;
}
if (parent.type !== 'BlockStatement') {
return undefined;
}
const ifStatement = (0, ancestor_js_1.getNodeParent)(parent);
return ifStatement.type === 'IfStatement' && ifStatement.consequent === parent
? ifStatement
: undefined;
}
function isReactUseRef(context, refIdentifier) {
const variable = (0, ast_js_1.getVariableFromName)(context, refIdentifier.name, refIdentifier);
if (variable?.defs.length !== 1 || variable.defs[0].type !== 'Variable') {
return false;
}
const init = variable.defs[0].node.init;
return init?.type === 'CallExpression' && isReactUseRefCall(context, init);
}
function isDirectRefInitializer(context, call) {
const parent = (0, ancestor_js_1.findFirstMatchingLocalAncestor)(call, ancestor => ancestor.type === 'CallExpression');
return (parent?.type === 'CallExpression' &&
(0, ast_js_1.unwrapTypeScriptExpression)(parent.arguments[0]) === call &&
// Safe: the preceding type guard narrows parent to a CallExpression.
isReactUseRefCall(context, parent));
}
function isReactUseRefCall(context, call) {
return (0, module_js_1.getFullyQualifiedName)(context, call) === 'react.useRef';
}
function isRefCurrentMember(node) {
return (node.type === 'MemberExpression' &&
!node.computed &&
(0, ast_js_1.isIdentifier)(node.object) &&
(0, ast_js_1.isIdentifier)(node.property, 'current'));
}
function isRefInitializationGuard(test, refIdentifier) {
if (test.type === 'UnaryExpression' &&
test.operator === '!' &&
isRefCurrentMemberFor(test.argument, refIdentifier)) {
return true;
}
if (test.type !== 'BinaryExpression' || !['===', '=='].includes(test.operator)) {
return false;
}
return ((isRefCurrentMemberFor(test.left, refIdentifier) && (0, ast_js_1.isNullLiteral)(test.right)) ||
((0, ast_js_1.isNullLiteral)(test.left) && isRefCurrentMemberFor(test.right, refIdentifier)));
}
function isRefCurrentMemberFor(node, refIdentifier) {
return isRefCurrentMember(node) && node.object.name === refIdentifier.name;
}
function isClassComponentRenderMethod(functionNode) {
const renderMember = (0, ancestor_js_1.getNodeParent)(functionNode);
if (!isRenderMember(renderMember)) {
return false;
}
const enclosingClass = (0, ancestor_js_1.findFirstMatchingAncestor)(renderMember, ancestor => ancestor.type === 'ClassDeclaration' || ancestor.type === 'ClassExpression');
return enclosingClass !== undefined && (0, component_analysis_js_1.isReactClassComponent)(enclosingClass);
}
function isRenderMember(node) {
return ((node.type === 'MethodDefinition' || node.type === 'PropertyDefinition') &&
!node.computed &&
!node.static &&
(0, ast_js_1.isIdentifier)(node.key, 'render'));
}
const memoHooks = new Set(['useMemo', 'useCallback']);
/**
* Whether `call` is wrapped in a useMemo/useCallback call before reaching
* `boundaryFunction`. Calls inside a hook callback are already excluded by
* findFirstEnclosingFunction; this covers `useCallback(debounce(fn, ms))`,
* where the debounced function is created as a direct hook argument.
*/
function isWrappedInMemoHook(context, call, boundaryFunction) {
const ancestors = context.sourceCode.getAncestors(call);
for (let i = ancestors.length - 1; i >= 0; i--) {
const ancestor = ancestors[i];
if (ancestor === boundaryFunction) {
return false;
}
if (ancestor.type === 'CallExpression') {
const calleeName = getHookCalleeName(context, ancestor);
if (calleeName !== undefined && memoHooks.has(calleeName)) {
return true;
}
}
}
return false;
}
function getHookCalleeName(context, call) {
if (call.callee.type === 'Identifier') {
return call.callee.name;
}
return (0, module_js_1.getFullyQualifiedName)(context, call)?.replace(/^react\./, '');
}
function getReportNode(callee) {
if (callee.type === 'ChainExpression') {
return getReportNode(callee.expression);
}
if (callee.type === 'MemberExpression') {
return callee.property;
}
return callee;
}