@ospm/eslint-plugin-react-signals-hooks
Version:
ESLint plugin for React Signals hooks - enforces best practices, performance optimizations, and integration patterns for @preact/signals-react usage in React projects
413 lines • 20.4 kB
JavaScript
/** biome-ignore-all assist/source/organizeImports: off */
import { ESLintUtils, AST_NODE_TYPES, } from '@typescript-eslint/utils';
import { PerformanceOperations } from './utils/performance-constants.js';
import { endPhase, startPhase, recordMetric, startTracking, trackOperation, createPerformanceTracker, DEFAULT_PERFORMANCE_BUDGET, } from './utils/performance.js';
import { getRuleDocUrl } from './utils/urls.js';
function getSeverity(messageId, options) {
if (!options?.severity) {
return 'error';
}
switch (messageId) {
case 'avoidSignalInComponent': {
return options.severity.avoidSignalInComponent ?? 'error';
}
case 'suggestMoveToModuleLevel': {
return options.severity.suggestMoveToModuleLevel ?? 'error';
}
case 'suggestMoveToCustomHook': {
return options.severity.suggestMoveToCustomHook ?? 'error';
}
case 'moveToModuleLevel': {
return options.severity.moveToModuleLevel ?? 'error';
}
case 'createCustomHook': {
return options.severity.createCustomHook ?? 'error';
}
default: {
return 'error';
}
}
}
function getSignalInfo(node, sourceCode) {
return {
signalName: node.callee.type === 'Identifier' ? node.callee.name : 'signal',
signalValue: node.arguments.length > 0 ? sourceCode.getText(node.arguments[0]) : 'undefined',
varName: (node.callee.type === 'Identifier' ? node.callee.name : 'signal') === 'signal'
? 'value'
: 'computedValue',
};
}
function generateUniqueHookName(context, baseName) {
const usedNames = new Set();
function collectNames(node) {
if (node.type === 'Identifier' && node.parent.type !== 'MemberExpression') {
usedNames.add(node.name);
}
if ('body' in node && Array.isArray(node.body)) {
node.body.forEach(collectNames);
}
else if ('body' in node && node.body) {
collectNames(node.body);
}
if ('declarations' in node && Array.isArray(node.declarations)) {
node.declarations.forEach(collectNames);
}
}
collectNames(context.sourceCode.ast);
let hookName = `use${baseName.charAt(0).toUpperCase() + baseName.slice(1)}`;
let counter = 1;
while (usedNames.has(hookName)) {
hookName = `use${baseName.charAt(0).toUpperCase() + baseName.slice(1)}${counter++}`;
}
return hookName;
}
function getLeadingCommentsText(node, sourceCode) {
const leadingComments = sourceCode.getCommentsBefore(node);
if (leadingComments.length === 0) {
return null;
}
const firstComment = leadingComments[0];
const lastComment = leadingComments[leadingComments.length - 1];
if (!firstComment || !lastComment) {
return null;
}
return {
text: sourceCode.text.slice(firstComment.range[0], lastComment.range[1]),
range: [
firstComment.range[0],
lastComment.range[1] + (sourceCode.text[lastComment.range[1]] === '\n' ? 1 : 0),
],
};
}
function isReactComponent(node, parent) {
if (node.type === 'FunctionDeclaration' && node.id) {
return /^[A-Z]/.test(node.id.name);
}
if (parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier') {
return /^[A-Z]/.test(parent.id.name);
}
return false;
}
function isHookFunction(node) {
if (![
AST_NODE_TYPES.FunctionDeclaration,
AST_NODE_TYPES.ArrowFunctionExpression,
AST_NODE_TYPES.FunctionExpression,
].includes(node.type)) {
return false;
}
if (node.type === AST_NODE_TYPES.FunctionDeclaration && node.id) {
return (node.id.name.startsWith('use') &&
node.id.name.length > 3 &&
node.id.name[3] === node.id.name[3]?.toUpperCase());
}
if (node.parent?.type === AST_NODE_TYPES.VariableDeclarator &&
node.parent.id.type === AST_NODE_TYPES.Identifier) {
return (node.parent.id.name.startsWith('use') &&
node.parent.id.name.length > 3 &&
node.parent.id.name[3] === node.parent.id.name[3]?.toUpperCase());
}
return false;
}
const functionStack = [];
let inComponent = false;
let inHook = false;
let inEffect = false;
const ruleName = 'no-signal-creation-in-component';
export const noSignalCreationInComponentRule = ESLintUtils.RuleCreator((name) => {
return getRuleDocUrl(name);
})({
name: ruleName,
meta: {
type: 'problem',
fixable: 'code',
hasSuggestions: true,
docs: {
description: 'Prevent signal creation inside React components, hooks, or effects',
url: getRuleDocUrl(ruleName),
},
messages: {
avoidSignalInComponent: 'Avoid creating {{ signalType }} signals inside {{ context }}. Move signal creation to module level or a custom hook.',
suggestMoveToModuleLevel: 'Move {{ signalType }} signal to module level',
suggestMoveToCustomHook: 'Extract {{ signalType }} signal to a custom hook',
moveToModuleLevel: 'Move to module level',
createCustomHook: 'Create custom hook for {{ signalType }} signal',
},
schema: [
{
type: 'object',
properties: {
severity: {
type: 'object',
properties: {
avoidSignalInComponent: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
suggestMoveToModuleLevel: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
suggestMoveToCustomHook: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
moveToModuleLevel: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
createCustomHook: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
},
additionalProperties: false,
},
performance: {
type: 'object',
properties: {
maxTime: { type: 'number', minimum: 1 },
maxMemory: { type: 'number', minimum: 1 },
maxNodes: { type: 'number', minimum: 1 },
enableMetrics: { type: 'boolean' },
logMetrics: { type: 'boolean' },
maxOperations: {
type: 'object',
properties: Object.fromEntries(Object.entries(PerformanceOperations).map(([key]) => [
key,
{ type: 'number', minimum: 1 },
])),
},
},
additionalProperties: false,
},
},
additionalProperties: false,
},
],
},
defaultOptions: [
{
performance: DEFAULT_PERFORMANCE_BUDGET,
},
],
create(context, [option]) {
const perfKey = `${ruleName}:${context.filename}:${Date.now()}`;
startPhase(perfKey, 'ruleInit');
const perf = createPerformanceTracker(perfKey, option?.performance);
if (option?.performance?.enableMetrics === true) {
startTracking(context, perfKey, option.performance, ruleName);
}
if (option?.performance?.enableMetrics === true && option.performance.logMetrics === true) {
console.info(`${ruleName}: Initializing rule for file: ${context.filename}`);
console.info(`${ruleName}: Rule configuration:`, option);
}
recordMetric(perfKey, 'config', {
performance: {
enableMetrics: option?.performance?.enableMetrics,
logMetrics: option?.performance?.logMetrics,
},
});
trackOperation(perfKey, PerformanceOperations.ruleInit);
endPhase(perfKey, 'ruleInit');
let nodeCount = 0;
function shouldContinue() {
nodeCount++;
if (typeof option?.performance?.maxNodes === 'number' &&
nodeCount > option.performance.maxNodes) {
trackOperation(perfKey, PerformanceOperations.nodeBudgetExceeded);
return false;
}
return true;
}
startPhase(perfKey, 'ruleExecution');
// Track local names and namespaces for signal creators
const signalCreatorLocals = new Set(['signal', 'computed']);
const signalNamespaces = new Set();
return {
'*': (node) => {
if (!shouldContinue()) {
endPhase(perfKey, 'recordMetrics');
return;
}
perf.trackNode(node);
trackOperation(perfKey,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
PerformanceOperations[`${node.type}Processing`] ?? PerformanceOperations.nodeProcessing);
},
'FunctionDeclaration, ArrowFunctionExpression, FunctionExpression'(node) {
const parent = node.parent;
const isComponent = isReactComponent(node, parent);
const isHook = isHookFunction(node);
functionStack.push({ isComponent, isHook });
if (isComponent) {
inComponent = true;
}
else if (isHook) {
inHook = true;
}
},
'FunctionDeclaration > :not(FunctionDeclaration), ArrowFunctionExpression > :not(ArrowFunctionExpression), FunctionExpression > :not(FunctionExpression)'(_node) {
const state = functionStack.pop();
if (typeof state === 'undefined') {
return;
}
if (state.isComponent) {
inComponent = false;
}
else if (state.isHook) {
inHook = false;
}
},
[AST_NODE_TYPES.Program](node) {
for (const stmt of node.body) {
if (stmt.type === AST_NODE_TYPES.ImportDeclaration &&
typeof stmt.source.value === 'string' &&
stmt.source.value === '@preact/signals-react') {
for (const spec of stmt.specifiers) {
if (spec.type === AST_NODE_TYPES.ImportSpecifier) {
if ('name' in spec.imported &&
(spec.imported.name === 'signal' || spec.imported.name === 'computed')) {
signalCreatorLocals.add(spec.local.name);
}
}
else if (spec.type === AST_NODE_TYPES.ImportNamespaceSpecifier) {
signalNamespaces.add(spec.local.name);
}
}
}
}
},
[AST_NODE_TYPES.CallExpression](node) {
const wasInEffect = inEffect;
if ((node.callee.type === AST_NODE_TYPES.Identifier &&
['useEffect', 'useCallback', 'useMemo', 'useLayoutEffect'].includes(node.callee.name)) ||
(node.callee.type === AST_NODE_TYPES.MemberExpression &&
node.callee.property.type === AST_NODE_TYPES.Identifier &&
['useEffect', 'useCallback', 'useMemo', 'useLayoutEffect'].includes(node.callee.property.name))) {
inEffect = true;
}
function isSignalCreate() {
// identifier call: alias or bare
if (node.callee.type === AST_NODE_TYPES.Identifier) {
return signalCreatorLocals.has(node.callee.name);
}
// namespace call: ns.signal/ns.computed
if (node.callee.type === AST_NODE_TYPES.MemberExpression &&
node.callee.object.type === AST_NODE_TYPES.Identifier &&
signalNamespaces.has(node.callee.object.name) &&
node.callee.property.type === AST_NODE_TYPES.Identifier &&
(node.callee.property.name === 'signal' || node.callee.property.name === 'computed')) {
return true;
}
// fallback to original broad heuristic (member .signal/.computed)
if (node.callee.type === AST_NODE_TYPES.MemberExpression &&
node.callee.property.type === AST_NODE_TYPES.Identifier &&
(node.callee.property.name === 'signal' || node.callee.property.name === 'computed')) {
return true;
}
return false;
}
if (isSignalCreate() && (inComponent || inHook || wasInEffect)) {
const { signalName, signalValue, varName } = getSignalInfo(node, context.sourceCode);
const signalType = signalName === 'signal' ? 'reactive' : 'computed';
if (getSeverity('avoidSignalInComponent', option) !== 'off') {
context.report({
node,
messageId: 'avoidSignalInComponent',
data: {
signalName,
context: inComponent ? 'component' : inHook ? 'hook' : 'effect',
},
suggest: [
{
messageId: 'suggestMoveToModuleLevel',
data: { signalType },
*fix(fixer) {
const firstNode = context.sourceCode.ast.body[0];
const newLine = context.sourceCode.getText().includes('\r\n') ? '\r\n' : '\n';
if (typeof firstNode === 'undefined') {
return;
}
yield fixer.insertTextBefore(firstNode, `const ${varName} = ${signalName}(${signalValue});${newLine}${newLine}`);
yield fixer.replaceText(node, varName);
const comments = getLeadingCommentsText(node, context.sourceCode);
if (comments !== null) {
yield fixer.insertTextBefore(firstNode, comments.text + newLine);
yield fixer.removeRange(comments.range);
}
},
},
{
messageId: 'createCustomHook',
*fix(fixer) {
// eslint-disable-next-line n/no-unsupported-features/es-syntax
const lastImport = context.sourceCode.ast.body.findLast((node) => {
return node.type === 'ImportDeclaration';
});
const insertPosition = typeof lastImport === 'undefined' ? 0 : lastImport.range[1] + 1;
const hookName = `use${signalName.charAt(0).toUpperCase() + signalName.slice(1)}`;
const newLine = context.sourceCode.getText().includes('\r\n') ? '\r\n' : '\n';
yield fixer.insertTextAfterRange([insertPosition, insertPosition], `${newLine}function ${hookName}() {${newLine} return ${signalName}(${signalValue});${newLine}}${newLine}${newLine}`);
yield fixer.replaceText(node, `${hookName}()`);
},
},
{
messageId: 'suggestMoveToCustomHook',
data: { signalType },
*fix(fixer) {
const lastImport = context.sourceCode.ast.body
.slice()
.reverse()
.find((node) => {
return node.type === 'ImportDeclaration';
});
const insertPosition = lastImport ? lastImport.range[1] + 1 : 0;
const hookName = generateUniqueHookName(context, signalName === 'signal' ? 'value' : 'computedValue');
const newLine = context.sourceCode.getText().includes('\r\n') ? '\r\n' : '\n';
yield fixer.insertTextAfterRange([insertPosition, insertPosition], `${newLine}function ${hookName}() {${newLine} return ${signalName}(${signalValue});${newLine}}${newLine}${newLine}`);
yield fixer.replaceText(node, `${hookName}()`);
const comments = getLeadingCommentsText(node, context.sourceCode);
if (comments !== null) {
yield fixer.insertTextBeforeRange([insertPosition, insertPosition], comments.text + newLine);
yield fixer.removeRange(comments.range);
}
},
},
],
});
}
}
if ((node.callee.type === AST_NODE_TYPES.Identifier &&
['useEffect', 'useCallback', 'useMemo', 'useLayoutEffect'].includes(node.callee.name)) ||
(node.callee.type === AST_NODE_TYPES.MemberExpression &&
node.callee.property.type === AST_NODE_TYPES.Identifier &&
['useEffect', 'useCallback', 'useMemo', 'useLayoutEffect'].includes(node.callee.property.name))) {
inEffect = wasInEffect;
}
},
'ClassDeclaration, PropertyDefinition, MethodDefinition'() {
inComponent = true;
},
'ClassDeclaration > :not(ClassDeclaration)'() {
inComponent = false;
},
'MethodDefinition, PropertyDefinition'() {
if (inComponent) {
functionStack.push({ isComponent: true, isHook: false });
}
},
'MethodDefinition > :not(MethodDefinition), PropertyDefinition > :not(PropertyDefinition)'() {
if (inComponent) {
functionStack.pop();
}
},
[`${AST_NODE_TYPES.Program}:exit`]() {
startPhase(perfKey, 'programExit');
perf['Program:exit']();
endPhase(perfKey, 'programExit');
},
};
},
});
//# sourceMappingURL=no-signal-creation-in-component.js.map