@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
JavaScript
/** 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