@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
604 lines • 34.4 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 getSeverity(messageId, options) {
if (!options?.severity) {
return 'error';
}
switch (messageId) {
case 'preferUseSignal': {
return options.severity.preferUseSignal ?? 'error';
}
case 'addUseSignalImport': {
return options.severity.addUseSignalImport ?? 'error';
}
case 'convertToUseSignal': {
return options.severity.convertToUseSignal ?? 'error';
}
default: {
return 'error';
}
}
}
const ruleName = 'prefer-use-signal-over-use-state';
export const preferUseSignalOverUseStateRule = ESLintUtils.RuleCreator((name) => {
return getRuleDocUrl(name);
})({
name: ruleName,
meta: {
type: 'suggestion',
hasSuggestions: true,
docs: {
description: "Encourages using `useSignal` instead of `useState` for primitive values and simple initializers. `useSignal` often provides better performance and ergonomics for local component state that doesn't require the full React reconciliation cycle. This rule helps migrate simple state management to signals while still allowing complex state to use `useState` when needed.",
url: getRuleDocUrl(ruleName),
},
messages: {
preferUseSignal: 'Prefer useSignal over useState for {{type}} values',
addUseSignalImport: "Add `useSignal` import from '@preact/signals-react'",
convertToUseSignal: 'Convert this useState to useSignal',
},
schema: [
{
type: 'object',
properties: {
ignoreComplexInitializers: {
type: 'boolean',
default: true,
description: 'Skip non-primitive initializers',
},
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: {
preferUseSignal: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
addUseSignalImport: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
convertToUseSignal: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
},
additionalProperties: false,
},
suffix: { type: 'string', minLength: 1 },
extraCreatorModules: {
type: 'array',
items: { type: 'string', minLength: 1 },
},
},
additionalProperties: false,
},
],
fixable: 'code',
},
defaultOptions: [
{
ignoreComplexInitializers: true,
performance: DEFAULT_PERFORMANCE_BUDGET,
severity: {
preferUseSignal: 'error',
addUseSignalImport: 'error',
convertToUseSignal: 'error',
},
suffix: 'Signal',
},
],
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;
const useStateLocalNames = new Set(['useState']);
const reactNamespaces = new Set();
// Track whether we're inside a React component or custom hook body using a depth counter.
let componentOrHookDepth = 0;
const markedComponentOrHookFns = new WeakSet();
function shouldContinue() {
nodeCount++;
if (typeof option?.performance?.maxNodes === 'number' &&
nodeCount > option.performance.maxNodes) {
trackOperation(perfKey, PerformanceOperations.nodeBudgetExceeded);
return false;
}
return true;
}
startPhase(perfKey, 'ruleExecution');
// Support importing creators from multiple modules
const creatorModules = new Set([
'@preact/signals-react',
...(Array.isArray(option?.extraCreatorModules) ? option.extraCreatorModules : []),
]);
function hasUseSignalImportFromAny() {
return context.sourceCode.ast.body.some((n) => {
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 &&
s.imported.type === AST_NODE_TYPES.Identifier &&
s.imported.name === 'useSignal');
}));
});
}
function ensureUseSignalImportAny(fixer, fixes, context) {
if (hasUseSignalImportFromAny()) {
return;
}
const importDeclarations = context.sourceCode.ast.body.filter((n) => {
return n.type === AST_NODE_TYPES.ImportDeclaration;
});
const existingCreatorImport = importDeclarations.find((d) => {
return typeof d.source.value === 'string' && creatorModules.has(d.source.value);
});
if (existingCreatorImport && typeof existingCreatorImport.source.value === 'string') {
const helperFixes = ensureNamedImportFixes({ sourceCode: context.sourceCode }, fixer, existingCreatorImport.source.value, 'useSignal');
for (const f of helperFixes) {
fixes.push(f);
}
return;
}
const helperFixes = ensureNamedImportFixes({ sourceCode: context.sourceCode }, fixer, '@preact/signals-react', 'useSignal');
for (const f of helperFixes) {
fixes.push(f);
}
}
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);
},
[AST_NODE_TYPES.Program](node) {
for (const stmt of node.body) {
if (stmt.type === AST_NODE_TYPES.ImportDeclaration && stmt.source.value === 'react') {
for (const spec of stmt.specifiers) {
if (spec.type === AST_NODE_TYPES.ImportSpecifier &&
spec.imported.type === AST_NODE_TYPES.Identifier &&
spec.imported.name === 'useState') {
useStateLocalNames.add(spec.local.name);
}
else if (spec.type === AST_NODE_TYPES.ImportNamespaceSpecifier ||
spec.type === AST_NODE_TYPES.ImportDefaultSpecifier) {
reactNamespaces.add(spec.local.name);
}
}
}
}
},
[AST_NODE_TYPES.FunctionDeclaration](node) {
if (node.id && (/^[A-Z]/.test(node.id.name) || /^use[A-Z]/.test(node.id.name))) {
markedComponentOrHookFns.add(node);
componentOrHookDepth++;
}
},
[`${AST_NODE_TYPES.FunctionDeclaration}:exit`](node) {
if (markedComponentOrHookFns.has(node)) {
componentOrHookDepth--;
}
},
// Handle components/hooks declared as: const Name = () => {} or function expressions
[AST_NODE_TYPES.FunctionExpression](node) {
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
node.parent !== null &&
typeof node.parent !== 'undefined' &&
node.parent.type === AST_NODE_TYPES.VariableDeclarator &&
node.parent.id.type === AST_NODE_TYPES.Identifier &&
(/^[A-Z]/.test(node.parent.id.name) || /^use[A-Z]/.test(node.parent.id.name))) {
markedComponentOrHookFns.add(node);
componentOrHookDepth++;
}
},
[`${AST_NODE_TYPES.FunctionExpression}:exit`](node) {
if (markedComponentOrHookFns.has(node)) {
componentOrHookDepth--;
}
},
[AST_NODE_TYPES.ArrowFunctionExpression](node) {
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
node.parent !== null &&
typeof node.parent !== 'undefined' &&
node.parent.type === AST_NODE_TYPES.VariableDeclarator &&
node.parent.id.type === AST_NODE_TYPES.Identifier &&
(/^[A-Z]/.test(node.parent.id.name) || /^use[A-Z]/.test(node.parent.id.name))) {
markedComponentOrHookFns.add(node);
componentOrHookDepth++;
}
},
[`${AST_NODE_TYPES.ArrowFunctionExpression}:exit`](node) {
if (markedComponentOrHookFns.has(node)) {
componentOrHookDepth--;
}
},
[AST_NODE_TYPES.VariableDeclarator](node) {
const inComponentOrHook = componentOrHookDepth > 0;
if (!(node.init?.type === AST_NODE_TYPES.CallExpression &&
((node.init.callee.type === AST_NODE_TYPES.Identifier &&
useStateLocalNames.has(node.init.callee.name)) ||
(node.init.callee.type === AST_NODE_TYPES.MemberExpression &&
node.init.callee.object.type === AST_NODE_TYPES.Identifier &&
reactNamespaces.has(node.init.callee.object.name) &&
node.init.callee.property.type === AST_NODE_TYPES.Identifier &&
node.init.callee.property.name === 'useState')) &&
node.id.type === AST_NODE_TYPES.ArrayPattern &&
node.id.elements.length === 2) ||
!inComponentOrHook ||
// If ignoring complex initializers (default), only allow simple initializer node types
(context.options[0]?.ignoreComplexInitializers !== false &&
typeof node.init.arguments[0] === 'undefined'
? false
: ![
AST_NODE_TYPES.Literal,
AST_NODE_TYPES.Identifier,
AST_NODE_TYPES.MemberExpression,
AST_NODE_TYPES.UnaryExpression,
AST_NODE_TYPES.BinaryExpression,
AST_NODE_TYPES.ConditionalExpression,
AST_NODE_TYPES.TemplateLiteral,
].includes(node.init.arguments[0]?.type ?? ''))) {
return;
}
const [stateVar, setterVar] = node.id.elements;
const initialValue = node.init.arguments[0];
if (!(stateVar?.type === AST_NODE_TYPES.Identifier &&
setterVar?.type === AST_NODE_TYPES.Identifier &&
setterVar.name.startsWith('set'))) {
return;
}
if (getSeverity('preferUseSignal', option) === 'off') {
return;
}
const suffix = typeof option?.suffix === 'string' && option.suffix.length > 0 ? option.suffix : 'Signal';
const suggestions = [];
// Suggestion 1: add import only (non-destructive)
suggestions.push({
messageId: 'addUseSignalImport',
fix(fixer) {
const fixes = [];
if (hasUseSignalImportFromAny()) {
return null;
}
ensureUseSignalImportAny(fixer, fixes, context);
return fixes.length > 0 ? fixes : null;
},
});
if ((typeof initialValue === 'undefined' ||
initialValue.type === AST_NODE_TYPES.Literal ||
initialValue.type === AST_NODE_TYPES.Identifier) &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
stateVar.type === AST_NODE_TYPES.Identifier) {
suggestions.push({
messageId: 'convertToUseSignal',
fix(fixer) {
const fixes = [];
// Ensure `useSignal` import is present for this conversion across any creator module
ensureUseSignalImportAny(fixer, fixes, context);
// Track used ranges to avoid overlapping fixes within the same suggestion
const usedRanges = [];
function overlaps(a, b) {
return a[0] < b[1] && a[1] > b[0];
}
function tryPushReplace(range, text) {
// Skip if this range overlaps any previously added fix
for (const r of usedRanges) {
if (overlaps(range, r)) {
return;
}
}
usedRanges.push(range);
fixes.push(fixer.replaceTextRange(range, text));
}
const initText = initialValue
? context.sourceCode.getText(initialValue)
: 'undefined';
const typeParamsNode = (node.init &&
('typeParameters' in node.init
? node.init.typeParameters
: 'typeArguments' in node.init
? node.init.typeArguments
: null)) ??
null;
const typeParamsText = typeParamsNode
? context.sourceCode.getText(typeParamsNode)
: '';
if (
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
node.parent &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
node.parent.type === AST_NODE_TYPES.VariableDeclaration &&
node.parent.declarations.length === 1) {
// Compute a collision-safe signal variable name
const baseSignalName = `${stateVar.name}${suffix}`;
const hasName = (n) => {
return context.sourceCode
.getScope(node)
.variables.some((v) => v.name === n);
};
let signalName = baseSignalName;
let i = 1;
while (hasName(signalName)) {
signalName = `${baseSignalName}${++i}`;
}
fixes.push(fixer.replaceText(node.parent, `const ${signalName} = useSignal${typeParamsText}(${initText})`));
}
else {
// Compute a collision-safe signal variable name
const baseSignalName = `${stateVar.name}${suffix}`;
const hasName = (n) => {
return context.sourceCode
.getScope(node)
.variables.some((v) => {
return v.name === n;
});
};
let signalName = baseSignalName;
let i = 1;
while (hasName(signalName)) {
signalName = `${baseSignalName}${++i}`;
}
tryPushReplace([node.range[0], node.range[1]], `${signalName} = useSignal${typeParamsText}(${initText})`);
}
const variable = context.sourceCode
.getScope(node)
.variables.find((v) => {
return v.name === stateVar.name;
});
if (typeof variable !== 'undefined') {
// Recompute collision-safe signalName for reference replacements in this scope
const baseSignalName = `${stateVar.name}${suffix}`;
function hasName(n) {
return context.sourceCode
.getScope(node)
.variables.some((v) => v.name === n);
}
let signalName = baseSignalName;
let i = 1;
while (hasName(signalName)) {
signalName = `${baseSignalName}${++i}`;
}
for (const ref of variable.references) {
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/strict-boolean-expressions
!ref.identifier ||
ref.identifier.range[0] === stateVar.range[0]) {
continue;
}
const ancestors = context.sourceCode.getAncestors(ref.identifier);
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);
});
// 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 (typeof anc === 'undefined') {
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 (typeof vd !== 'undefined' &&
vd.id.type === AST_NODE_TYPES.Identifier &&
/^[A-Z]/.test(vd.id.name)) {
inComponentScope = true;
}
break;
}
}
tryPushReplace([ref.identifier.range[0], ref.identifier.range[1]], `${signalName}${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;
}) ||
(isJsx &&
ancestors.some((a) => {
if (a.type !== AST_NODE_TYPES.CallExpression) {
return false;
}
if (
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
a.callee &&
ref.identifier.range[0] >= a.callee.range[0] &&
ref.identifier.range[1] <= a.callee.range[1]) {
return false;
}
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.identifier.range[0] >= arg.range[0] &&
ref.identifier.range[1] <= arg.range[1]);
});
})) ||
(isJsx &&
ancestors.some((a) => {
if (a.type === AST_NODE_TYPES.BinaryExpression) {
return (a.operator === '+' ||
a.operator === '-' ||
a.operator === '*' ||
a.operator === '/' ||
a.operator === '%' ||
a.operator === '**');
}
if (a.type === AST_NODE_TYPES.UnaryExpression) {
return a.operator === '+' || a.operator === '-';
}
if (a.type === AST_NODE_TYPES.UpdateExpression) {
return true; // ++x, --x, x++, x--
}
if (a.type === AST_NODE_TYPES.AssignmentExpression) {
return ['+=', '-=', '*=', '/=', '%=', '**='].includes(a.operator);
}
return false;
}))
? '.value'
: isJsx
? ''
: inComponentScope
? '.value'
: '.peek()'}`);
}
}
// Replace setter usages to mutate the signal
if (typeof setterVar !== 'undefined' &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
setterVar.type === AST_NODE_TYPES.Identifier) {
const setterVariable = context.sourceCode
.getScope(node)
.variables.find((v) => {
return v.name === setterVar.name;
});
if (typeof setterVariable !== 'undefined') {
const baseSignalName = `${stateVar.name}${suffix}`;
function hasName(n) {
return context.sourceCode
.getScope(node)
.variables.some((v) => {
return v.name === n;
});
}
let signalName = baseSignalName;
let i = 1;
while (hasName(signalName)) {
signalName = `${baseSignalName}${++i}`;
}
for (const ref of setterVariable.references) {
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/strict-boolean-expressions
ref.identifier.parent &&
ref.identifier.parent.type === AST_NODE_TYPES.CallExpression &&
ref.identifier.parent.callee === ref.identifier) {
const args = ref.identifier.parent.arguments;
if (args.length === 1) {
const arg = args[0];
if (typeof arg === 'undefined') {
continue;
}
const argText = context.sourceCode.getText(arg);
let replacement;
if (arg.type === AST_NODE_TYPES.ArrowFunctionExpression ||
arg.type === AST_NODE_TYPES.FunctionExpression) {
// Functional update: state = cb(state)
replacement = `${signalName}.value = ${argText}(${signalName}.value)`;
}
else {
replacement = `${signalName}.value = ${argText}`;
}
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
if (ref.identifier.parent?.range) {
tryPushReplace([ref.identifier.parent.range[0], ref.identifier.parent.range[1]], replacement);
}
}
}
}
}
}
return fixes;
},
});
}
else {
// If conversion isn't applicable, provide a safe import-only suggestion
suggestions.push({
messageId: 'addUseSignalImport',
fix(fixer) {
const fixes = [];
ensureUseSignalImportAny(fixer, fixes, context);
return fixes.length > 0 ? fixes : null;
},
});
}
context.report({
node: node.init,
messageId: 'preferUseSignal',
data: {
type: typeof initialValue === 'undefined'
? 'state'
: initialValue.type === AST_NODE_TYPES.Literal
? initialValue.value === null
? 'null'
: typeof initialValue.value
: initialValue.type === AST_NODE_TYPES.TemplateLiteral
? 'string'
: initialValue.type === AST_NODE_TYPES.Identifier
? 'identifier'
: 'state',
},
suggest: suggestions,
});
},
[`${AST_NODE_TYPES.Program}:exit`]() {
startPhase(perfKey, 'programExit');
perf['Program:exit']();
endPhase(perfKey, 'programExit');
},
};
},
});
//# sourceMappingURL=prefer-use-signal-over-use-state.js.map