@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
568 lines • 30.6 kB
JavaScript
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 { buildSuffixRegex } from './utils/suffix.js';
import { getRuleDocUrl } from './utils/urls.js';
function getSeverity(messageId, option) {
if (!option?.severity) {
return 'error';
}
switch (messageId) {
case 'variableWithSignalSuffixNotSignal': {
return option.severity.variableWithSignalSuffixNotSignal ?? 'error';
}
case 'parameterWithSignalSuffixNotSignal': {
return option.severity.parameterWithSignalSuffixNotSignal ?? 'error';
}
case 'propertyWithSignalSuffixNotSignal': {
return option.severity.propertyWithSignalSuffixNotSignal ?? 'error';
}
case 'suggestRenameWithoutSuffix': {
return option.severity.suggestRenameWithoutSuffix ?? 'error';
}
case 'suggestConvertToSignal': {
return option.severity.suggestConvertToSignal ?? 'error';
}
default:
return 'error';
}
}
// moved into create() to avoid cross-file leakage
function isSignalCreation(node, perfKey, creatorNames, signalImports, signalLocalNames, signalsNamespaceImports) {
trackOperation(perfKey, PerformanceOperations.isSignalCreation);
if (node.type !== AST_NODE_TYPES.CallExpression) {
return false;
}
// Identifier callee: direct or locally aliased creator
if (node.callee.type === AST_NODE_TYPES.Identifier) {
const name = node.callee.name;
if (name === 'signal' ||
signalImports.has(name) ||
signalLocalNames.has(name) ||
creatorNames.has(name)) {
trackOperation(perfKey, PerformanceOperations.signalCreationFound);
return true;
}
}
// Namespaced call: e.g., Signals.signal(...)
if (node.callee.type === AST_NODE_TYPES.MemberExpression &&
!node.callee.computed &&
node.callee.object.type === AST_NODE_TYPES.Identifier &&
node.callee.property.type === AST_NODE_TYPES.Identifier) {
const ns = node.callee.object.name;
const prop = node.callee.property.name;
if (signalsNamespaceImports.has(ns) && (prop === 'signal' || creatorNames.has(prop))) {
trackOperation(perfKey, PerformanceOperations.signalCreationFound);
return true;
}
}
// Detect known signal hooks regardless of where they are imported from (supports re-exports)
if ('name' in node.callee &&
['useSignal', 'useComputed', 'useSignalEffect', 'useSignalState', 'useSignalRef'].includes(node.callee.name)) {
trackOperation(perfKey,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
PerformanceOperations[`signalHookFound:${node.callee.name}`] ?? PerformanceOperations.nodeProcessing);
return true;
}
// Member calls like SomeNamespace.useComputed(), accommodate re-exports and aliasing
if (node.callee.type === AST_NODE_TYPES.MemberExpression &&
!node.callee.computed &&
node.callee.property.type === AST_NODE_TYPES.Identifier &&
['useSignal', 'useComputed', 'useSignalEffect', 'useSignalState', 'useSignalRef'].includes(node.callee.property.name)) {
const hookName = node.callee.property.name;
trackOperation(perfKey,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
PerformanceOperations[`signalHookFound:${hookName}`] ?? PerformanceOperations.nodeProcessing);
return true;
}
return false;
}
function isSignalExpression(node, context, perfKey, creatorNames, signalImports, signalLocalNames, signalsNamespaceImports) {
if (node === null) {
return false;
}
if (isSignalCreation(node, perfKey, creatorNames, signalImports, signalLocalNames, signalsNamespaceImports)) {
return true;
}
if (node.type === AST_NODE_TYPES.Identifier) {
const variable = context.sourceCode.getScope(node).variables.find((v) => {
return v.name === node.name;
});
if (variable) {
return variable.defs.some((def) => {
if ('init' in def.node) {
return isSignalExpression(def.node.init, context, perfKey, creatorNames, signalImports, signalLocalNames, signalsNamespaceImports);
}
return false;
});
}
}
return false;
}
const ruleName = 'no-non-signal-with-signal-suffix';
export const noNonSignalWithSignalSuffixRule = ESLintUtils.RuleCreator((name) => {
return getRuleDocUrl(name);
})({
name: ruleName,
meta: {
type: 'problem',
fixable: 'code',
hasSuggestions: true,
docs: {
description: 'Enforce that variables with Signal suffix are actual signal instances',
url: getRuleDocUrl(ruleName),
},
schema: [
{
type: 'object',
properties: {
ignorePattern: {
type: 'string',
description: 'Pattern to ignore (regex as string)',
default: '',
},
signalNames: {
type: 'array',
items: {
type: 'string',
},
description: 'Additional creator names that should be considered as signals (e.g., createSignal)',
default: ['signal', 'useSignal', 'createSignal'],
},
suffix: {
type: 'string',
description: 'Suffix to detect (default: "Signal")',
default: 'Signal',
},
validateProperties: {
type: 'boolean',
description: 'Whether to validate object properties that end with the suffix',
default: true,
},
validateExported: {
type: 'boolean',
description: 'When true, also validate exported variables (by default exported names are skipped)',
default: false,
},
severity: {
type: 'object',
properties: {
variableWithSignalSuffixNotSignal: {
type: 'string',
enum: ['error', 'warn', 'off'],
default: 'error',
},
parameterWithSignalSuffixNotSignal: {
type: 'string',
enum: ['error', 'warn', 'off'],
default: 'error',
},
propertyWithSignalSuffixNotSignal: {
type: 'string',
enum: ['error', 'warn', 'off'],
default: 'error',
},
},
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,
},
],
messages: {
variableWithSignalSuffixNotSignal: "Variable '{{ name }}' has a signal-like suffix but is not a signal instance. Use a signal or rename to remove the suffix.",
parameterWithSignalSuffixNotSignal: "Parameter '{{ name }}' has a signal-like suffix but is not a signal instance.",
propertyWithSignalSuffixNotSignal: "Property '{{ name }}' has a signal-like suffix but is not a signal instance.",
suggestRenameWithoutSuffix: "Rename '{{ name }}' to '{{ newName }}' (remove suffix)",
suggestConvertToSignal: "Convert '{{ name }}' to a signal using signal() or useSignal()",
},
},
defaultOptions: [
{
ignorePattern: '',
signalNames: ['signal', 'useSignal', 'createSignal'],
suffix: 'Signal',
validateProperties: true,
validateExported: false,
severity: {
variableWithSignalSuffixNotSignal: 'error',
parameterWithSignalSuffixNotSignal: 'error',
propertyWithSignalSuffixNotSignal: 'error',
},
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');
// Per-file mutable state
const signalImports = new Set();
const signalLocalNames = new Set();
const signalsNamespaceImports = new Set();
const suffixRegex = buildSuffixRegex(typeof option?.suffix === 'string' && option.suffix.length > 0 ? option.suffix : 'Signal');
const creatorNames = new Set(option?.signalNames ?? ['signal', 'useSignal', 'createSignal']);
// Avoid touching identifiers within TypeScript type positions
function isInTypePosition(node) {
const ancestors = context.sourceCode.getAncestors(node);
return ancestors.some((a) => {
// All TS-specific AST node types start with 'TS'
// Additionally, type annotations wrap identifiers used only for types
// We conservatively skip if any TS node is in the ancestor chain
// to avoid renaming inside type positions.
return a.type.startsWith('TS') || a.type === AST_NODE_TYPES.TSTypeAnnotation;
});
}
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);
},
ImportDeclaration(node) {
startPhase(perfKey, 'import-declaration');
if (node.source.value === '@preact/signals-react') {
trackOperation(perfKey, PerformanceOperations.signalsImportFound);
node.specifiers.forEach((specifier) => {
if (specifier.type === AST_NODE_TYPES.ImportSpecifier &&
'name' in specifier.imported) {
// Track imported names and local aliases
signalImports.add(specifier.imported.name);
signalLocalNames.add(specifier.local.name);
}
if (specifier.type === AST_NODE_TYPES.ImportNamespaceSpecifier) {
signalsNamespaceImports.add(specifier.local.name);
}
});
}
endPhase(perfKey, 'import-declaration');
},
VariableDeclarator(node) {
startPhase(perfKey, 'variable-declarator');
trackOperation(perfKey, PerformanceOperations.variableCheck);
try {
if (node.id.type !== 'Identifier') {
endPhase(perfKey, 'variable-declarator');
return;
}
if (!suffixRegex.test(node.id.name)) {
endPhase(perfKey, 'variable-declarator');
return;
}
// Do not report if this identifier is inside a TS type position
if (isInTypePosition(node.id)) {
endPhase(perfKey, 'variable-declarator');
return;
}
if (typeof option?.ignorePattern !== 'undefined' &&
option.ignorePattern !== '' &&
// User provided pattern
// eslint-disable-next-line security/detect-non-literal-regexp
new RegExp(option.ignorePattern).test(node.id.name)) {
trackOperation(perfKey, PerformanceOperations.ignoredByPattern);
endPhase(perfKey, 'variable-declarator');
return;
}
if (node.init !== null &&
isSignalExpression(node.init, context, perfKey, creatorNames, signalImports, signalLocalNames, signalsNamespaceImports)) {
trackOperation(perfKey, PerformanceOperations.validSignalFound);
endPhase(perfKey, 'variable-declarator');
return;
}
const newName = node.id.name.replace(suffixRegex, '');
const messageId = 'variableWithSignalSuffixNotSignal';
// Skip exported/public API names
const parentDecl =
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
node.parent?.type === AST_NODE_TYPES.VariableDeclaration ? node.parent : null;
trackOperation(perfKey, PerformanceOperations.reportingIssue);
if ((option?.validateExported === true ||
(parentDecl?.parent &&
parentDecl.parent.type === AST_NODE_TYPES.ExportNamedDeclaration) !== true) &&
getSeverity(messageId, option) !== 'off') {
context.report({
node: node.id,
messageId,
data: { name: node.id.name },
suggest: [
{
messageId: 'suggestRenameWithoutSuffix',
data: {
name: node.id.name,
newName,
},
fix(fixer) {
return fixer.replaceText(node.id, newName);
},
},
// Convert to signal using existing named import
...(parentDecl?.kind === 'const' &&
Array.isArray(parentDecl.declarations) &&
parentDecl.declarations.length === 1 &&
signalImports.has('signal') &&
signalLocalNames.has('signal')
? [
{
messageId: 'suggestConvertToSignal',
data: { name: node.id.name },
fix(fixer) {
if ('name' in node.id) {
const initText = node.init
? context.sourceCode.getText(node.init)
: 'null';
if (node.init) {
return fixer.replaceTextRange(node.init.range, `signal(${initText})`);
}
const insertPos =
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
node.id.type === AST_NODE_TYPES.Identifier && node.id.typeAnnotation
? node.id.typeAnnotation.range[1]
: node.id.range[1];
return fixer.insertTextAfterRange([insertPos, insertPos], ` = signal(${initText})`);
}
return null;
},
},
]
: []),
// Insert named import for signal if missing, then convert
...(parentDecl?.kind === 'const' &&
Array.isArray(parentDecl.declarations) &&
parentDecl.declarations.length === 1 &&
!(signalImports.has('signal') && signalLocalNames.has('signal'))
? [
{
messageId: 'suggestConvertToSignal',
data: { name: node.id.name },
fix(fixer) {
const fixes = [];
const initText = node.init
? context.sourceCode.getText(node.init)
: 'null';
const firstImport = context.sourceCode.ast.body.find((s) => s.type === AST_NODE_TYPES.ImportDeclaration);
const importText = "import { signal } from '@preact/signals-react';\n";
if (firstImport) {
fixes.push(fixer.insertTextBeforeRange(firstImport.range, importText));
}
else {
fixes.push(fixer.insertTextBeforeRange([0, 0], importText));
}
if (node.init) {
fixes.push(fixer.replaceTextRange(node.init.range, `signal(${initText})`));
}
else {
const insertPos = node.id.type === AST_NODE_TYPES.Identifier && node.id.typeAnnotation
? node.id.typeAnnotation.range[1]
: node.id.range[1];
fixes.push(fixer.insertTextAfterRange([insertPos, insertPos], ` = signal(${initText})`));
}
return fixes;
},
},
]
: []),
],
});
}
}
finally {
endPhase(perfKey, 'variable-declarator');
}
},
'FunctionDeclaration, FunctionExpression, ArrowFunctionExpression'(node) {
startPhase(perfKey, 'function-declaration');
trackOperation(perfKey, PerformanceOperations.parameterCheck);
try {
if (!('params' in node) || !Array.isArray(node.params)) {
endPhase(perfKey, 'function-declaration');
return;
}
node.params.forEach((param) => {
if (!(param.type === AST_NODE_TYPES.Identifier && suffixRegex.test(param.name))) {
return;
}
// Skip parameters used in type-only positions (defensive, though params are values)
if (isInTypePosition(param)) {
return;
}
if (typeof option?.ignorePattern !== 'undefined' &&
option.ignorePattern !== '' &&
// User provided pattern
// eslint-disable-next-line security/detect-non-literal-regexp
new RegExp(option.ignorePattern).test(param.name)) {
trackOperation(perfKey, PerformanceOperations.ignoredByPattern);
return;
}
const messageId = 'parameterWithSignalSuffixNotSignal';
const newName = param.name.replace(suffixRegex, '');
trackOperation(perfKey, PerformanceOperations.reportingIssue);
if (getSeverity(messageId, option) !== 'off') {
context.report({
node: param,
messageId,
data: { name: param.name },
suggest: [
{
messageId: 'suggestRenameWithoutSuffix',
data: {
name: param.name,
newName,
},
fix(fixer) {
const fixes = [];
// Rename the parameter identifier itself
fixes.push(fixer.replaceText(param, newName));
const variable = context.sourceCode
.getDeclaredVariables(node)
.find((v) => {
return v.defs.some((d) => {
return (d.name.range[0] === param.range[0] &&
d.name.range[1] === param.range[1]);
});
});
if (typeof variable !== 'undefined') {
for (const ref of variable.references) {
// Skip the definition identifier (already handled) and any TS type positions
if ((ref.identifier.range[0] === param.range[0] &&
ref.identifier.range[1] === param.range[1]) ||
isInTypePosition(ref.identifier)) {
continue;
}
fixes.push(fixer.replaceText(ref.identifier, newName));
}
}
return fixes;
},
},
],
});
}
});
}
finally {
endPhase(perfKey, 'function-declaration');
}
},
[AST_NODE_TYPES.Property](node) {
startPhase(perfKey, 'property');
trackOperation(perfKey, PerformanceOperations.propertyCheck);
try {
if (option?.validateProperties !== true) {
endPhase(perfKey, 'property');
return;
}
if (node.key.type === AST_NODE_TYPES.Identifier &&
suffixRegex.test(node.key.name) &&
!node.computed) {
// Avoid touching properties within TS type literals (should be TSPropertySignature, but be safe)
if (isInTypePosition(node)) {
endPhase(perfKey, 'property');
return;
}
if (node.shorthand &&
node.value.type === AST_NODE_TYPES.Identifier &&
isSignalExpression(node.value, context, perfKey, creatorNames, signalImports, signalLocalNames, signalsNamespaceImports)) {
trackOperation(perfKey, PerformanceOperations.validSignalFound);
endPhase(perfKey, 'property');
return;
}
if (typeof option.ignorePattern !== 'undefined' &&
option.ignorePattern !== '' &&
// User provided pattern
// eslint-disable-next-line security/detect-non-literal-regexp
new RegExp(option.ignorePattern).test(node.key.name)) {
trackOperation(perfKey, PerformanceOperations.ignoredByPattern);
endPhase(perfKey, 'property');
return;
}
if (isSignalExpression(node.value, context, perfKey, creatorNames, signalImports, signalLocalNames, signalsNamespaceImports)) {
trackOperation(perfKey, PerformanceOperations.validSignalFound);
endPhase(perfKey, 'property');
return;
}
const messageId = 'propertyWithSignalSuffixNotSignal';
const newName = node.key.name.replace(suffixRegex, '');
trackOperation(perfKey, PerformanceOperations.reportingIssue);
if (getSeverity(messageId, option) !== 'off') {
context.report({
node: node.key,
messageId,
data: { name: node.key.name },
suggest: [
{
messageId: 'suggestRenameWithoutSuffix',
data: {
name: node.key.name,
newName,
},
fix(fixer) {
return fixer.replaceText(node.key, newName);
},
},
],
});
}
}
}
finally {
endPhase(perfKey, 'property');
}
},
[`${AST_NODE_TYPES.Program}:exit`]() {
startPhase(perfKey, 'programExit');
perf['Program:exit']();
endPhase(perfKey, 'programExit');
},
};
},
});
//# sourceMappingURL=no-non-signal-with-signal-suffix.js.map