@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
423 lines • 18.8 kB
JavaScript
/** biome-ignore-all assist/source/organizeImports: off */
import { ESLintUtils, AST_NODE_TYPES, } from '@typescript-eslint/utils';
import { isInJSXAttribute } from './utils/jsx.js';
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';
function isInFunctionProp(node) {
let current = node.parent;
while (current) {
if (current.type === AST_NODE_TYPES.ArrowFunctionExpression ||
current.type === AST_NODE_TYPES.FunctionExpression) {
if (current.parent.type === AST_NODE_TYPES.JSXExpressionContainer &&
current.parent.parent.type === AST_NODE_TYPES.JSXAttribute) {
return true;
}
if (current.parent.type === AST_NODE_TYPES.Property &&
current.parent.parent.type === AST_NODE_TYPES.ObjectExpression &&
current.parent.parent.parent.type === AST_NODE_TYPES.JSXExpressionContainer &&
current.parent.parent.parent.parent.type === AST_NODE_TYPES.JSXAttribute) {
return true;
}
return false;
}
if (current.type === AST_NODE_TYPES.JSXElement || current.type === AST_NODE_TYPES.JSXFragment) {
return false;
}
current = current.parent;
}
return false;
}
function hasAncestorOfType(node, type) {
let current = node.parent;
while (current) {
if (current.type === type) {
return true;
}
if (current.type === AST_NODE_TYPES.JSXElement ||
current.type === AST_NODE_TYPES.JSXFragment ||
current.type === AST_NODE_TYPES.Program) {
return false;
}
current = current.parent;
}
return false;
}
// Returns true when `node` (typically a MemberExpression like `x.value`) is inside
// a CallExpression's argument list while within JSX. In that context we allow `.value`.
function isInJSXCallArgument(node) {
let current = node.parent;
while (current) {
if (current.type === AST_NODE_TYPES.CallExpression) {
const callee = current.callee;
// If node is within callee, it's not an argument
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
callee !== null &&
typeof callee !== 'undefined' &&
node.range[0] >= callee.range[0] &&
node.range[1] <= callee.range[1]) {
return false;
}
if (current.arguments.some((arg) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/strict-boolean-expressions
if (!arg) {
return false;
}
return node.range[0] >= arg.range[0] && node.range[1] <= arg.range[1];
})) {
return true;
}
}
if (current.type === AST_NODE_TYPES.JSXElement || current.type === AST_NODE_TYPES.JSXFragment) {
break;
}
current = current.parent;
}
return false;
}
function isInJSONStringify(node) {
let current = node.parent;
while (current) {
if (current.type === AST_NODE_TYPES.CallExpression &&
current.callee.type === AST_NODE_TYPES.MemberExpression &&
current.callee.object.type === AST_NODE_TYPES.Identifier &&
current.callee.object.name === 'JSON' &&
current.callee.property.type === AST_NODE_TYPES.Identifier &&
current.callee.property.name === 'stringify') {
return true;
}
if (current.type === AST_NODE_TYPES.JSXElement || current.type === AST_NODE_TYPES.JSXFragment) {
return false;
}
current = current.parent;
}
return false;
}
let jsxDepth = 0;
const ruleName = 'prefer-signal-in-jsx';
function getSeverity(messageId, options) {
if (!options?.severity) {
return 'error';
}
// eslint-disable-next-line security/detect-object-injection
const severity = options.severity[messageId];
return severity ?? 'error';
}
export const preferSignalInJsxRule = ESLintUtils.RuleCreator((name) => {
return getRuleDocUrl(name);
})({
name: ruleName,
meta: {
type: 'suggestion',
docs: {
description: "Enforces direct signal usage in JSX by preferring the signal itself over explicit `.value` access. In JSX, signals are automatically unwrapped, so there's no need to access the `.value` property. This rule helps maintain cleaner JSX code by removing unnecessary property access.",
url: getRuleDocUrl(ruleName),
},
fixable: 'code',
messages: {
preferDirectSignalUsage: 'Use the signal directly in JSX instead of accessing .value',
},
hasSuggestions: true,
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: {
preferDirectSignalUsage: { type: 'string', enum: ['error', 'warn', 'off'] },
},
additionalProperties: false,
},
suffix: { type: 'string', minLength: 1 },
extraCreatorModules: {
type: 'array',
items: { type: 'string', minLength: 1 },
default: [],
},
extraCreatorNames: {
type: 'array',
items: { type: 'string', minLength: 1 },
default: [],
},
extraCreatorNamespaces: {
type: 'array',
items: { type: 'string', minLength: 1 },
default: [],
},
suggestOnly: { type: 'boolean', default: false },
},
additionalProperties: false,
},
],
},
defaultOptions: [
{
performance: DEFAULT_PERFORMANCE_BUDGET,
severity: {
preferDirectSignalUsage: '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 suffix = typeof option?.suffix === 'string' && option.suffix.length > 0 ? option.suffix : 'Signal';
const suffixRegex = buildSuffixRegex(suffix);
startPhase(perfKey, 'ruleExecution');
const signalCreatorLocals = new Set(['signal']);
const computedCreatorLocals = new Set(['computed']);
const creatorNamespaces = new Set();
const creatorModules = new Set([
'@preact/signals-react',
...(Array.isArray(option?.extraCreatorModules) ? option.extraCreatorModules : []),
]);
const signalVariables = new Set();
// Seed from explicit options if provided
if (Array.isArray(option?.extraCreatorNames)) {
for (const n of option.extraCreatorNames) {
// Add to both sets so either name is treated as a creator of a signal or computed
signalCreatorLocals.add(n);
computedCreatorLocals.add(n);
}
}
if (Array.isArray(option?.extraCreatorNamespaces)) {
for (const ns of option.extraCreatorNamespaces) {
creatorNamespaces.add(ns);
}
}
return {
'*': (node) => {
if (!shouldContinue()) {
endPhase(perfKey, 'recordMetrics');
return;
}
perf.trackNode(node);
const dynamicOp =
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
PerformanceOperations[`${node.type}Processing`] ?? PerformanceOperations.nodeProcessing;
trackOperation(perfKey, dynamicOp);
},
[AST_NODE_TYPES.JSXElement]() {
jsxDepth++;
},
[`${AST_NODE_TYPES.JSXElement}:exit`]() {
jsxDepth--;
},
[`${AST_NODE_TYPES.JSXFragment}`]() {
jsxDepth++;
},
[`${AST_NODE_TYPES.JSXFragment}:exit`]() {
jsxDepth--;
},
[AST_NODE_TYPES.MemberExpression](node) {
if (jsxDepth === 0) {
return;
}
if (node.property.type !== AST_NODE_TYPES.Identifier) {
return;
}
if (node.property.name !== 'value' && node.property.name !== 'peek') {
return;
}
// Be conservative with optional chaining and bail
// - Direct optional on this member
// - Any ChainExpression ancestor (e.g., signal?.peek?.())
if (node.optional) {
return;
}
if (hasAncestorOfType(node, AST_NODE_TYPES.ChainExpression)) {
return;
}
if (node.object.type !== AST_NODE_TYPES.Identifier) {
if (node.object.type === AST_NODE_TYPES.MemberExpression) {
return;
}
if (node.parent.type === AST_NODE_TYPES.CallExpression) {
return;
}
return;
}
if (!hasSignalSuffix(node.object.name, suffixRegex) &&
!signalVariables.has(node.object.name)) {
return;
}
// Avoid fixing when part of complex expressions directly around the member
if ([
AST_NODE_TYPES.MemberExpression,
AST_NODE_TYPES.BinaryExpression,
AST_NODE_TYPES.UnaryExpression,
AST_NODE_TYPES.LogicalExpression,
].some((type) => {
return typeof node.parent !== 'undefined' && node.parent.type === type;
})) {
return;
}
// Do not fix if this member (or its chained parent member) is on the LHS of assignment
// or used as the argument of an update expression
{
let topMember = node;
let p = topMember.parent;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
while (p && p.type === AST_NODE_TYPES.MemberExpression && p.object === topMember) {
topMember = p;
p = topMember.parent;
}
if (
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
p &&
((p.type === AST_NODE_TYPES.AssignmentExpression && p.left === topMember) ||
(p.type === AST_NODE_TYPES.UpdateExpression && p.argument === topMember))) {
return;
}
}
if (isInJSXAttribute(node) || isInFunctionProp(node) || isInJSONStringify(node)) {
return;
}
// Allow `.value` when used as an argument to a function call inside JSX
if (isInJSXCallArgument(node)) {
return;
}
if (getSeverity('preferDirectSignalUsage', option) === 'off') {
return;
}
function applyReplacement(fixer) {
if ('name' in node.object) {
// For `.value`, replace the member expression with the identifier
if ('name' in node.property && node.property.name === 'value') {
return fixer.replaceText(node, node.object.name);
}
// For `.peek()`, replace the parent CallExpression if applicable
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
node.parent?.type === AST_NODE_TYPES.CallExpression &&
node.parent.callee === node) {
return fixer.replaceText(node.parent, node.object.name);
}
}
return null;
}
if (option?.suggestOnly === true) {
context.report({
node,
messageId: 'preferDirectSignalUsage',
suggest: [
{
messageId: 'preferDirectSignalUsage',
fix: applyReplacement,
},
],
});
}
else {
context.report({
node,
messageId: 'preferDirectSignalUsage',
fix: applyReplacement,
});
}
},
[AST_NODE_TYPES.VariableDeclarator](node) {
if (node.id.type !== AST_NODE_TYPES.Identifier) {
return;
}
if (!node.init || node.init.type !== AST_NODE_TYPES.CallExpression) {
return;
}
const callee = node.init.callee;
let isCreator = false;
if (callee.type === AST_NODE_TYPES.Identifier) {
if (signalCreatorLocals.has(callee.name) || computedCreatorLocals.has(callee.name)) {
isCreator = true;
}
}
else if (callee.type === AST_NODE_TYPES.MemberExpression &&
callee.object.type === AST_NODE_TYPES.Identifier &&
creatorNamespaces.has(callee.object.name) &&
callee.property.type === AST_NODE_TYPES.Identifier &&
(callee.property.name === 'signal' || callee.property.name === 'computed')) {
isCreator = true;
}
if (isCreator) {
signalVariables.add(node.id.name);
}
},
[AST_NODE_TYPES.Program](node) {
for (const stmt of node.body) {
if (stmt.type !== AST_NODE_TYPES.ImportDeclaration) {
continue;
}
if (typeof stmt.source.value === 'string' && creatorModules.has(stmt.source.value)) {
for (const spec of stmt.specifiers) {
if (spec.type === AST_NODE_TYPES.ImportSpecifier) {
if (spec.imported.type === AST_NODE_TYPES.Identifier &&
spec.imported.name === 'signal') {
signalCreatorLocals.add(spec.local.name);
}
else if (spec.imported.type === AST_NODE_TYPES.Identifier &&
spec.imported.name === 'computed') {
computedCreatorLocals.add(spec.local.name);
}
}
else if (spec.type === AST_NODE_TYPES.ImportNamespaceSpecifier) {
creatorNamespaces.add(spec.local.name);
}
}
}
}
},
[`${AST_NODE_TYPES.Program}:exit`]() {
startPhase(perfKey, 'programExit');
perf['Program:exit']();
endPhase(perfKey, 'programExit');
},
};
},
});
//# sourceMappingURL=prefer-signal-in-jsx.js.map