@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
484 lines • 20.9 kB
JavaScript
/** 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 { buildSuffixRegex, hasSignalSuffix } from "./utils/suffix.js";
import { getRuleDocUrl } from "./utils/urls.js";
const REACT_HOOKS = new Set([
"useEffect",
"useLayoutEffect",
"useCallback",
"useMemo",
"useImperativeHandle",
"useState",
"useReducer",
"useRef",
"useContext",
]);
function getSeverity(messageId, options) {
if (!options?.severity) {
return "error";
}
switch (messageId) {
case "preferForOverMap": {
return options.severity.preferForOverMap ?? "error";
}
case "suggestForComponent": {
return options.severity.suggestForComponent ?? "error";
}
case "addForImport": {
return options.severity.addForImport ?? "error";
}
default: {
return "error";
}
}
}
const signalMapCache = new WeakMap();
function getBaseIdentifierFromMemberChain(node) {
let current = node.object;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
if (current.type === AST_NODE_TYPES.Identifier) {
return current;
}
if (current.type === AST_NODE_TYPES.MemberExpression) {
current = current.object;
continue;
}
return null;
}
}
function memberChainIncludesValue(node) {
let current = node;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
while (current && current.type === AST_NODE_TYPES.MemberExpression) {
if (current.property.type === AST_NODE_TYPES.Identifier &&
current.property.name === "value") {
return true;
}
current = current.object;
}
return false;
}
function unwrapCalleeMember(callee) {
// Handle ChainExpression wrapping a MemberExpression
if (callee.type === AST_NODE_TYPES.ChainExpression) {
const expr = callee.expression;
if (expr.type === AST_NODE_TYPES.MemberExpression) {
return expr;
}
return null;
}
if (callee.type === AST_NODE_TYPES.MemberExpression) {
return callee;
}
return null;
}
function isSignalArrayMap(node, suffixRegex) {
const cached = signalMapCache.get(node);
if (typeof cached !== "undefined") {
return cached;
}
let result = null;
const member = unwrapCalleeMember(node.callee);
if (member &&
member.property.type === AST_NODE_TYPES.Identifier &&
member.property.name === "map") {
const obj = member.object;
// Direct identifier receiver: fooSignal.map(...)
if (obj.type === AST_NODE_TYPES.Identifier) {
if (hasSignalSuffix(obj.name, suffixRegex)) {
result = { signalName: obj.name, hasValueAccess: false };
}
}
// Member chain receiver: may include .value and/or nested props
else if (obj.type === AST_NODE_TYPES.MemberExpression) {
const base = getBaseIdentifierFromMemberChain(obj);
if (base && hasSignalSuffix(base.name, suffixRegex)) {
const hasValue = memberChainIncludesValue(obj);
result = { signalName: base.name, hasValueAccess: hasValue };
}
}
}
signalMapCache.set(node, result);
return result;
}
function getForComponentReplacement(node, signalName, sourceCode) {
const mapCallback = node.arguments[0];
if (!mapCallback) {
return null;
}
// Handle different callback types
if (mapCallback.type === AST_NODE_TYPES.ArrowFunctionExpression ||
mapCallback.type === AST_NODE_TYPES.FunctionExpression) {
const params = "params" in mapCallback ? mapCallback.params : [];
// Build param list and potential body identifier rewrites when first param is an ObjectPattern
let firstParamText = "(item)";
let needsRewriteToItem = false;
const rewriteMap = {};
if (params[0]) {
if (params[0].type === AST_NODE_TYPES.ObjectPattern) {
// Capture type annotation if present
const typeText = params[0].typeAnnotation
? sourceCode.getText(params[0].typeAnnotation.typeAnnotation)
: undefined;
// Collect property identifiers for rewrite (simple Identifier keys only)
for (const prop of params[0].properties) {
if (prop.type === AST_NODE_TYPES.Property &&
prop.key.type === AST_NODE_TYPES.Identifier) {
const keyName = prop.key.name;
// if there is an alias `{ key: local }`, rewrite local -> item.key
if (prop.value.type === AST_NODE_TYPES.Identifier) {
rewriteMap[prop.value.name] = `item.${keyName}`;
}
else if (prop.value.type === AST_NODE_TYPES.AssignmentPattern &&
prop.value.left.type === AST_NODE_TYPES.Identifier) {
rewriteMap[prop.value.left.name] = `item.${keyName}`;
}
else {
// fallback: also rewrite bare key to item.key
// eslint-disable-next-line security/detect-object-injection
rewriteMap[keyName] = `item.${keyName}`;
}
}
}
firstParamText =
typeof typeText === "undefined" ? `(item)` : `(item: ${typeText})`;
needsRewriteToItem = true;
}
else if (params[0].type === AST_NODE_TYPES.Identifier) {
// Preserve original identifier and any type annotation
const id = params[0];
const typeText = id.typeAnnotation
? sourceCode.getText(id.typeAnnotation.typeAnnotation)
: undefined;
firstParamText =
typeof typeText === "undefined"
? `(${id.name})`
: `(${id.name}: ${typeText})`;
}
else {
firstParamText = `(item)`;
}
}
// Get the body of the callback
let bodyText = "";
let needsParens = false;
if ("body" in mapCallback) {
if (mapCallback.body.type === AST_NODE_TYPES.BlockStatement) {
// For block statements, we need to handle the return statement
const returnStmt = mapCallback.body.body.find((stmt) => stmt.type === AST_NODE_TYPES.ReturnStatement);
if (returnStmt?.argument) {
bodyText = sourceCode.getText(returnStmt.argument);
}
else if (mapCallback.body.body.length > 0) {
bodyText = sourceCode.getText(mapCallback.body);
}
}
else {
// For concise arrow functions, just get the expression
bodyText = sourceCode.getText(mapCallback.body);
needsParens =
mapCallback.body.type !== AST_NODE_TYPES.JSXElement &&
mapCallback.body.type !== AST_NODE_TYPES.JSXFragment;
}
}
// If we destructured, rewrite bare identifiers in body to item.<prop>
if (needsRewriteToItem && bodyText) {
for (const [from, to] of Object.entries(rewriteMap)) {
// replace word-boundary identifiers not already qualified (best-effort)
// eslint-disable-next-line security/detect-non-literal-regexp
const re = new RegExp(`(?<!["'.])\\b${from}\\b`, "g");
bodyText = bodyText.replace(re, to);
}
}
return {
replacement: `<For each={${signalName}}>{(${params.length > 1 &&
typeof (params.length > 1
? params[1]?.type === AST_NODE_TYPES.Identifier
? params[1].name
: "index"
: undefined) !== "undefined"
? `${firstParamText.slice(1, -1)}, ${params.length > 1
? params[1]?.type === AST_NODE_TYPES.Identifier
? params[1].name
: "index"
: undefined}`
: firstParamText.slice(1, -1)}) => ${needsParens ? `(${bodyText})` : bodyText}}</For>`,
needsParens: false,
};
}
// For identifier callbacks, just use the identifier directly
if (mapCallback.type === AST_NODE_TYPES.Identifier) {
return {
replacement: `<For each={${signalName}}>{${sourceCode.getText(mapCallback)}}</For>`,
needsParens: false,
};
}
return {
replacement: `<For each={${signalName}}>{${sourceCode.getText(mapCallback)}}</For>`,
needsParens: true,
};
}
let importCheckCache = false;
function checkForImport(context) {
if (!importCheckCache) {
importCheckCache = context.sourceCode.ast.body.some((node) => {
return (node.type === AST_NODE_TYPES.ImportDeclaration &&
node.source.value === "@preact/signals-react/utils" &&
node.specifiers.some((s) => {
return (s.type === AST_NODE_TYPES.ImportSpecifier &&
"name" in s.imported &&
s.imported.name === "For");
}));
});
}
return importCheckCache;
}
let inJSX = false;
let jsxDepth = 0;
let inHook = false;
let hookDepth = 0;
const ruleName = "prefer-for-over-map";
export const preferForOverMapRule = ESLintUtils.RuleCreator((name) => {
return getRuleDocUrl(name);
})({
name: ruleName,
meta: {
type: "suggestion",
fixable: "code",
hasSuggestions: true,
docs: {
description: "Prefer For component over .map() for rendering signal arrays",
url: getRuleDocUrl(ruleName),
},
messages: {
preferForOverMap: "Prefer using the `<For>` component instead of `.map()` for better performance with signal arrays.",
suggestForComponent: "Replace `.map()` with `<For>` component",
addForImport: "Add `For` import from @preact/signals-react/utils",
},
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: {
preferForOverMap: {
type: "string",
enum: ["error", "warn", "off"],
},
suggestForComponent: {
type: "string",
enum: ["error", "warn", "off"],
},
addForImport: {
type: "string",
enum: ["error", "warn", "off"],
},
},
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;
}
const suffix = typeof option?.suffix === "string" && option.suffix.length > 0
? option.suffix
: "Signal";
const suffixRegex = buildSuffixRegex(suffix);
startPhase(perfKey, "ruleExecution");
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.JSXElement](_node) {
inJSX = true;
jsxDepth++;
},
[`${AST_NODE_TYPES.JSXElement}:exit`](_node) {
jsxDepth--;
if (jsxDepth === 0) {
inJSX = false;
}
},
[AST_NODE_TYPES.JSXFragment](_node) {
inJSX = true;
jsxDepth++;
},
[`${AST_NODE_TYPES.JSXFragment}:exit`](_node) {
jsxDepth--;
if (jsxDepth === 0) {
inJSX = false;
}
},
[AST_NODE_TYPES.CallExpression](node) {
if (node.callee.type === AST_NODE_TYPES.Identifier &&
REACT_HOOKS.has(node.callee.name)) {
hookDepth++;
if (hookDepth === 1) {
inHook = true;
}
return;
}
if (!inJSX || inHook || hookDepth > 0) {
return;
}
const signalMapInfo = isSignalArrayMap(node, suffixRegex);
if (signalMapInfo === null) {
return;
}
const replacement = getForComponentReplacement(node, signalMapInfo.signalName, context.sourceCode);
if (!replacement) {
return;
}
if (getSeverity("preferForOverMap", option) === "off") {
return;
}
context.report({
node,
messageId: "preferForOverMap",
fix: (fixer) => {
const replacementResult = getForComponentReplacement(node, signalMapInfo.signalName, context.sourceCode);
if (!replacementResult) {
return null;
}
const fixes = [
fixer.replaceText(node, replacementResult.replacement),
];
if (!checkForImport(context) &&
getSeverity("addForImport", option) !== "off") {
const forImport = "import { For } from '@preact/signals-react/utils';\n";
const firstImport = context.sourceCode.ast.body.find((n) => {
return n.type === AST_NODE_TYPES.ImportDeclaration;
});
if (firstImport) {
fixes.push(fixer.insertTextBefore(firstImport, forImport));
}
else {
const b = context.sourceCode.ast.body[0];
if (!b) {
return null;
}
fixes.push(fixer.insertTextBefore(b, forImport));
}
}
return fixes;
},
suggest: getSeverity("suggestForComponent", option) === "off"
? []
: [
{
messageId: "suggestForComponent",
fix: (fixer) => {
const replacementResult = getForComponentReplacement(node, signalMapInfo.signalName, context.sourceCode);
if (!replacementResult) {
return null;
}
const { replacement } = replacementResult;
const fixes = [fixer.replaceText(node, replacement)];
if (!checkForImport(context) &&
getSeverity("addForImport", option) !== "off") {
const forImport = "import { For } from '@preact/signals-react/utils';\n";
const firstImport = context.sourceCode.ast.body.find((n) => {
return n.type === AST_NODE_TYPES.ImportDeclaration;
});
if (firstImport) {
fixes.push(fixer.insertTextBefore(firstImport, forImport));
}
else {
const b = context.sourceCode.ast.body[0];
if (!b) {
return null;
}
fixes.push(fixer.insertTextBefore(b, forImport));
}
}
return fixes;
},
},
],
});
},
[`${AST_NODE_TYPES.CallExpression}:exit`](node) {
if (node.callee.type === AST_NODE_TYPES.Identifier &&
REACT_HOOKS.has(node.callee.name)) {
hookDepth = Math.max(0, hookDepth - 1);
if (hookDepth === 0) {
inHook = false;
}
}
},
[`${AST_NODE_TYPES.Program}:exit`]() {
startPhase(perfKey, "programExit");
perf["Program:exit"]();
endPhase(perfKey, "programExit");
},
};
},
});
//# sourceMappingURL=prefer-for-over-map.js.map