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

425 lines 19.7 kB
/** biome-ignore-all assist/source/organizeImports: off */ import path from "node:path"; import { AST_NODE_TYPES, ESLintUtils } 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 "signalInComponent": { return options.severity.signalInComponent ?? "error"; } case "computedInComponent": { return options.severity.computedInComponent ?? "error"; } case "exportedSignal": { return options.severity.exportedSignal ?? "error"; } default: { return "error"; } } } function isInAllowedDir(filename, allowedDirs) { if (!Array.isArray(allowedDirs) || allowedDirs.length === 0) { return false; } const normalizedFile = path.normalize(filename); return allowedDirs.some((dir) => { const abs = path.normalize(dir); const withSep = abs.endsWith(path.sep) ? abs : abs + path.sep; return normalizedFile.startsWith(withSep) || normalizedFile === abs; }); } function isSignalCall(node, signalCreatorLocals, signalNamespaces) { if (node.callee.type === AST_NODE_TYPES.Identifier) { return signalCreatorLocals.has(node.callee.name); } 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; } return false; } function isMemoLikeCall(node) { if (!node || node.type !== AST_NODE_TYPES.CallExpression) { return false; } const callee = node.callee; if (callee.type === AST_NODE_TYPES.Identifier) { return callee.name === "memo" || callee.name === "forwardRef"; } if (callee.type === AST_NODE_TYPES.MemberExpression && callee.property.type === AST_NODE_TYPES.Identifier) { const name = callee.property.name; return name === "memo" || name === "forwardRef"; } return false; } function isSignalCreation(callee, signalCreatorLocals, signalNamespaces) { // Direct identifier callee: aliased allowed if (callee.type === AST_NODE_TYPES.Identifier) { return signalCreatorLocals.has(callee.name); } // Namespace member: ns.signal / ns.computed if (callee.type === AST_NODE_TYPES.MemberExpression && callee.object.type === AST_NODE_TYPES.Identifier && signalNamespaces.has(callee.object.name) && callee.property.type === AST_NODE_TYPES.Identifier && (callee.property.name === "signal" || callee.property.name === "computed")) { return true; } return false; } function isComputed(callee, computedLocals, signalNamespaces) { // Direct identifier (aliased allowed) if (callee.type === AST_NODE_TYPES.Identifier) { return computedLocals.has(callee.name); } // Namespace member: ns.computed if (callee.type === AST_NODE_TYPES.MemberExpression && callee.object.type === AST_NODE_TYPES.Identifier && signalNamespaces.has(callee.object.name) && callee.property.type === AST_NODE_TYPES.Identifier && callee.property.name === "computed") { return true; } return false; } const ruleName = "restrict-signal-locations"; export const restrictSignalLocations = ESLintUtils.RuleCreator((name) => { return getRuleDocUrl(name); })({ name: ruleName, meta: { type: "suggestion", docs: { description: "Enforces best practices for signal creation by restricting where signals can be created. Signals should typically be created at the module level or within custom hooks, not inside component bodies. This helps prevent performance issues and unexpected behavior in React components.", url: getRuleDocUrl(ruleName), }, messages: { signalInComponent: "Avoid creating signals in component bodies. Move to module level or to external file", computedInComponent: "Avoid creating computed values in component bodies. Prefer moving them to a custom hook or module scope. If you must keep it in the component, consider useMemo.", exportedSignal: "Avoid exporting signals directly. Prefer creating them locally and passing values or utilities instead. If you suspect circular imports, run a circular dependency diagnostic (e.g., with @biomejs/biome).", }, hasSuggestions: false, schema: [ { type: "object", properties: { allowedDirs: { type: "array", items: { type: "string" }, default: [], }, allowComputedInComponents: { type: "boolean", default: false, }, customHookPattern: { type: "string", default: "^use[A-Z][a-zA-Z0-9]*$", }, 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: { signalInComponent: { type: "string", enum: ["error", "warn", "off"], }, computedInComponent: { type: "string", enum: ["error", "warn", "off"], }, exportedSignal: { type: "string", enum: ["error", "warn", "off"], }, }, additionalProperties: false, }, }, additionalProperties: false, }, ], }, defaultOptions: [ { allowedDirs: [], allowComputedInComponents: false, customHookPattern: "^use[A-Z][a-zA-Z0-9]*$", performance: DEFAULT_PERFORMANCE_BUDGET, }, ], create(context, [option]) { if (!/\.tsx$/i.test(context.filename)) { return {}; } 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; // Precompile hook regex once for performance/safety // eslint-disable-next-line security/detect-non-literal-regexp const hookRegex = new RegExp(option?.customHookPattern ?? "^use[A-Z][a-zA-Z0-9]*$"); function shouldContinue() { nodeCount++; if (typeof option?.performance?.maxNodes === "number" && nodeCount > option.performance.maxNodes) { trackOperation(perfKey, PerformanceOperations.nodeBudgetExceeded); return false; } return true; } startPhase(perfKey, "ruleExecution"); // Per-file state const componentStack = []; const signalCreatorLocals = new Set(["signal", "computed"]); const computedLocals = new Set(["computed"]); const signalNamespaces = new Set(); const signalVariables = 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); }, [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); if (spec.imported.name === "computed") { computedLocals.add(spec.local.name); } } } else if (spec.type === AST_NODE_TYPES.ImportNamespaceSpecifier) { signalNamespaces.add(spec.local.name); } } } } }, [AST_NODE_TYPES.VariableDeclarator](node) { if (node.id.type === AST_NODE_TYPES.Identifier && node.init && node.init.type === AST_NODE_TYPES.CallExpression && isSignalCreation(node.init.callee, signalCreatorLocals, signalNamespaces)) { signalVariables.add(node.id.name); } }, [AST_NODE_TYPES.ExportDefaultDeclaration](node) { if (isInAllowedDir(context.filename, option?.allowedDirs)) { return; } if (node.declaration.type === AST_NODE_TYPES.VariableDeclaration) { for (const d of node.declaration.declarations) { if (d.init !== null && d.init.type === AST_NODE_TYPES.CallExpression && isSignalCreation(d.init.callee, signalCreatorLocals, signalNamespaces) && getSeverity("exportedSignal", option) !== "off") { context.report({ node: d, messageId: "exportedSignal" }); } } } else if (node.declaration.type === AST_NODE_TYPES.Identifier && signalVariables.has(node.declaration.name) && getSeverity("exportedSignal", option) !== "off") { context.report({ node: node.declaration, messageId: "exportedSignal", }); } else if (node.declaration.type === AST_NODE_TYPES.CallExpression && isSignalCreation(node.declaration.callee, signalCreatorLocals, signalNamespaces) && getSeverity("exportedSignal", option) !== "off") { context.report({ node: node.declaration, messageId: "exportedSignal", }); } }, [AST_NODE_TYPES.FunctionDeclaration](node) { componentStack.push({ isComponent: "id" in node && node.id !== null && node.id.type === AST_NODE_TYPES.Identifier && /^[A-Z]/.test(node.id.name), isHook: "id" in node && node.id && "name" in node.id ? hookRegex.test(node.id.name) : false, node, }); }, [`${AST_NODE_TYPES.FunctionDeclaration}:exit`]() { componentStack.pop(); }, [AST_NODE_TYPES.ArrowFunctionExpression](node) { componentStack.push({ isComponent: // Arrow functions have no id; use parent variable name if any "parent" in node && // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition node.parent !== null && node.parent.type === AST_NODE_TYPES.VariableDeclarator && node.parent.id.type === AST_NODE_TYPES.Identifier && (/^[A-Z]/.test(node.parent.id.name) || isMemoLikeCall(node.parent.parent)), isHook: node.parent && node.parent.type === AST_NODE_TYPES.VariableDeclarator && node.parent.id.type === AST_NODE_TYPES.Identifier ? hookRegex.test(node.parent.id.name) : false, node, }); }, [`${AST_NODE_TYPES.ArrowFunctionExpression}:exit`]() { componentStack.pop(); }, [AST_NODE_TYPES.FunctionExpression](node) { componentStack.push({ isComponent: // Prefer variable declarator name when present 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)) || // Or when wrapped in memo/forwardRef isMemoLikeCall(node.parent)), isHook: node.parent && node.parent.type === AST_NODE_TYPES.VariableDeclarator && node.parent.id.type === AST_NODE_TYPES.Identifier ? hookRegex.test(node.parent.id.name) : false, node, }); }, [`${AST_NODE_TYPES.FunctionExpression}:exit`]() { componentStack.pop(); }, [AST_NODE_TYPES.CallExpression](node) { if (!isSignalCall(node, signalCreatorLocals, signalNamespaces)) { return; } if (isInAllowedDir(context.filename, option?.allowedDirs)) { return; } const currentContext = componentStack[componentStack.length - 1]; if (!currentContext) { return; } const { isComponent, isHook } = currentContext; if (isHook) { return; } if (!isComponent) { return; } if (isComputed(node.callee, computedLocals, signalNamespaces) && option?.allowComputedInComponents === true) { return; } const messageId = isComputed(node.callee, computedLocals, signalNamespaces) ? "computedInComponent" : "signalInComponent"; if (getSeverity(messageId, option) !== "off") { context.report({ node, messageId, }); } }, [AST_NODE_TYPES.ExportNamedDeclaration](node) { if (isInAllowedDir(context.filename, option?.allowedDirs)) { return; } if (node.declaration?.type === AST_NODE_TYPES.VariableDeclaration) { for (const decl of node.declaration.declarations) { if (decl.init?.type === AST_NODE_TYPES.CallExpression && isSignalCreation(decl.init.callee, signalCreatorLocals, signalNamespaces) && getSeverity("exportedSignal", option) !== "off") { context.report({ node: decl, messageId: "exportedSignal", }); } } } else if (!node.source && node.specifiers.length > 0) { // export { foo, bar } — report if any are known signal variables for (const spec of node.specifiers) { if ( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition spec.type === AST_NODE_TYPES.ExportSpecifier && // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition spec.local.type === AST_NODE_TYPES.Identifier && signalVariables.has(spec.local.name) && getSeverity("exportedSignal", option) !== "off") { context.report({ node: spec, messageId: "exportedSignal" }); } } } }, [`${AST_NODE_TYPES.Program}:exit`]() { startPhase(perfKey, "programExit"); perf["Program:exit"](); endPhase(perfKey, "programExit"); }, }; }, }); //# sourceMappingURL=restrict-signal-locations.js.map