@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
200 lines • 9.15 kB
JavaScript
import { ESLintUtils, AST_NODE_TYPES, } from '@typescript-eslint/utils';
import { ensureNamedImportFixes } from './utils/imports.js';
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 isReactComponent(node) {
// Check if this is a function declaration with PascalCase name
if (node.type === AST_NODE_TYPES.FunctionDeclaration) {
return typeof node.id?.name === 'undefined' ? false : /^[A-Z]/.test(node.id.name);
}
// Check if this is a variable declarator with PascalCase name assigned to a function
if (node.type === AST_NODE_TYPES.VariableDeclarator) {
if (node.id.type === AST_NODE_TYPES.Identifier &&
/^[A-Z]/.test(node.id.name) &&
node.init &&
(node.init.type === AST_NODE_TYPES.ArrowFunctionExpression ||
node.init.type === AST_NODE_TYPES.FunctionExpression)) {
return true;
}
}
return false;
}
function isInsideReactComponent(node, context) {
const ancestors = context.sourceCode.getAncestors(node);
for (let i = ancestors.length - 1; i >= 0; i--) {
// eslint-disable-next-line security/detect-object-injection
const ancestor = ancestors[i];
if (!ancestor) {
continue;
}
if (isReactComponent(ancestor)) {
return true;
}
}
return false;
}
function isEffectCall(node) {
if (node.callee.type === AST_NODE_TYPES.Identifier) {
return node.callee.name === 'effect';
}
if (node.callee.type === AST_NODE_TYPES.MemberExpression &&
!node.callee.computed &&
node.callee.property.type === AST_NODE_TYPES.Identifier) {
return node.callee.property.name === 'effect';
}
return false;
}
function hasUseSignalEffectImport(context) {
return context.sourceCode.ast.body.some((stmt) => {
return (stmt.type === AST_NODE_TYPES.ImportDeclaration &&
stmt.source.value === '@preact/signals-react' &&
stmt.specifiers.some((spec) => {
return (spec.type === AST_NODE_TYPES.ImportSpecifier &&
spec.imported.type === AST_NODE_TYPES.Identifier &&
spec.imported.name === 'useSignalEffect');
}));
});
}
const ruleName = 'prefer-use-signal-effect-in-react-component';
export const preferUseSignalEffectInReactComponentRule = ESLintUtils.RuleCreator((name) => {
return getRuleDocUrl(name);
})({
name: ruleName,
meta: {
type: 'suggestion',
docs: {
description: 'Enforces using `useSignalEffect()` instead of `effect()` inside React components to ensure proper lifecycle integration.',
url: getRuleDocUrl(ruleName),
},
fixable: 'code',
hasSuggestions: true,
messages: {
preferUseSignalEffectInComponent: 'Use `useSignalEffect()` instead of `effect()` inside React components to ensure proper lifecycle integration',
suggestUseSignalEffect: 'Replace `effect()` with `useSignalEffect()`',
addUseSignalEffectImport: 'Add `useSignalEffect` import from @preact/signals-react',
},
schema: [
{
type: 'object',
properties: {
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);
}
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');
return {
'*': (node) => {
if (!shouldContinue()) {
return;
}
perf.trackNode(node);
trackOperation(perfKey,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
PerformanceOperations[`${node.type}Processing`] ?? PerformanceOperations.nodeProcessing);
},
[AST_NODE_TYPES.CallExpression](node) {
if (!isEffectCall(node)) {
return;
}
if (!isInsideReactComponent(node, context)) {
return;
}
recordMetric(perfKey, 'effectCallsInComponents', 1);
const hasUseSignalEffect = hasUseSignalEffectImport(context);
context.report({
node,
messageId: 'preferUseSignalEffectInComponent',
fix(fixer) {
const fixes = [];
// Replace effect with useSignalEffect
if (node.callee.type === AST_NODE_TYPES.Identifier) {
fixes.push(fixer.replaceText(node.callee, 'useSignalEffect'));
}
else if (node.callee.type === AST_NODE_TYPES.MemberExpression &&
node.callee.property.type === AST_NODE_TYPES.Identifier) {
fixes.push(fixer.replaceText(node.callee.property, 'useSignalEffect'));
}
// Add useSignalEffect import if needed
if (!hasUseSignalEffect) {
const importFixes = ensureNamedImportFixes(context, fixer, '@preact/signals-react', 'useSignalEffect');
fixes.push(...importFixes);
}
return fixes;
},
suggest: [
{
messageId: 'suggestUseSignalEffect',
fix(fixer) {
const fixes = [];
// Replace effect with useSignalEffect
if (node.callee.type === AST_NODE_TYPES.Identifier) {
fixes.push(fixer.replaceText(node.callee, 'useSignalEffect'));
}
else if (node.callee.type === AST_NODE_TYPES.MemberExpression &&
node.callee.property.type === AST_NODE_TYPES.Identifier) {
fixes.push(fixer.replaceText(node.callee.property, 'useSignalEffect'));
}
// Add useSignalEffect import if needed
if (!hasUseSignalEffect) {
const importFixes = ensureNamedImportFixes(context, fixer, '@preact/signals-react', 'useSignalEffect');
fixes.push(...importFixes);
}
return fixes;
},
},
],
});
},
[`${AST_NODE_TYPES.Program}:exit`]: () => {
startPhase(perfKey, 'programExit');
perf['Program:exit']();
endPhase(perfKey, 'programExit');
},
};
},
});
//# sourceMappingURL=prefer-use-signal-effect-in-react-component.js.map