@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
471 lines • 27.6 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 { buildSuffixRegex, hasSignalSuffix } from './utils/suffix.js';
import { getRuleDocUrl } from './utils/urls.js';
function getSeverity(messageId, options) {
if (!options?.severity) {
return 'error';
}
switch (messageId) {
case 'preferComputedWithSignal': {
return options.severity.preferComputedWithSignal ?? 'error';
}
case 'preferComputedWithSignals': {
return options.severity.preferComputedWithSignals ?? 'error';
}
case 'suggestComputed': {
return options.severity.suggestComputed ?? 'error';
}
case 'addComputedImport': {
return options.severity.addComputedImport ?? 'error';
}
case 'suggestAddComputedImport': {
return options.severity.suggestAddComputedImport ?? 'error';
}
default: {
return 'error';
}
}
}
function getSignalDependencyInfo(dep, suffixRegex) {
if (dep === null) {
return null;
}
if (dep.type === AST_NODE_TYPES.MemberExpression &&
dep.property.type === AST_NODE_TYPES.Identifier &&
dep.property.name === 'value' &&
dep.object.type === AST_NODE_TYPES.Identifier &&
hasSignalSuffix(dep.object.name, suffixRegex)) {
return {
signalName: dep.object.name,
isDirectAccess: false,
node: dep,
};
}
if (dep.type === AST_NODE_TYPES.Identifier && hasSignalSuffix(dep.name, suffixRegex)) {
return {
signalName: dep.name,
isDirectAccess: true,
node: dep,
};
}
return null;
}
let hasComputedImport = false;
let program = null;
const ruleName = 'prefer-computed';
export const preferComputedRule = ESLintUtils.RuleCreator((name) => {
return getRuleDocUrl(name);
})({
name: ruleName,
meta: {
type: 'suggestion',
docs: {
description: 'Encourages using `computed()` from @preact/signals-react instead of `useMemo` when working with signals. This provides better performance through automatic dependency tracking and more predictable reactivity behavior in React components.',
url: getRuleDocUrl(ruleName),
},
fixable: 'code',
hasSuggestions: true,
messages: {
preferComputedWithSignal: 'Prefer `computed()` over `useMemo` when using signal "{{ signalName }}" for better performance and automatic reactivity.',
preferComputedWithSignals: 'Prefer `computed()` over `useMemo` when using signals ({{ signalNames }}) for better performance and automatic reactivity.',
suggestComputed: 'Replace `useMemo` with `computed()`',
addComputedImport: 'Add `computed` import from @preact/signals-react',
suggestAddComputedImport: 'Add missing import for `computed`',
},
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,
},
severity: {
type: 'object',
properties: {
preferComputedWithSignal: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
preferComputedWithSignals: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
suggestComputed: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
addComputedImport: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
suggestAddComputedImport: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
},
additionalProperties: false,
},
suffix: { type: 'string', minLength: 1 },
extraCreatorModules: {
type: 'array',
items: { type: 'string', minLength: 1 },
},
rename: { type: 'boolean' },
accessors: {
type: 'object',
properties: {
jsx: { type: 'string', enum: ['auto', 'value', 'none'] },
inComponent: { type: 'string', enum: ['value', 'peek'] },
outsideComponent: { type: 'string', enum: ['peek', 'value'] },
},
additionalProperties: false,
},
},
additionalProperties: false,
},
],
},
defaultOptions: [
{
performance: DEFAULT_PERFORMANCE_BUDGET,
rename: true,
accessors: {
jsx: 'auto',
inComponent: 'value',
outsideComponent: 'peek',
},
},
],
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;
}
const suffix = typeof option?.suffix === 'string' && option.suffix.length > 0 ? option.suffix : 'Signal';
const suffixRegex = buildSuffixRegex(suffix);
startPhase(perfKey, 'ruleExecution');
const creatorModules = new Set([
'@preact/signals-react',
...(Array.isArray(option?.extraCreatorModules) ? option.extraCreatorModules : []),
]);
return {
'*': (node) => {
if (!shouldContinue()) {
endPhase(perfKey, 'recordMetrics');
return;
}
perf.trackNode(node);
const op =
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
PerformanceOperations[`${node.type}Processing`] ?? PerformanceOperations.nodeProcessing;
trackOperation(perfKey, op);
},
[AST_NODE_TYPES.Program](node) {
startPhase(perfKey, 'program-analysis');
program = node;
hasComputedImport = program.body.some((n) => {
trackOperation(perfKey, PerformanceOperations.importCheck);
return (n.type === AST_NODE_TYPES.ImportDeclaration &&
typeof n.source.value === 'string' &&
creatorModules.has(n.source.value) &&
n.specifiers.some((s) => {
return (s.type === AST_NODE_TYPES.ImportSpecifier &&
'name' in s.imported &&
s.imported.name === 'computed');
}));
});
endPhase(perfKey, 'program-analysis');
},
[AST_NODE_TYPES.CallExpression](node) {
recordMetric(perfKey, 'useMemoCallsAnalyzed', 1);
trackOperation(perfKey, PerformanceOperations.callExpressionCheck);
let depth = 0;
let parent = node.parent;
while (parent) {
if (parent.type === AST_NODE_TYPES.CallExpression)
depth++;
parent = parent.parent;
}
recordMetric(perfKey, 'currentCallDepth', depth);
const isUseMemoCall = (() => {
if (node.callee.type === AST_NODE_TYPES.Identifier) {
if (node.callee.name === 'useMemo') {
return true;
}
const variable = context.sourceCode
.getScope(node)
.variables.find((v) => {
return 'name' in node.callee && v.name === node.callee.name;
});
if (typeof variable !== 'undefined') {
return variable.defs.some((def) => {
if (def.type !== 'ImportBinding') {
return false;
}
// Guard imported name access
if ('imported' in def.node && 'name' in def.node.imported) {
return def.node.imported.name === 'useMemo';
}
return false;
});
}
return false;
}
if (node.callee.type === AST_NODE_TYPES.MemberExpression &&
node.callee.property.type === AST_NODE_TYPES.Identifier) {
// React.useMemo or aliased namespace
return node.callee.property.name === 'useMemo';
}
return false;
})();
if (!isUseMemoCall ||
node.arguments.length !== 2 ||
node.arguments[1]?.type !== AST_NODE_TYPES.ArrayExpression) {
return;
}
startPhase(perfKey, 'signal-analysis');
const signalDeps = [];
for (const dep of node.arguments[1].elements) {
trackOperation(perfKey, PerformanceOperations.dependencyCheck);
const depInfo = getSignalDependencyInfo(dep, suffixRegex);
if (depInfo) {
signalDeps.push(depInfo);
recordMetric(perfKey, 'totalSignalDependencies', signalDeps.length);
}
}
if (signalDeps.length === 0) {
endPhase(perfKey, 'signal-analysis');
return;
}
recordMetric(perfKey, 'useMemoCallsWithSignals', 1);
const uniqueSignalNames = [...new Set(signalDeps.map((s) => s.signalName))];
const hasMultipleSignals = uniqueSignalNames.length > 1;
recordMetric(perfKey, 'uniqueSignalsPerUseMemo', uniqueSignalNames.length);
if (hasMultipleSignals) {
recordMetric(perfKey, 'useMemoWithMultipleSignals', 1);
}
const suggestionType = hasMultipleSignals ? 'multipleSignals' : 'singleSignal';
recordMetric(perfKey, `suggestions.${suggestionType}`, 1);
trackOperation(perfKey, PerformanceOperations.reportGeneration);
const messageId = signalDeps.length === 1 ? 'preferComputedWithSignal' : 'preferComputedWithSignals';
if (getSeverity(messageId, option) !== 'off') {
context.report({
node,
messageId,
data: {
signalName: uniqueSignalNames[0],
signalNames: uniqueSignalNames.join(', '),
},
suggest: [
{
messageId: 'suggestComputed',
*fix(fixer) {
const callback = node.arguments[0];
if (typeof callback === 'undefined') {
return;
}
yield fixer.replaceText(node, `computed(${context.sourceCode.getText(callback)})`);
// Also optionally rename the capturing variable to have Signal suffix and fix all references with correct accessors
// Find the VariableDeclarator that initializes with this call
let decl = null;
for (const anc of context.sourceCode.getAncestors(node)) {
if (anc.type === AST_NODE_TYPES.VariableDeclarator && anc.init === node) {
decl = anc;
break;
}
}
if (option?.rename !== false &&
decl &&
decl.id.type === AST_NODE_TYPES.Identifier) {
const originalName = decl.id.name;
// Build the fixed name similar to signal-variable-name rule
let fixedName = originalName;
if (fixedName.startsWith('use') && fixedName.length > 3) {
fixedName = fixedName.slice(3);
}
if (fixedName.length > 0) {
fixedName = fixedName.charAt(0).toLowerCase() + fixedName.slice(1);
}
if (!hasSignalSuffix(fixedName, suffixRegex)) {
fixedName += suffix;
}
if (fixedName !== originalName) {
// Avoid name collision in current scope
const declScope = context.sourceCode.getScope(decl);
if (!declScope.set.has(fixedName)) {
yield fixer.replaceText(decl.id, fixedName);
const variable = declScope.set.get(originalName);
if (variable) {
for (const reference of variable.references) {
const ref = reference.identifier;
// Skip the declarator id itself
if (ref.range[0] === decl.id.range[0] &&
ref.range[1] === decl.id.range[1]) {
continue;
}
// Skip if used as property name in MemberExpression foo.bar
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
ref.parent?.type === AST_NODE_TYPES.MemberExpression &&
ref.parent.property === ref &&
!ref.parent.computed) {
continue;
}
const ancestors = context.sourceCode.getAncestors(ref);
const isJsx = ancestors.some((a) => {
return (a.type === AST_NODE_TYPES.JSXElement ||
a.type === AST_NODE_TYPES.JSXFragment ||
a.type === AST_NODE_TYPES.JSXAttribute ||
a.type === AST_NODE_TYPES.JSXExpressionContainer ||
a.type === AST_NODE_TYPES.JSXSpreadAttribute);
});
// In JSX attribute context? (either directly under JSXAttribute, or inside its expression container)
const inJsxAttribute = ancestors.some((a, idx) => {
if (a.type === AST_NODE_TYPES.JSXAttribute) {
return true;
}
if (a.type === AST_NODE_TYPES.JSXExpressionContainer &&
idx > 0 &&
ancestors[idx - 1]?.type === AST_NODE_TYPES.JSXAttribute) {
return true;
}
return false;
});
// Determine if inside a component/hook function
let inComponentScope = false;
for (let i = ancestors.length - 1; i >= 0; i--) {
// eslint-disable-next-line security/detect-object-injection
const anc = ancestors[i];
if (!anc)
continue;
if (anc.type === AST_NODE_TYPES.FunctionDeclaration) {
if (anc.id && /^[A-Z]/.test(anc.id.name)) {
inComponentScope = true;
}
break;
}
if (anc.type === AST_NODE_TYPES.FunctionExpression ||
anc.type === AST_NODE_TYPES.ArrowFunctionExpression) {
// Look for enclosing variable declarator with Uppercase name
const vd = ancestors.find((x) => {
return x.type === AST_NODE_TYPES.VariableDeclarator;
});
if (vd &&
vd.id.type === AST_NODE_TYPES.Identifier &&
/^[A-Z]/.test(vd.id.name)) {
inComponentScope = true;
}
break;
}
}
// If identifier is inside a CallExpression argument in JSX, treat as argument usage and require .value
const isInJsxCallArg = isJsx &&
ancestors.some((a) => {
if (a.type !== AST_NODE_TYPES.CallExpression) {
return false;
}
// If identifier is within callee, it's not an argument
if (
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
a.callee &&
ref.range[0] >= a.callee.range[0] &&
ref.range[1] <= a.callee.range[1]) {
return false;
}
// Identifier lies within one of the arguments' ranges
return a.arguments.some((arg) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/strict-boolean-expressions
if (!arg) {
return false;
}
return (ref.range[0] >= arg.range[0] && ref.range[1] <= arg.range[1]);
});
});
const jsxStrategy = option?.accessors?.jsx ?? 'auto';
const inCompPref = option?.accessors?.inComponent ?? 'value';
const outCompPref = option?.accessors?.outsideComponent ?? 'peek';
let accessor = '';
if (inJsxAttribute || isInJsxCallArg) {
accessor = '.value';
}
else if (isJsx) {
accessor = jsxStrategy === 'value' ? '.value' : '';
}
else if (inComponentScope) {
accessor = inCompPref === 'value' ? '.value' : '.peek()';
}
else {
accessor = outCompPref === 'peek' ? '.peek()' : '.value';
}
yield fixer.replaceText(ref, `${fixedName}${accessor}`);
}
}
}
}
}
if (getSeverity('suggestAddComputedImport', option) === 'off') {
return;
}
if (!hasComputedImport) {
const fixes = ensureNamedImportFixes(context, fixer, '@preact/signals-react', 'computed');
for (const f of fixes) {
yield f;
}
}
},
},
],
});
}
},
[`${AST_NODE_TYPES.Program}:exit`]: () => {
startPhase(perfKey, 'programExit');
perf['Program:exit']();
endPhase(perfKey, 'programExit');
},
};
},
});
//# sourceMappingURL=prefer-computed.js.map