@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
514 lines • 24.1 kB
JavaScript
/** biome-ignore-all assist/source/organizeImports: off */
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 messageId === 'addEffectImport' ? 'warn' : 'error';
}
switch (messageId) {
case 'preferSignalEffect': {
return options.severity.preferSignalEffect ?? 'error';
}
case 'suggestEffect': {
return options.severity.suggestEffect ?? 'error';
}
case 'addEffectImport': {
return options.severity.addEffectImport ?? 'warn';
}
case 'mixedDeps': {
return options.severity.mixedDeps ?? 'warn';
}
default: {
return 'error';
}
}
}
function isSignalDependency(dep, createdSignals) {
if (!dep || dep.type === AST_NODE_TYPES.SpreadElement) {
return false;
}
// Unwrap ChainExpression conservatively; bail if optional access exists later
if (dep.type === AST_NODE_TYPES.ChainExpression) {
dep = dep.expression;
}
// Member: fooSignal.value (no optional)
if (dep.type === AST_NODE_TYPES.MemberExpression &&
!dep.optional &&
dep.property.type === AST_NODE_TYPES.Identifier &&
dep.property.name === 'value' &&
dep.object.type === AST_NODE_TYPES.Identifier &&
createdSignals.has(dep.object.name)) {
return true;
}
// Identifier: fooSignal
if (dep.type === AST_NODE_TYPES.Identifier && createdSignals.has(dep.name)) {
return true;
}
return false;
}
function isUseEffectCall(callee, effectLocalNames) {
if (callee.type === AST_NODE_TYPES.Identifier) {
return effectLocalNames.has(callee.name);
}
if (callee.type === AST_NODE_TYPES.MemberExpression &&
callee.property.type === AST_NODE_TYPES.Identifier) {
return callee.property.name === 'useEffect' || callee.property.name === 'useLayoutEffect';
}
return false;
}
function hasCleanupReturn(cb) {
if (cb.type === AST_NODE_TYPES.ArrowFunctionExpression &&
cb.body.type === AST_NODE_TYPES.BlockStatement) {
return cb.body.body.some((s) => {
return s.type === AST_NODE_TYPES.ReturnStatement;
});
}
if (cb.type === AST_NODE_TYPES.FunctionExpression) {
return cb.body.body.some((s) => {
return s.type === AST_NODE_TYPES.ReturnStatement;
});
}
return false;
}
function callbackReadsSignal(cb, createdSignals) {
const visit = (n) => {
// Look for member reads like: foo.value where foo is a created signal
if (n.type === AST_NODE_TYPES.MemberExpression &&
!n.optional &&
n.object.type === AST_NODE_TYPES.Identifier &&
n.property.type === AST_NODE_TYPES.Identifier &&
n.property.name === 'value' &&
createdSignals.has(n.object.name)) {
return true;
}
// Traverse children conservatively; avoid parent back-references that cause cycles
for (const key of Object.keys(n)) {
if (key === 'parent') {
continue;
}
const val = n[key];
if (typeof val !== 'undefined' && typeof val === 'object') {
if (Array.isArray(val)) {
for (const item of val) {
if (item && typeof item === 'object' && 'type' in item && visit(item)) {
return true;
}
}
}
else if ('type' in val && visit(val)) {
return true;
}
}
}
return false;
};
// Body can be block or expression for arrow functions
if (cb.type === AST_NODE_TYPES.ArrowFunctionExpression &&
cb.body.type !== AST_NODE_TYPES.BlockStatement) {
return visit(cb.body);
}
if ('body' in cb.body && Array.isArray(cb.body.body)) {
for (const stmt of cb.body.body) {
if (visit(stmt)) {
return true;
}
}
}
return false;
}
function hasEffectImportFromAny(context, creatorModules) {
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 &&
'name' in s.imported &&
s.imported.name === 'effect');
}));
});
}
function ensureEffectImportAny(fixer, fixes, context, creatorModules) {
if (hasEffectImportFromAny(context, creatorModules)) {
return;
}
const importDecls = context.sourceCode.ast.body.filter((n) => {
return n.type === AST_NODE_TYPES.ImportDeclaration;
});
const anyCreatorImport = importDecls.find((d) => {
return typeof d.source.value === 'string' && creatorModules.has(d.source.value);
});
const importText = "import { effect } from '@preact/signals-react';\n";
if (!anyCreatorImport) {
const lastImport = importDecls[importDecls.length - 1];
const firstStmt = context.sourceCode.ast.body[0];
if (!firstStmt) {
return;
}
fixes.push(typeof lastImport === 'undefined'
? fixer.insertTextBefore(firstStmt, importText)
: fixer.insertTextAfter(lastImport, `\n${importText.trimStart()}`));
return;
}
// If we have an import from any creator module, try to append named import
const hasNamespace = anyCreatorImport.specifiers.some((s) => {
return s.type === AST_NODE_TYPES.ImportNamespaceSpecifier;
});
if (anyCreatorImport.importKind === 'type' || hasNamespace) {
fixes.push(fixer.insertTextAfter(anyCreatorImport, `\n${importText}`));
return;
}
if (anyCreatorImport.specifiers.some((s) => {
return (s.type === AST_NODE_TYPES.ImportSpecifier &&
'name' in s.imported &&
s.imported.name === 'effect');
})) {
return;
}
const lastNamed = [...anyCreatorImport.specifiers]
.reverse()
.find((s) => s.type === AST_NODE_TYPES.ImportSpecifier);
if (lastNamed) {
fixes.push(fixer.insertTextAfter(lastNamed, ', effect'));
return;
}
const defaultSpec = anyCreatorImport.specifiers.find((s) => {
return s.type === AST_NODE_TYPES.ImportDefaultSpecifier;
});
if (defaultSpec) {
fixes.push(fixer.replaceText(anyCreatorImport, `import ${defaultSpec.local.name}, { effect } from '${String(anyCreatorImport.source.value)}';`));
return;
}
// Fallback: separate import
fixes.push(fixer.insertTextAfter(anyCreatorImport, `\n${importText}`));
}
const ruleName = 'prefer-signal-effect';
export const preferSignalEffectRule = ESLintUtils.RuleCreator((name) => {
return getRuleDocUrl(name);
})({
name: ruleName,
meta: {
type: 'problem',
fixable: 'code',
hasSuggestions: true,
docs: {
description: 'Encourages using `effect()` from @preact/signals-react instead of `useEffect` when working with signals. This provides better performance through automatic dependency tracking and more predictable reactivity behavior.',
url: getRuleDocUrl(ruleName),
},
messages: {
preferSignalEffect: 'Prefer using `effect()` instead of `useEffect` for signal-only dependencies',
suggestEffect: 'Replace `useEffect` with `effect()`',
addEffectImport: 'Add `effect` import from @preact/signals-react',
mixedDeps: 'Effect has mixed dependencies (signals and non-signals); consider splitting logic or using effect() for signal reads',
},
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: {
preferSignalEffect: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
suggestEffect: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
addEffectImport: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
},
additionalProperties: false,
},
suffix: {
type: 'string',
},
reportMixedDeps: {
type: 'boolean',
},
extraCreatorModules: {
type: 'array',
items: { type: 'string', minLength: 1 },
default: [],
},
},
additionalProperties: false,
},
],
},
defaultOptions: [
{
performance: DEFAULT_PERFORMANCE_BUDGET,
severity: {
preferSignalEffect: 'error',
suggestEffect: 'error',
addEffectImport: 'warn',
mixedDeps: 'warn',
},
reportMixedDeps: false,
},
],
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 effectLocalNames = new Set(['useEffect', 'useLayoutEffect']);
const creatorLocalNames = new Set(); // e.g., signal, computed (possibly aliased)
const signalsNamespaceNames = new Set(); // e.g., Signals in `import * as Signals from ...`
const createdSignals = new Set(); // identifiers created via recognized creators
const creatorModules = new Set([
'@preact/signals-react',
...(Array.isArray(option?.extraCreatorModules) ? option.extraCreatorModules : []),
]);
for (const stmt of context.sourceCode.ast.body) {
if (stmt.type === AST_NODE_TYPES.ImportDeclaration) {
if (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 === 'useEffect' || spec.imported.name === 'useLayoutEffect')) {
effectLocalNames.add(spec.local.name);
}
}
}
if (typeof stmt.source.value === 'string' && creatorModules.has(stmt.source.value)) {
for (const spec of stmt.specifiers) {
if (spec.type === AST_NODE_TYPES.ImportSpecifier) {
const importedName = spec.imported.type === AST_NODE_TYPES.Identifier ? spec.imported.name : '';
if (importedName === 'signal' || importedName === 'computed') {
creatorLocalNames.add(spec.local.name);
}
}
else if (spec.type === AST_NODE_TYPES.ImportNamespaceSpecifier) {
signalsNamespaceNames.add(spec.local.name);
}
}
}
}
}
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.VariableDeclarator](node) {
// Track const foo = signal(...); or const foo = computed(...);
if (node.id.type !== AST_NODE_TYPES.Identifier || !node.init)
return;
if (node.init.type === AST_NODE_TYPES.CallExpression) {
if (node.init.callee.type === AST_NODE_TYPES.Identifier) {
if (creatorLocalNames.has(node.init.callee.name)) {
createdSignals.add(node.id.name);
}
}
else if (node.init.callee.type === AST_NODE_TYPES.MemberExpression &&
!node.init.callee.computed &&
node.init.callee.object.type === AST_NODE_TYPES.Identifier &&
node.init.callee.property.type === AST_NODE_TYPES.Identifier &&
signalsNamespaceNames.has(node.init.callee.object.name) &&
(node.init.callee.property.name === 'signal' ||
node.init.callee.property.name === 'computed')) {
createdSignals.add(node.id.name);
}
}
},
[AST_NODE_TYPES.CallExpression](node) {
if (!isUseEffectCall(node.callee, effectLocalNames)) {
return;
}
// Case A: effect with deps array that's signal-only -> main report + fix
if (node.arguments.length === 2 &&
node.arguments[1]?.type === AST_NODE_TYPES.ArrayExpression) {
if (!(node.arguments[1].elements.length > 0 &&
node.arguments[1].elements.every((dep) => {
return isSignalDependency(dep, createdSignals);
}))) {
// Not all are signals; if configured, report mixed deps when both present
if (option?.reportMixedDeps === true && node.arguments[1].elements.length > 0) {
const hasSignal = node.arguments[1].elements.some((dep) => isSignalDependency(dep, createdSignals));
const hasNonSignal = node.arguments[1].elements.some((dep) => {
if (!dep || dep.type === AST_NODE_TYPES.SpreadElement)
return false;
return !isSignalDependency(dep, createdSignals);
});
if (hasSignal && hasNonSignal && getSeverity('mixedDeps', option) !== 'off') {
context.report({ node, messageId: 'mixedDeps' });
}
}
return;
}
const hasEffectImport = hasEffectImportFromAny(context, creatorModules);
if (getSeverity('preferSignalEffect', option) === 'off') {
return;
}
context.report({
node,
messageId: 'preferSignalEffect',
fix(fixer) {
// Conservative autofix: only when callback is a zero-arg function with no cleanup return
const cb = node.arguments[0];
if (!cb ||
(cb.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
cb.type !== AST_NODE_TYPES.FunctionExpression)) {
return null;
}
if (cb.params.length !== 0) {
return null;
}
if (hasCleanupReturn(cb)) {
return null;
}
const fixes = [];
fixes.push(fixer.replaceText(node, `effect(${context.sourceCode.getText(cb)})`));
if (!hasEffectImport) {
ensureEffectImportAny(fixer, fixes, context, creatorModules);
}
return fixes;
},
suggest: [
{
messageId: 'suggestEffect',
fix(fixer) {
const fixes = [];
if (!node.arguments[0] ||
(node.arguments[0].type !== AST_NODE_TYPES.ArrowFunctionExpression &&
node.arguments[0].type !== AST_NODE_TYPES.FunctionExpression)) {
return null;
}
fixes.push(fixer.replaceText(node, `effect(${context.sourceCode.getText(node.arguments[0])})`));
// Add effect import if needed
if (!hasEffectImport) {
ensureEffectImportAny(fixer, fixes, context, creatorModules);
}
return fixes;
},
},
...(hasEffectImport
? []
: [
{
messageId: 'addEffectImport',
fix(fixer) {
const fixes = [];
ensureEffectImportAny(fixer, fixes, context, creatorModules);
return fixes.length > 0 ? fixes : null;
},
},
]),
],
});
return;
}
// Case B: effect without deps array, but callback reads signals -> suggestion-only
if ((node.arguments.length === 1 ||
node.arguments[1]?.type !== AST_NODE_TYPES.ArrayExpression) &&
node.arguments[0] &&
(node.arguments[0].type === AST_NODE_TYPES.ArrowFunctionExpression ||
node.arguments[0].type === AST_NODE_TYPES.FunctionExpression)) {
const cb = node.arguments[0];
if (cb.params.length === 0 &&
!hasCleanupReturn(cb) &&
callbackReadsSignal(cb, createdSignals)) {
const hasEffectImport = hasEffectImportFromAny(context, creatorModules);
context.report({
node,
messageId: 'preferSignalEffect',
suggest: [
{
messageId: 'suggestEffect',
fix(fixer) {
const fixes = [];
fixes.push(fixer.replaceText(node, `effect(${context.sourceCode.getText(cb)})`));
if (!hasEffectImport) {
const importFixes = ensureNamedImportFixes({ sourceCode: context.sourceCode }, fixer, '@preact/signals-react', 'effect');
if (importFixes.length === 0) {
return null;
}
for (const f of importFixes) {
fixes.push(f);
}
}
return fixes;
},
},
...(hasEffectImport
? []
: [
{
messageId: 'addEffectImport',
fix(fixer) {
const fixes = [];
ensureEffectImportAny(fixer, fixes, context, creatorModules);
return fixes.length > 0 ? fixes : null;
},
},
]),
],
});
}
}
},
[`${AST_NODE_TYPES.Program}:exit`]() {
startPhase(perfKey, 'programExit');
perf['Program:exit']();
endPhase(perfKey, 'programExit');
},
};
},
});
//# sourceMappingURL=prefer-signal-effect.js.map