@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
495 lines • 23 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, PerformanceLimitExceededError, } from './utils/performance.js';
import { getRuleDocUrl } from './utils/urls.js';
function getSeverity(messageId, options) {
if (!options?.severity) {
return 'error';
}
switch (messageId) {
case 'avoidSignalAssignmentInEffect': {
return options.severity.avoidSignalAssignmentInEffect ?? 'error';
}
case 'suggestUseSignalsEffect': {
return options.severity.suggestUseSignalsEffect ?? 'error';
}
case 'suggestUseSignalsLayoutEffect': {
return options.severity.suggestUseSignalsLayoutEffect ?? 'error';
}
case 'avoidSignalAssignmentInLayoutEffect': {
return options.severity.avoidSignalAssignmentInLayoutEffect ?? 'error';
}
default: {
return 'error';
}
}
}
function hasOptionalChainAncestor(node) {
let current = node.parent;
while (current) {
if (current.type === AST_NODE_TYPES.ChainExpression) {
return true;
}
if ((current.type === AST_NODE_TYPES.MemberExpression ||
current.type === AST_NODE_TYPES.CallExpression) &&
current.optional === true) {
return true;
}
if (current.type === AST_NODE_TYPES.Program ||
current.type === AST_NODE_TYPES.JSXElement ||
current.type === AST_NODE_TYPES.JSXFragment) {
return false;
}
current = current.parent;
}
return false;
}
function isSignalAssignment(node, signalNames, perfKey, signalNameCache, signalVariables) {
if (node.type !== AST_NODE_TYPES.MemberExpression) {
return false;
}
try {
trackOperation(perfKey, PerformanceOperations.signalCheck);
// Be conservative: bail out when optional chaining is involved
if (node.optional === true || hasOptionalChainAncestor(node)) {
return false;
}
if (!node.computed &&
node.property.type === AST_NODE_TYPES.Identifier &&
node.property.name === 'value' &&
node.object.type === AST_NODE_TYPES.Identifier) {
const cacheKey = `${node.object.name}:${signalNames.join(',')}`;
if (signalVariables.has(node.object.name)) {
return true;
}
if (signalNameCache.has(cacheKey)) {
const cached = signalNameCache.get(cacheKey) ?? false;
if (cached) {
signalVariables.add(node.object.name);
}
return cached;
}
const isSignal = signalNames.some((name) => {
return 'name' in node.object && node.object.name.endsWith(name);
});
signalNameCache.set(cacheKey, isSignal);
if (isSignal) {
signalVariables.add(node.object.name);
}
return isSignal;
}
return false;
}
catch (error) {
if (error instanceof PerformanceLimitExceededError) {
throw error;
}
return false;
}
}
function isEffectHook(node, perfKey) {
try {
trackOperation(perfKey, PerformanceOperations.hookCheck);
if (node.callee.type !== AST_NODE_TYPES.Identifier) {
return null;
}
if (['useEffect', 'useLayoutEffect'].includes(node.callee.name)) {
return {
isEffect: true,
isLayoutEffect: node.callee.name === 'useLayoutEffect',
};
}
return null;
}
catch (error) {
if (error instanceof PerformanceLimitExceededError) {
throw error;
}
return null;
}
}
function visitNode(node, effectStack, signalNames, signalNameCache, signalVariables, perfKey) {
if (node.type === AST_NODE_TYPES.VariableDeclarator &&
node.init?.type === AST_NODE_TYPES.CallExpression &&
node.init.callee.type === AST_NODE_TYPES.Identifier &&
signalNames.some((name) => {
return (node.init !== null &&
'callee' in node.init &&
'name' in node.init.callee &&
node.init.callee.name.endsWith(name));
}) &&
node.id.type === AST_NODE_TYPES.Identifier) {
signalVariables.add(node.id.name);
}
if (effectStack.length === 0) {
return;
}
try {
trackOperation(perfKey, PerformanceOperations.nodeProcessing);
if (node.type === AST_NODE_TYPES.AssignmentExpression &&
node.operator === '=' &&
isSignalAssignment(node.left, signalNames, perfKey, signalNameCache, signalVariables)) {
const currentEffect = effectStack[effectStack.length - 1];
if (currentEffect) {
currentEffect.signalAssignments.push(node.left);
}
return;
}
if (typeof node !== 'object') {
return;
}
for (const key in node) {
if (key === 'parent' || key === 'range' || key === 'loc' || key === 'comments') {
continue;
}
const value = node[key];
if (Array.isArray(value)) {
for (const item of value) {
if (typeof item === 'object' && 'type' in item) {
visitNode(
// Array.isArray produces incorrect item type number, which down the line converts to never
item, effectStack, signalNames, signalNameCache, signalVariables, perfKey);
}
}
}
else if (typeof value === 'object' && 'type' in value) {
visitNode(value, effectStack, signalNames, signalNameCache, signalVariables, perfKey);
}
}
}
catch (error) {
if (error instanceof PerformanceLimitExceededError) {
throw error;
}
return;
}
}
const effectStack = [];
const signalVariables = new Set();
const patternCache = new Map();
const signalNameCache = new Map();
const ruleName = 'no-signal-assignment-in-effect';
export const noSignalAssignmentInEffectRule = ESLintUtils.RuleCreator((name) => {
return getRuleDocUrl(name);
})({
name: ruleName,
meta: {
type: 'problem',
fixable: 'code',
hasSuggestions: true,
docs: {
description: 'Prevent direct signal assignments in useEffect and useLayoutEffect',
url: getRuleDocUrl(ruleName),
},
messages: {
avoidSignalAssignmentInEffect: 'Avoid direct signal assignments in {{ hookName }}. This can cause unexpected behavior in React 18+ strict mode. Use useSignalsEffect instead.',
avoidSignalAssignmentInLayoutEffect: 'Avoid direct signal assignments in {{ hookName }}. This can cause unexpected behavior in React 18+ strict mode. Use useSignalsLayoutEffect instead.',
suggestUseSignalsEffect: 'Use useSignalsEffect for signal assignments',
suggestUseSignalsLayoutEffect: 'Use useSignalsLayoutEffect for signal assignments in layout effects',
},
schema: [
{
type: 'object',
properties: {
signalNames: {
type: 'array',
items: { type: 'string' },
default: ['Signal', 'useSignal', 'createSignal'],
description: 'Custom signal function names to check',
},
allowedPatterns: {
type: 'array',
items: { type: 'string' },
default: [],
description: 'File patterns where signal assignments are allowed',
},
severity: {
type: 'object',
properties: {
avoidSignalAssignmentInEffect: {
type: 'string',
enum: ['off', 'warn', 'error'],
default: 'error',
description: 'Severity for signal assignments in useEffect',
},
avoidSignalAssignmentInLayoutEffect: {
type: 'string',
enum: ['off', 'warn', 'error'],
default: 'error',
description: 'Severity for signal assignments in useLayoutEffect',
},
suggestUseSignalsEffect: {
type: 'string',
enum: ['off', 'warn', 'error'],
default: 'error',
description: 'Severity for suggest useSignalsEffect',
},
suggestUseSignalsLayoutEffect: {
type: 'string',
enum: ['off', 'warn', 'error'],
default: 'error',
description: 'Severity for suggest useSignalsLayoutEffect',
},
},
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,
},
],
},
defaultOptions: [
{
signalNames: ['Signal', 'useSignal', 'createSignal'],
allowedPatterns: [],
severity: {
avoidSignalAssignmentInEffect: 'error',
avoidSignalAssignmentInLayoutEffect: 'error',
suggestUseSignalsEffect: 'error',
suggestUseSignalsLayoutEffect: '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 (nodeCount > (option?.performance?.maxNodes ?? 2000)) {
trackOperation(perfKey, PerformanceOperations.nodeBudgetExceeded);
return false;
}
return true;
}
startPhase(perfKey, 'fileAnalysis');
if ((option?.allowedPatterns?.length ?? 0) > 0) {
const fileMatchesPattern = option?.allowedPatterns?.some((pattern) => {
if (patternCache.has(pattern)) {
return patternCache.get(pattern)?.test(context.filename) ?? false;
}
try {
// User defined value
// eslint-disable-next-line security/detect-non-literal-regexp
const regex = new RegExp(pattern);
patternCache.set(pattern, regex);
return regex.test(context.filename);
}
catch (error) {
if (error instanceof Error) {
console.error(`Invalid regex pattern: ${pattern}. Error: ${error.message}`);
}
else if (typeof error === 'string') {
console.error(`Invalid regex pattern: ${pattern}. Error: ${error}`);
}
else {
console.error(`Invalid regex pattern: ${pattern}. Error: ${JSON.stringify(error)}`);
}
return false;
}
});
if (fileMatchesPattern === true) {
return {};
}
}
startPhase(perfKey, 'ruleExecution');
return {
'*': (node) => {
if (!shouldContinue()) {
return;
}
perf.trackNode(node);
trackOperation(perfKey, PerformanceOperations[`${node.type}Processing`]);
// Handle function declarations and variables
if (node.type === AST_NODE_TYPES.FunctionDeclaration ||
node.type === AST_NODE_TYPES.FunctionExpression ||
node.type === AST_NODE_TYPES.ArrowFunctionExpression) {
const scope = context.sourceCode.getScope(node);
for (const variable of scope.variables) {
if (variable.defs.some((def) => {
trackOperation(perfKey, PerformanceOperations.signalCheck);
return ('init' in def.node &&
def.node.init?.type === AST_NODE_TYPES.CallExpression &&
def.node.init.callee.type === AST_NODE_TYPES.Identifier &&
option?.signalNames?.includes(def.node.init.callee.name) === true);
}) === true) {
signalVariables.add(variable.name);
}
}
}
},
[AST_NODE_TYPES.CallExpression](node) {
if (!shouldContinue()) {
return;
}
trackOperation(perfKey, PerformanceOperations.hookCheck);
const effectInfo = isEffectHook(node, perfKey);
if (!effectInfo) {
return;
}
// Push new effect context
effectStack.push({
isEffect: effectInfo.isEffect,
isLayoutEffect: effectInfo.isLayoutEffect,
signalAssignments: [],
node,
});
// Check for signal assignments in the effect callback
if (node.arguments.length > 0) {
if (node.arguments[0]?.type === AST_NODE_TYPES.ArrowFunctionExpression ||
node.arguments[0]?.type === AST_NODE_TYPES.FunctionExpression) {
if (node.arguments[0].body.type === AST_NODE_TYPES.BlockStatement) {
// Process block statement body
for (const statement of node.arguments[0].body.body) {
if (typeof option?.signalNames !== 'undefined' &&
statement.type === AST_NODE_TYPES.ExpressionStatement) {
visitNode(statement.expression, effectStack, option.signalNames, signalNameCache, signalVariables, perfKey);
}
}
}
else if (typeof option?.signalNames !== 'undefined' &&
node.arguments[0].body.type === AST_NODE_TYPES.CallExpression) {
// Handle direct function call in arrow function
visitNode(node.arguments[0].body, effectStack, option.signalNames, signalNameCache, signalVariables, perfKey);
}
}
}
},
[AST_NODE_TYPES.AssignmentExpression](node) {
if (!shouldContinue() || effectStack.length === 0) {
return;
}
trackOperation(perfKey, PerformanceOperations.signalAccess);
if (option?.signalNames && node.left.type === AST_NODE_TYPES.MemberExpression) {
const isSignal = isSignalAssignment(node.left, option.signalNames, perfKey, signalNameCache, signalVariables);
if (isSignal) {
effectStack[effectStack.length - 1]?.signalAssignments.push(node.left);
}
}
},
[AST_NODE_TYPES.MemberExpression](node) {
if (!shouldContinue() || effectStack.length === 0) {
return;
}
trackOperation(perfKey, PerformanceOperations.signalAccess);
if (typeof option?.signalNames !== 'undefined' &&
isSignalAssignment(node, option.signalNames, perfKey, signalNameCache, signalVariables)) {
effectStack[effectStack.length - 1]?.signalAssignments.push(node);
}
},
'CallExpression > :not(CallExpression)'(node) {
if (!shouldContinue() || effectStack.length === 0) {
return;
}
if (!isEffectHook(node, perfKey)) {
return;
}
const currentEffect = effectStack[effectStack.length - 1];
if (currentEffect?.node !== node) {
return;
}
if (currentEffect.signalAssignments.length > 0) {
const suggest = [];
if (currentEffect.isLayoutEffect) {
suggest.push({
messageId: 'suggestUseSignalsLayoutEffect',
fix: (fixer) => {
const callback = node.arguments[0];
if (typeof callback === 'undefined' ||
(callback.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
callback.type !== AST_NODE_TYPES.FunctionExpression)) {
return null;
}
const fixes = ensureNamedImportFixes({ sourceCode: context.sourceCode }, fixer, '@preact/signals-react/runtime', 'useSignalsLayoutEffect');
fixes.push(fixer.replaceTextRange([node.range[0], node.range[1]], `useSignalsLayoutEffect(() => ${context.sourceCode.text
.slice(callback.body.range[0], node.arguments[1]?.range[0] ?? node.range[0])
.trim()})`));
return fixes;
},
});
}
else {
suggest.push({
messageId: 'suggestUseSignalsEffect',
fix: (fixer) => {
const callback = node.arguments[0];
if (typeof callback === 'undefined' ||
(callback.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
callback.type !== AST_NODE_TYPES.FunctionExpression)) {
return null;
}
const fixes = ensureNamedImportFixes({ sourceCode: context.sourceCode }, fixer, '@preact/signals-react/runtime', 'useSignalsEffect');
fixes.push(fixer.replaceTextRange([node.range[0], node.range[1]], `useSignalsEffect(() => ${context.sourceCode.text.slice(callback.body.range[0], node.arguments[1]?.range[0] ?? node.range[0]).trim()})`));
return fixes;
},
});
}
const messageId = currentEffect.isLayoutEffect
? 'avoidSignalAssignmentInLayoutEffect'
: 'avoidSignalAssignmentInEffect';
if (getSeverity(messageId, option) !== 'off') {
context.report({
node,
messageId,
suggest,
data: {
hookName: currentEffect.isLayoutEffect ? 'useLayoutEffect' : 'useEffect',
signalNames: currentEffect.signalAssignments
.map((assign) => {
if (assign.object.type === AST_NODE_TYPES.Identifier) {
return assign.object.name;
}
return context.sourceCode.getText(assign.object);
})
.join(', '),
},
});
}
}
effectStack.pop();
},
[`${AST_NODE_TYPES.Program}:exit`]() {
startPhase(perfKey, 'programExit');
perf['Program:exit']();
endPhase(perfKey, 'programExit');
},
};
},
});
//# sourceMappingURL=no-signal-assignment-in-effect.js.map