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

413 lines 20.4 kB
/** biome-ignore-all assist/source/organizeImports: off */ import { ESLintUtils, AST_NODE_TYPES, } from '@typescript-eslint/utils'; 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 'avoidSignalInComponent': { return options.severity.avoidSignalInComponent ?? 'error'; } case 'suggestMoveToModuleLevel': { return options.severity.suggestMoveToModuleLevel ?? 'error'; } case 'suggestMoveToCustomHook': { return options.severity.suggestMoveToCustomHook ?? 'error'; } case 'moveToModuleLevel': { return options.severity.moveToModuleLevel ?? 'error'; } case 'createCustomHook': { return options.severity.createCustomHook ?? 'error'; } default: { return 'error'; } } } function getSignalInfo(node, sourceCode) { return { signalName: node.callee.type === 'Identifier' ? node.callee.name : 'signal', signalValue: node.arguments.length > 0 ? sourceCode.getText(node.arguments[0]) : 'undefined', varName: (node.callee.type === 'Identifier' ? node.callee.name : 'signal') === 'signal' ? 'value' : 'computedValue', }; } function generateUniqueHookName(context, baseName) { const usedNames = new Set(); function collectNames(node) { if (node.type === 'Identifier' && node.parent.type !== 'MemberExpression') { usedNames.add(node.name); } if ('body' in node && Array.isArray(node.body)) { node.body.forEach(collectNames); } else if ('body' in node && node.body) { collectNames(node.body); } if ('declarations' in node && Array.isArray(node.declarations)) { node.declarations.forEach(collectNames); } } collectNames(context.sourceCode.ast); let hookName = `use${baseName.charAt(0).toUpperCase() + baseName.slice(1)}`; let counter = 1; while (usedNames.has(hookName)) { hookName = `use${baseName.charAt(0).toUpperCase() + baseName.slice(1)}${counter++}`; } return hookName; } function getLeadingCommentsText(node, sourceCode) { const leadingComments = sourceCode.getCommentsBefore(node); if (leadingComments.length === 0) { return null; } const firstComment = leadingComments[0]; const lastComment = leadingComments[leadingComments.length - 1]; if (!firstComment || !lastComment) { return null; } return { text: sourceCode.text.slice(firstComment.range[0], lastComment.range[1]), range: [ firstComment.range[0], lastComment.range[1] + (sourceCode.text[lastComment.range[1]] === '\n' ? 1 : 0), ], }; } function isReactComponent(node, parent) { if (node.type === 'FunctionDeclaration' && node.id) { return /^[A-Z]/.test(node.id.name); } if (parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier') { return /^[A-Z]/.test(parent.id.name); } return false; } function isHookFunction(node) { if (![ AST_NODE_TYPES.FunctionDeclaration, AST_NODE_TYPES.ArrowFunctionExpression, AST_NODE_TYPES.FunctionExpression, ].includes(node.type)) { return false; } if (node.type === AST_NODE_TYPES.FunctionDeclaration && node.id) { return (node.id.name.startsWith('use') && node.id.name.length > 3 && node.id.name[3] === node.id.name[3]?.toUpperCase()); } if (node.parent?.type === AST_NODE_TYPES.VariableDeclarator && node.parent.id.type === AST_NODE_TYPES.Identifier) { return (node.parent.id.name.startsWith('use') && node.parent.id.name.length > 3 && node.parent.id.name[3] === node.parent.id.name[3]?.toUpperCase()); } return false; } const functionStack = []; let inComponent = false; let inHook = false; let inEffect = false; const ruleName = 'no-signal-creation-in-component'; export const noSignalCreationInComponentRule = ESLintUtils.RuleCreator((name) => { return getRuleDocUrl(name); })({ name: ruleName, meta: { type: 'problem', fixable: 'code', hasSuggestions: true, docs: { description: 'Prevent signal creation inside React components, hooks, or effects', url: getRuleDocUrl(ruleName), }, messages: { avoidSignalInComponent: 'Avoid creating {{ signalType }} signals inside {{ context }}. Move signal creation to module level or a custom hook.', suggestMoveToModuleLevel: 'Move {{ signalType }} signal to module level', suggestMoveToCustomHook: 'Extract {{ signalType }} signal to a custom hook', moveToModuleLevel: 'Move to module level', createCustomHook: 'Create custom hook for {{ signalType }} signal', }, schema: [ { type: 'object', properties: { severity: { type: 'object', properties: { avoidSignalInComponent: { type: 'string', enum: ['error', 'warn', 'off'], }, suggestMoveToModuleLevel: { type: 'string', enum: ['error', 'warn', 'off'], }, suggestMoveToCustomHook: { type: 'string', enum: ['error', 'warn', 'off'], }, moveToModuleLevel: { type: 'string', enum: ['error', 'warn', 'off'], }, createCustomHook: { type: 'string', enum: ['error', 'warn', 'off'], }, }, 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: [ { 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 (typeof option?.performance?.maxNodes === 'number' && nodeCount > option.performance.maxNodes) { trackOperation(perfKey, PerformanceOperations.nodeBudgetExceeded); return false; } return true; } startPhase(perfKey, 'ruleExecution'); // Track local names and namespaces for signal creators const signalCreatorLocals = new Set(['signal', 'computed']); const signalNamespaces = new Set(); 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); }, 'FunctionDeclaration, ArrowFunctionExpression, FunctionExpression'(node) { const parent = node.parent; const isComponent = isReactComponent(node, parent); const isHook = isHookFunction(node); functionStack.push({ isComponent, isHook }); if (isComponent) { inComponent = true; } else if (isHook) { inHook = true; } }, 'FunctionDeclaration > :not(FunctionDeclaration), ArrowFunctionExpression > :not(ArrowFunctionExpression), FunctionExpression > :not(FunctionExpression)'(_node) { const state = functionStack.pop(); if (typeof state === 'undefined') { return; } if (state.isComponent) { inComponent = false; } else if (state.isHook) { inHook = false; } }, [AST_NODE_TYPES.Program](node) { for (const stmt of node.body) { if (stmt.type === AST_NODE_TYPES.ImportDeclaration && typeof stmt.source.value === 'string' && stmt.source.value === '@preact/signals-react') { for (const spec of stmt.specifiers) { if (spec.type === AST_NODE_TYPES.ImportSpecifier) { if ('name' in spec.imported && (spec.imported.name === 'signal' || spec.imported.name === 'computed')) { signalCreatorLocals.add(spec.local.name); } } else if (spec.type === AST_NODE_TYPES.ImportNamespaceSpecifier) { signalNamespaces.add(spec.local.name); } } } } }, [AST_NODE_TYPES.CallExpression](node) { const wasInEffect = inEffect; if ((node.callee.type === AST_NODE_TYPES.Identifier && ['useEffect', 'useCallback', 'useMemo', 'useLayoutEffect'].includes(node.callee.name)) || (node.callee.type === AST_NODE_TYPES.MemberExpression && node.callee.property.type === AST_NODE_TYPES.Identifier && ['useEffect', 'useCallback', 'useMemo', 'useLayoutEffect'].includes(node.callee.property.name))) { inEffect = true; } function isSignalCreate() { // identifier call: alias or bare if (node.callee.type === AST_NODE_TYPES.Identifier) { return signalCreatorLocals.has(node.callee.name); } // namespace call: ns.signal/ns.computed if (node.callee.type === AST_NODE_TYPES.MemberExpression && node.callee.object.type === AST_NODE_TYPES.Identifier && signalNamespaces.has(node.callee.object.name) && node.callee.property.type === AST_NODE_TYPES.Identifier && (node.callee.property.name === 'signal' || node.callee.property.name === 'computed')) { return true; } // fallback to original broad heuristic (member .signal/.computed) if (node.callee.type === AST_NODE_TYPES.MemberExpression && node.callee.property.type === AST_NODE_TYPES.Identifier && (node.callee.property.name === 'signal' || node.callee.property.name === 'computed')) { return true; } return false; } if (isSignalCreate() && (inComponent || inHook || wasInEffect)) { const { signalName, signalValue, varName } = getSignalInfo(node, context.sourceCode); const signalType = signalName === 'signal' ? 'reactive' : 'computed'; if (getSeverity('avoidSignalInComponent', option) !== 'off') { context.report({ node, messageId: 'avoidSignalInComponent', data: { signalName, context: inComponent ? 'component' : inHook ? 'hook' : 'effect', }, suggest: [ { messageId: 'suggestMoveToModuleLevel', data: { signalType }, *fix(fixer) { const firstNode = context.sourceCode.ast.body[0]; const newLine = context.sourceCode.getText().includes('\r\n') ? '\r\n' : '\n'; if (typeof firstNode === 'undefined') { return; } yield fixer.insertTextBefore(firstNode, `const ${varName} = ${signalName}(${signalValue});${newLine}${newLine}`); yield fixer.replaceText(node, varName); const comments = getLeadingCommentsText(node, context.sourceCode); if (comments !== null) { yield fixer.insertTextBefore(firstNode, comments.text + newLine); yield fixer.removeRange(comments.range); } }, }, { messageId: 'createCustomHook', *fix(fixer) { // eslint-disable-next-line n/no-unsupported-features/es-syntax const lastImport = context.sourceCode.ast.body.findLast((node) => { return node.type === 'ImportDeclaration'; }); const insertPosition = typeof lastImport === 'undefined' ? 0 : lastImport.range[1] + 1; const hookName = `use${signalName.charAt(0).toUpperCase() + signalName.slice(1)}`; const newLine = context.sourceCode.getText().includes('\r\n') ? '\r\n' : '\n'; yield fixer.insertTextAfterRange([insertPosition, insertPosition], `${newLine}function ${hookName}() {${newLine} return ${signalName}(${signalValue});${newLine}}${newLine}${newLine}`); yield fixer.replaceText(node, `${hookName}()`); }, }, { messageId: 'suggestMoveToCustomHook', data: { signalType }, *fix(fixer) { const lastImport = context.sourceCode.ast.body .slice() .reverse() .find((node) => { return node.type === 'ImportDeclaration'; }); const insertPosition = lastImport ? lastImport.range[1] + 1 : 0; const hookName = generateUniqueHookName(context, signalName === 'signal' ? 'value' : 'computedValue'); const newLine = context.sourceCode.getText().includes('\r\n') ? '\r\n' : '\n'; yield fixer.insertTextAfterRange([insertPosition, insertPosition], `${newLine}function ${hookName}() {${newLine} return ${signalName}(${signalValue});${newLine}}${newLine}${newLine}`); yield fixer.replaceText(node, `${hookName}()`); const comments = getLeadingCommentsText(node, context.sourceCode); if (comments !== null) { yield fixer.insertTextBeforeRange([insertPosition, insertPosition], comments.text + newLine); yield fixer.removeRange(comments.range); } }, }, ], }); } } if ((node.callee.type === AST_NODE_TYPES.Identifier && ['useEffect', 'useCallback', 'useMemo', 'useLayoutEffect'].includes(node.callee.name)) || (node.callee.type === AST_NODE_TYPES.MemberExpression && node.callee.property.type === AST_NODE_TYPES.Identifier && ['useEffect', 'useCallback', 'useMemo', 'useLayoutEffect'].includes(node.callee.property.name))) { inEffect = wasInEffect; } }, 'ClassDeclaration, PropertyDefinition, MethodDefinition'() { inComponent = true; }, 'ClassDeclaration > :not(ClassDeclaration)'() { inComponent = false; }, 'MethodDefinition, PropertyDefinition'() { if (inComponent) { functionStack.push({ isComponent: true, isHook: false }); } }, 'MethodDefinition > :not(MethodDefinition), PropertyDefinition > :not(PropertyDefinition)'() { if (inComponent) { functionStack.pop(); } }, [`${AST_NODE_TYPES.Program}:exit`]() { startPhase(perfKey, 'programExit'); perf['Program:exit'](); endPhase(perfKey, 'programExit'); }, }; }, }); //# sourceMappingURL=no-signal-creation-in-component.js.map