UNPKG

@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
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