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

471 lines 27.6 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, } from './utils/performance.js'; import { buildSuffixRegex, hasSignalSuffix } from './utils/suffix.js'; import { getRuleDocUrl } from './utils/urls.js'; function getSeverity(messageId, options) { if (!options?.severity) { return 'error'; } switch (messageId) { case 'preferComputedWithSignal': { return options.severity.preferComputedWithSignal ?? 'error'; } case 'preferComputedWithSignals': { return options.severity.preferComputedWithSignals ?? 'error'; } case 'suggestComputed': { return options.severity.suggestComputed ?? 'error'; } case 'addComputedImport': { return options.severity.addComputedImport ?? 'error'; } case 'suggestAddComputedImport': { return options.severity.suggestAddComputedImport ?? 'error'; } default: { return 'error'; } } } function getSignalDependencyInfo(dep, suffixRegex) { if (dep === null) { return null; } if (dep.type === AST_NODE_TYPES.MemberExpression && dep.property.type === AST_NODE_TYPES.Identifier && dep.property.name === 'value' && dep.object.type === AST_NODE_TYPES.Identifier && hasSignalSuffix(dep.object.name, suffixRegex)) { return { signalName: dep.object.name, isDirectAccess: false, node: dep, }; } if (dep.type === AST_NODE_TYPES.Identifier && hasSignalSuffix(dep.name, suffixRegex)) { return { signalName: dep.name, isDirectAccess: true, node: dep, }; } return null; } let hasComputedImport = false; let program = null; const ruleName = 'prefer-computed'; export const preferComputedRule = ESLintUtils.RuleCreator((name) => { return getRuleDocUrl(name); })({ name: ruleName, meta: { type: 'suggestion', docs: { description: 'Encourages using `computed()` from @preact/signals-react instead of `useMemo` when working with signals. This provides better performance through automatic dependency tracking and more predictable reactivity behavior in React components.', url: getRuleDocUrl(ruleName), }, fixable: 'code', hasSuggestions: true, messages: { preferComputedWithSignal: 'Prefer `computed()` over `useMemo` when using signal "{{ signalName }}" for better performance and automatic reactivity.', preferComputedWithSignals: 'Prefer `computed()` over `useMemo` when using signals ({{ signalNames }}) for better performance and automatic reactivity.', suggestComputed: 'Replace `useMemo` with `computed()`', addComputedImport: 'Add `computed` import from @preact/signals-react', suggestAddComputedImport: 'Add missing import for `computed`', }, 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: { preferComputedWithSignal: { type: 'string', enum: ['error', 'warn', 'off'], }, preferComputedWithSignals: { type: 'string', enum: ['error', 'warn', 'off'], }, suggestComputed: { type: 'string', enum: ['error', 'warn', 'off'], }, addComputedImport: { type: 'string', enum: ['error', 'warn', 'off'], }, suggestAddComputedImport: { type: 'string', enum: ['error', 'warn', 'off'], }, }, additionalProperties: false, }, suffix: { type: 'string', minLength: 1 }, extraCreatorModules: { type: 'array', items: { type: 'string', minLength: 1 }, }, rename: { type: 'boolean' }, accessors: { type: 'object', properties: { jsx: { type: 'string', enum: ['auto', 'value', 'none'] }, inComponent: { type: 'string', enum: ['value', 'peek'] }, outsideComponent: { type: 'string', enum: ['peek', 'value'] }, }, additionalProperties: false, }, }, additionalProperties: false, }, ], }, defaultOptions: [ { performance: DEFAULT_PERFORMANCE_BUDGET, rename: true, accessors: { jsx: 'auto', inComponent: 'value', outsideComponent: 'peek', }, }, ], 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 suffix = typeof option?.suffix === 'string' && option.suffix.length > 0 ? option.suffix : 'Signal'; const suffixRegex = buildSuffixRegex(suffix); startPhase(perfKey, 'ruleExecution'); const creatorModules = new Set([ '@preact/signals-react', ...(Array.isArray(option?.extraCreatorModules) ? option.extraCreatorModules : []), ]); return { '*': (node) => { if (!shouldContinue()) { endPhase(perfKey, 'recordMetrics'); return; } perf.trackNode(node); const op = // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition PerformanceOperations[`${node.type}Processing`] ?? PerformanceOperations.nodeProcessing; trackOperation(perfKey, op); }, [AST_NODE_TYPES.Program](node) { startPhase(perfKey, 'program-analysis'); program = node; hasComputedImport = program.body.some((n) => { trackOperation(perfKey, PerformanceOperations.importCheck); 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 === 'computed'); })); }); endPhase(perfKey, 'program-analysis'); }, [AST_NODE_TYPES.CallExpression](node) { recordMetric(perfKey, 'useMemoCallsAnalyzed', 1); trackOperation(perfKey, PerformanceOperations.callExpressionCheck); let depth = 0; let parent = node.parent; while (parent) { if (parent.type === AST_NODE_TYPES.CallExpression) depth++; parent = parent.parent; } recordMetric(perfKey, 'currentCallDepth', depth); const isUseMemoCall = (() => { if (node.callee.type === AST_NODE_TYPES.Identifier) { if (node.callee.name === 'useMemo') { return true; } const variable = context.sourceCode .getScope(node) .variables.find((v) => { return 'name' in node.callee && v.name === node.callee.name; }); if (typeof variable !== 'undefined') { return variable.defs.some((def) => { if (def.type !== 'ImportBinding') { return false; } // Guard imported name access if ('imported' in def.node && 'name' in def.node.imported) { return def.node.imported.name === 'useMemo'; } return false; }); } return false; } if (node.callee.type === AST_NODE_TYPES.MemberExpression && node.callee.property.type === AST_NODE_TYPES.Identifier) { // React.useMemo or aliased namespace return node.callee.property.name === 'useMemo'; } return false; })(); if (!isUseMemoCall || node.arguments.length !== 2 || node.arguments[1]?.type !== AST_NODE_TYPES.ArrayExpression) { return; } startPhase(perfKey, 'signal-analysis'); const signalDeps = []; for (const dep of node.arguments[1].elements) { trackOperation(perfKey, PerformanceOperations.dependencyCheck); const depInfo = getSignalDependencyInfo(dep, suffixRegex); if (depInfo) { signalDeps.push(depInfo); recordMetric(perfKey, 'totalSignalDependencies', signalDeps.length); } } if (signalDeps.length === 0) { endPhase(perfKey, 'signal-analysis'); return; } recordMetric(perfKey, 'useMemoCallsWithSignals', 1); const uniqueSignalNames = [...new Set(signalDeps.map((s) => s.signalName))]; const hasMultipleSignals = uniqueSignalNames.length > 1; recordMetric(perfKey, 'uniqueSignalsPerUseMemo', uniqueSignalNames.length); if (hasMultipleSignals) { recordMetric(perfKey, 'useMemoWithMultipleSignals', 1); } const suggestionType = hasMultipleSignals ? 'multipleSignals' : 'singleSignal'; recordMetric(perfKey, `suggestions.${suggestionType}`, 1); trackOperation(perfKey, PerformanceOperations.reportGeneration); const messageId = signalDeps.length === 1 ? 'preferComputedWithSignal' : 'preferComputedWithSignals'; if (getSeverity(messageId, option) !== 'off') { context.report({ node, messageId, data: { signalName: uniqueSignalNames[0], signalNames: uniqueSignalNames.join(', '), }, suggest: [ { messageId: 'suggestComputed', *fix(fixer) { const callback = node.arguments[0]; if (typeof callback === 'undefined') { return; } yield fixer.replaceText(node, `computed(${context.sourceCode.getText(callback)})`); // Also optionally rename the capturing variable to have Signal suffix and fix all references with correct accessors // Find the VariableDeclarator that initializes with this call let decl = null; for (const anc of context.sourceCode.getAncestors(node)) { if (anc.type === AST_NODE_TYPES.VariableDeclarator && anc.init === node) { decl = anc; break; } } if (option?.rename !== false && decl && decl.id.type === AST_NODE_TYPES.Identifier) { const originalName = decl.id.name; // Build the fixed name similar to signal-variable-name rule let fixedName = originalName; if (fixedName.startsWith('use') && fixedName.length > 3) { fixedName = fixedName.slice(3); } if (fixedName.length > 0) { fixedName = fixedName.charAt(0).toLowerCase() + fixedName.slice(1); } if (!hasSignalSuffix(fixedName, suffixRegex)) { fixedName += suffix; } if (fixedName !== originalName) { // Avoid name collision in current scope const declScope = context.sourceCode.getScope(decl); if (!declScope.set.has(fixedName)) { yield fixer.replaceText(decl.id, fixedName); const variable = declScope.set.get(originalName); if (variable) { for (const reference of variable.references) { const ref = reference.identifier; // Skip the declarator id itself if (ref.range[0] === decl.id.range[0] && ref.range[1] === decl.id.range[1]) { continue; } // Skip if used as property name in MemberExpression foo.bar if ( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition ref.parent?.type === AST_NODE_TYPES.MemberExpression && ref.parent.property === ref && !ref.parent.computed) { continue; } const ancestors = context.sourceCode.getAncestors(ref); 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); }); // In JSX attribute context? (either directly under JSXAttribute, or inside its expression container) const inJsxAttribute = 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; }); // 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 (!anc) 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 (vd && vd.id.type === AST_NODE_TYPES.Identifier && /^[A-Z]/.test(vd.id.name)) { inComponentScope = true; } break; } } // If identifier is inside a CallExpression argument in JSX, treat as argument usage and require .value const isInJsxCallArg = isJsx && ancestors.some((a) => { if (a.type !== AST_NODE_TYPES.CallExpression) { return false; } // If identifier is within callee, it's not an argument if ( // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition a.callee && ref.range[0] >= a.callee.range[0] && ref.range[1] <= a.callee.range[1]) { return false; } // Identifier lies within one of the arguments' ranges 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.range[0] >= arg.range[0] && ref.range[1] <= arg.range[1]); }); }); const jsxStrategy = option?.accessors?.jsx ?? 'auto'; const inCompPref = option?.accessors?.inComponent ?? 'value'; const outCompPref = option?.accessors?.outsideComponent ?? 'peek'; let accessor = ''; if (inJsxAttribute || isInJsxCallArg) { accessor = '.value'; } else if (isJsx) { accessor = jsxStrategy === 'value' ? '.value' : ''; } else if (inComponentScope) { accessor = inCompPref === 'value' ? '.value' : '.peek()'; } else { accessor = outCompPref === 'peek' ? '.peek()' : '.value'; } yield fixer.replaceText(ref, `${fixedName}${accessor}`); } } } } } if (getSeverity('suggestAddComputedImport', option) === 'off') { return; } if (!hasComputedImport) { const fixes = ensureNamedImportFixes(context, fixer, '@preact/signals-react', 'computed'); for (const f of fixes) { yield f; } } }, }, ], }); } }, [`${AST_NODE_TYPES.Program}:exit`]: () => { startPhase(perfKey, 'programExit'); perf['Program:exit'](); endPhase(perfKey, 'programExit'); }, }; }, }); //# sourceMappingURL=prefer-computed.js.map