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

406 lines 19.9 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 { getRuleDocUrl } from './utils/urls.js'; function getSeverity(messageId, options) { if (!options?.severity) { return 'error'; } switch (messageId) { case 'preferUseSignalRef': { return options.severity.preferUseSignalRef ?? 'error'; } case 'addUseSignalRefImport': { return options.severity.addUseSignalRefImport ?? 'error'; } case 'convertToUseSignalRef': { return options.severity.convertToUseSignalRef ?? 'error'; } default: { return 'error'; } } } function isUseRefCall(expr, useRefLocalNames, reactNamespaces) { if (expr.callee.type === AST_NODE_TYPES.Identifier && useRefLocalNames.has(expr.callee.name)) { return true; } if (expr.callee.type === AST_NODE_TYPES.MemberExpression && expr.callee.object.type === AST_NODE_TYPES.Identifier && reactNamespaces.has(expr.callee.object.name) && expr.callee.property.type === AST_NODE_TYPES.Identifier && expr.callee.property.name === 'useRef') { return true; } return false; } function isInsideEffectCallback(node, useEffectLocalNames, reactNamespaces) { let cur = node.parent; while (cur) { if (cur.type === AST_NODE_TYPES.ArrowFunctionExpression || cur.type === AST_NODE_TYPES.FunctionExpression) { // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition if (cur.parent && cur.parent.type === AST_NODE_TYPES.CallExpression) { if (cur.parent.callee.type === AST_NODE_TYPES.Identifier && useEffectLocalNames.has(cur.parent.callee.name)) { return true; } if (cur.parent.callee.type === AST_NODE_TYPES.MemberExpression && cur.parent.callee.object.type === AST_NODE_TYPES.Identifier && reactNamespaces.has(cur.parent.callee.object.name) && cur.parent.callee.property.type === AST_NODE_TYPES.Identifier && useEffectLocalNames.has(cur.parent.callee.property.name)) { return true; } } } cur = cur.parent; } return false; } const ruleName = 'prefer-use-signal-ref-over-use-ref'; export const preferUseSignalRefOverUseRefRule = ESLintUtils.RuleCreator((name) => getRuleDocUrl(name))({ name: ruleName, meta: { type: 'suggestion', hasSuggestions: true, docs: { description: 'Encourage using `useSignalRef` instead of `useRef` when reading .current during render/JSX to make the value reactive and aligned with Signals.', url: getRuleDocUrl(ruleName), }, messages: { preferUseSignalRef: 'Prefer useSignalRef over useRef when reading .current during render', addUseSignalRefImport: "Add `useSignalRef` import from '@preact/signals-react/utils'", convertToUseSignalRef: 'Convert this useRef to useSignalRef', }, schema: [ { type: 'object', properties: { onlyWhenReadInRender: { type: 'boolean', default: true, description: 'When true, only suggest for refs whose .current is read during render/JSX.', }, renameRef: { type: 'boolean', default: true, description: 'When false, keep original variable name (do not append SignalRef).', }, 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: { preferUseSignalRef: { type: 'string', enum: ['error', 'warn', 'off'], }, addUseSignalRefImport: { type: 'string', enum: ['error', 'warn', 'off'], }, convertToUseSignalRef: { type: 'string', enum: ['error', 'warn', 'off'], }, }, additionalProperties: false, }, }, additionalProperties: false, }, ], fixable: 'code', }, defaultOptions: [ { onlyWhenReadInRender: true, renameRef: true, performance: DEFAULT_PERFORMANCE_BUDGET, severity: { preferUseSignalRef: 'error', addUseSignalRefImport: 'error', convertToUseSignalRef: 'error', }, }, ], 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 useRefLocalNames = new Set(['useRef']); const reactNamespaces = new Set(); const useEffectLocalNames = new Set([ 'useEffect', 'useLayoutEffect', 'useInsertionEffect', ]); // Track declarations and reads const refDeclMap = new Map(); const refIdNodeMap = new Map(); const refVarDeclMap = new Map(); const refReadInRender = new Set(); // Track whether we're inside a React component or custom hook body. // Use a depth counter guarded by per-node marks to handle nesting reliably. let componentOrHookDepth = 0; const markedComponentOrHookFns = new WeakSet(); startPhase(perfKey, 'ruleExecution'); 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); }, [AST_NODE_TYPES.Program](node) { for (const stmt of node.body) { if (stmt.type === AST_NODE_TYPES.ImportDeclaration && 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 === 'useRef') { useRefLocalNames.add(spec.local.name); } else if (spec.type === AST_NODE_TYPES.ImportNamespaceSpecifier || spec.type === AST_NODE_TYPES.ImportDefaultSpecifier) { reactNamespaces.add(spec.local.name); } } } } }, [AST_NODE_TYPES.FunctionDeclaration](node) { if (node.id && (/^[A-Z]/.test(node.id.name) || /^use[A-Z]/.test(node.id.name))) { markedComponentOrHookFns.add(node); componentOrHookDepth++; } }, [`${AST_NODE_TYPES.FunctionDeclaration}:exit`](node) { if (markedComponentOrHookFns.has(node)) { componentOrHookDepth--; } }, // Handle components/hooks declared as: const Name = () => {} or function expressions [AST_NODE_TYPES.FunctionExpression](node) { if ( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition node.parent !== null && typeof node.parent !== 'undefined' && node.parent.type === AST_NODE_TYPES.VariableDeclarator && node.parent.id.type === AST_NODE_TYPES.Identifier && (/^[A-Z]/.test(node.parent.id.name) || /^use[A-Z]/.test(node.parent.id.name))) { markedComponentOrHookFns.add(node); componentOrHookDepth++; } }, [`${AST_NODE_TYPES.FunctionExpression}:exit`](node) { if (markedComponentOrHookFns.has(node)) { componentOrHookDepth--; } }, [AST_NODE_TYPES.ArrowFunctionExpression](node) { if ( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition node.parent !== null && typeof node.parent !== 'undefined' && node.parent.type === AST_NODE_TYPES.VariableDeclarator && node.parent.id.type === AST_NODE_TYPES.Identifier && (/^[A-Z]/.test(node.parent.id.name) || /^use[A-Z]/.test(node.parent.id.name))) { markedComponentOrHookFns.add(node); componentOrHookDepth++; } }, [`${AST_NODE_TYPES.ArrowFunctionExpression}:exit`](node) { if (markedComponentOrHookFns.has(node)) { componentOrHookDepth--; } }, [AST_NODE_TYPES.VariableDeclarator](node) { // Track ref declarations: const ref = useRef(...) if (node.id.type === AST_NODE_TYPES.Identifier && node.init?.type === AST_NODE_TYPES.CallExpression && isUseRefCall(node.init, useRefLocalNames, reactNamespaces)) { refDeclMap.set(node.id.name, node.init); refIdNodeMap.set(node.id.name, node.id); refVarDeclMap.set(node.id.name, node); } }, [AST_NODE_TYPES.CallExpression](node) { if (node.callee.type === AST_NODE_TYPES.Identifier && (node.callee.name === 'useEffect' || node.callee.name === 'useLayoutEffect' || node.callee.name === 'useInsertionEffect')) { useEffectLocalNames.add(node.callee.name); } }, [AST_NODE_TYPES.MemberExpression](node) { const inComponentOrHook = componentOrHookDepth > 0; if (!inComponentOrHook) { return; } if (node.object.type === AST_NODE_TYPES.Identifier && node.property.type === AST_NODE_TYPES.Identifier && node.property.name === 'current' && refDeclMap.has(node.object.name)) { if (option?.onlyWhenReadInRender !== false && isInsideEffectCallback(node, useEffectLocalNames, reactNamespaces)) { return; } refReadInRender.add(node.object.name); } }, [`${AST_NODE_TYPES.Program}:exit`]() { // If onlyWhenReadInRender is disabled, include all declared refs if (option?.onlyWhenReadInRender === false) { for (const name of refDeclMap.keys()) { refReadInRender.add(name); } } for (const name of refReadInRender) { const init = refDeclMap.get(name); if (typeof init === 'undefined') { continue; } if (getSeverity('preferUseSignalRef', option) === 'off') { continue; } const suggestions = []; suggestions.push({ messageId: 'addUseSignalRefImport', fix(fixer) { const fixes = ensureNamedImportFixes({ sourceCode: context.sourceCode }, fixer, '@preact/signals-react/utils', 'useSignalRef'); return fixes.length > 0 ? fixes : null; }, }); suggestions.push({ messageId: 'convertToUseSignalRef', fix(fixer) { const fixes = ensureNamedImportFixes({ sourceCode: context.sourceCode }, fixer, '@preact/signals-react/utils', 'useSignalRef'); if (init.callee.type === AST_NODE_TYPES.Identifier) { fixes.push(fixer.replaceText(init.callee, 'useSignalRef')); } else if (init.callee.type === AST_NODE_TYPES.MemberExpression && init.callee.property.type === AST_NODE_TYPES.Identifier && init.callee.property.name === 'useRef') { fixes.push(fixer.replaceText(init.callee.property, 'useSignalRef')); } const idNode = refIdNodeMap.get(name); const varDecl = refVarDeclMap.get(name); function computeNewName(orig) { if (orig.endsWith('SignalRef')) { return orig; } if (orig.endsWith('Ref')) { return `${orig.slice(0, -3)}SignalRef`; } if (orig.endsWith('ref')) { return `${orig.slice(0, -3)}SignalRef`; } return `${orig}SignalRef`; } const newName = computeNewName(name); if (option?.renameRef !== false && typeof idNode !== 'undefined' && typeof varDecl !== 'undefined' && context.sourceCode.scopeManager !== null && newName !== name) { fixes.push(fixer.replaceText(idNode, newName)); const seen = new Set([`${idNode.range[0]}:${idNode.range[1]}`]); const visited = new Set(); function collectScopes(scope, out = []) { if (scope === null || visited.has(scope)) { return out; } visited.add(scope); out.push(scope); for (const child of scope.childScopes) { collectScopes(child, out); } return out; } const allScopes = collectScopes(context.sourceCode.scopeManager.globalScope); let targetVar = null; for (const sc of allScopes) { const found = sc.variables.find((v) => { if (v.name !== name) { return false; } return typeof v.defs[0] !== 'undefined' && v.defs[0].node === varDecl; }); if (typeof found !== 'undefined') { targetVar = found; break; } } if (targetVar !== null) { for (const ref of targetVar.references) { const key = `${ref.identifier.range[0]}:${ref.identifier.range[1]}`; if (!seen.has(key)) { fixes.push(fixer.replaceText(ref.identifier, newName)); seen.add(key); } } } } return fixes.length > 0 ? fixes : null; }, }); context.report({ node: init, messageId: 'preferUseSignalRef', suggest: suggestions, }); } endPhase(perfKey, 'ruleExecution'); }, }; }, }); //# sourceMappingURL=prefer-use-signal-ref-over-use-ref.js.map