@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
442 lines • 20.5 kB
JavaScript
/** biome-ignore-all assist/source/organizeImports: off */
import { ESLintUtils, AST_NODE_TYPES, } from '@typescript-eslint/utils';
import { isInJSXContext } 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 { isInDependencyArray } from './utils/react.js';
import { buildSuffixRegex, hasSignalSuffix } from './utils/suffix.js';
import { getRuleDocUrl } from './utils/urls.js';
let isInEffect = false;
let isInJSX = false;
let effectDepth = 0;
const ruleName = 'prefer-signal-methods';
function hasAncestorOfType(node, type) {
let current = node.parent;
while (current) {
if (current.type === type) {
return true;
}
if (current.type === AST_NODE_TYPES.Program ||
current.type === AST_NODE_TYPES.JSXElement ||
current.type === AST_NODE_TYPES.JSXFragment) {
return false;
}
current = current.parent;
}
return false;
}
function getSeverity(messageId, options) {
if (!options?.severity) {
return 'error';
}
switch (messageId) {
case 'usePeekInEffect': {
return options.severity.usePeekInEffect ?? 'error';
}
case 'preferPeekInNonReactiveContext': {
return options.severity.preferPeekInNonReactiveContext ?? 'error';
}
default: {
return 'error';
}
}
}
export const preferSignalMethodsRule = ESLintUtils.RuleCreator((name) => {
return getRuleDocUrl(name);
})({
name: ruleName,
meta: {
type: 'suggestion',
fixable: 'code',
hasSuggestions: true,
docs: {
description: 'Enforces proper usage of signal methods (`.value`, `.peek()`) in non-JSX contexts. This rule helps ensure you use the right access pattern for effects and regular code, promoting best practices to optimize reactivity and performance.',
url: getRuleDocUrl(ruleName),
},
messages: {
usePeekInEffect: 'Use signal.peek() to read the current value without subscribing to changes in this effect',
preferPeekInNonReactiveContext: 'Prefer .peek() when reading signal value without using its reactive value',
},
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,
},
suffix: { type: 'string', minLength: 1 },
extraCreatorModules: {
type: 'array',
items: { type: 'string', minLength: 1 },
},
reactiveEffectCallees: {
type: 'array',
items: { type: 'string', minLength: 1 },
},
effectsSuggestionOnly: { type: 'boolean' },
typeAware: { type: 'boolean' },
},
additionalProperties: false,
},
],
},
defaultOptions: [
{
performance: DEFAULT_PERFORMANCE_BUDGET,
extraCreatorModules: [],
reactiveEffectCallees: [],
effectsSuggestionOnly: false,
typeAware: false,
},
],
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;
}
startPhase(perfKey, 'ruleExecution');
// Track local names and namespaces for signal/computed creators
const signalCreatorLocals = new Set(['signal']);
const computedCreatorLocals = new Set(['computed']);
const creatorNamespaces = new Set();
// Track variables initialized from signal/computed creators
const signalVariables = new Set();
const checker = context.sourceCode.parserServices?.program?.getTypeChecker();
function isSignalType(node) {
if (!checker ||
!context.sourceCode.parserServices ||
!('esTreeNodeToTSNodeMap' in context.sourceCode.parserServices)) {
return undefined;
}
const anyServices = context.sourceCode.parserServices;
const tsNode = anyServices.esTreeNodeToTSNodeMap?.get(node);
if (!tsNode) {
return undefined;
}
const type = checker.getTypeAtLocation(tsNode);
// Heuristic: has properties 'value' and 'peek'
const hasValue = type.getProperty('value');
const hasPeek = type.getProperty('peek');
if (hasValue && hasPeek) {
return true;
}
// Also check apparent type
const apparent = checker.getApparentType(type);
const aHasValue = apparent.getProperty('value');
const aHasPeek = apparent.getProperty('peek');
if (aHasValue && aHasPeek) {
return true;
}
// Try to detect named type 'Signal'
const sym = type.aliasSymbol ?? type.symbol;
if (typeof sym !== 'undefined' &&
(sym.escapedName === 'Signal' || sym.escapedName === 'ReadableSignal')) {
return true;
}
return false;
}
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.CallExpression](node) {
// Detect configured effect-like callees
const names = new Set([
'useEffect',
'useLayoutEffect',
...(option?.reactiveEffectCallees ?? []),
]);
const callee = node.callee;
let name;
if (callee.type === AST_NODE_TYPES.Identifier) {
name = callee.name;
}
else if (callee.type === AST_NODE_TYPES.MemberExpression &&
callee.property.type === AST_NODE_TYPES.Identifier) {
name = callee.property.name;
}
if (typeof name !== 'undefined' && names.has(name)) {
effectDepth++;
isInEffect = effectDepth > 0;
}
},
[`${AST_NODE_TYPES.CallExpression}:exit`](node) {
const names = new Set([
'useEffect',
'useLayoutEffect',
...(option?.reactiveEffectCallees ?? []),
]);
let name;
if (node.callee.type === AST_NODE_TYPES.Identifier) {
name = node.callee.name;
}
else if (node.callee.type === AST_NODE_TYPES.MemberExpression &&
node.callee.property.type === AST_NODE_TYPES.Identifier) {
name = node.callee.property.name;
}
if (typeof name !== 'undefined' && names.has(name)) {
effectDepth = Math.max(0, effectDepth - 1);
isInEffect = effectDepth > 0;
}
isInJSX = false;
},
[`${AST_NODE_TYPES.JSXFragment}`]() {
isInJSX = true;
},
[`${AST_NODE_TYPES.JSXFragment}:exit`]() {
isInJSX = false;
},
[AST_NODE_TYPES.Identifier](node) {
if (node.type !== AST_NODE_TYPES.Identifier) {
return;
}
let isSignalIdent = hasSignalSuffix(node.name, buildSuffixRegex(typeof option?.suffix === 'string' && option.suffix.length > 0
? option.suffix
: 'Signal')) || signalVariables.has(node.name);
if (option?.typeAware === true) {
const byType = isSignalType(node);
if (byType === true) {
isSignalIdent = true;
}
else if (byType === false) {
// If we have a definitive non-signal type, rely on variable tracking only to avoid suffix false-positives
isSignalIdent = signalVariables.has(node.name);
}
}
if (!isSignalIdent) {
return;
}
if (node.parent.type !== AST_NODE_TYPES.MemberExpression || node.parent.object !== node) {
if (isInEffect && !isInDependencyArray(node)) {
if (getSeverity('usePeekInEffect', option) === 'off') {
return;
}
if (option?.effectsSuggestionOnly === true) {
context.report({
node,
messageId: 'usePeekInEffect',
suggest: [
{
messageId: 'usePeekInEffect',
fix(fixer) {
return fixer.insertTextAfter(node, '.peek()');
},
},
],
});
}
else {
context.report({
node,
messageId: 'usePeekInEffect',
fix(fixer) {
return fixer.insertTextAfter(node, '.peek()');
},
});
}
}
else if (isInJSX || isInJSXContext(node)) {
return;
}
return;
}
// Optional chaining handling:
// - Generally bail on ChainExpression to stay conservative
// - But allow direct optional on the member when property is 'value' in effect context,
// so we can safely convert `signal?.value` -> `signal?.peek()`.
if (hasAncestorOfType(node, AST_NODE_TYPES.ChainExpression)) {
const allowDirectOptionalOnValueInEffect = node.parent.optional === true &&
'name' in node.parent.property &&
node.parent.property.name === 'value' &&
isInEffect &&
!isInDependencyArray(node);
if (!allowDirectOptionalOnValueInEffect) {
return;
}
}
if (!('name' in node.parent.property)) {
return;
}
// Delegate JSX `.value` handling to prefer-signal-in-jsx to avoid duplicates
if ((isInJSX || isInJSXContext(node)) && node.parent.property.name === 'value') {
return;
}
// Delegate JSX `.peek()` handling to prefer-signal-in-jsx to avoid duplicates
if ((isInJSX || isInJSXContext(node)) && node.parent.property.name === 'peek') {
return;
}
// Do not flag writes: if this MemberExpression (or its chained parent MemberExpressions)
// is used as the left-hand side of an assignment or as the argument of an update, skip.
{
const memberExpr = node.parent;
// Bubble up through chained MemberExpressions: signal.value[...].foo...
let topMember = memberExpr;
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 (!(isInEffect && !isInDependencyArray(node) && node.parent.property.name === 'value')) {
return;
}
if (getSeverity('preferPeekInNonReactiveContext', option) === 'off') {
return;
}
const applyFix = (fixer) => {
// If this member uses direct optional chaining like `signal?.value`,
// replace the entire member with `object?.peek()` to preserve short-circuiting.
if ('optional' in node.parent &&
'object' in node.parent &&
node.parent.optional === true) {
return fixer.replaceText(node.parent, `${context.sourceCode.getText(node.parent.object)}?.peek()`);
}
// Fallback: replace only the property name `value` -> `peek()`
if ('property' in node.parent) {
return fixer.replaceText(node.parent.property, 'peek()');
}
return null;
};
if (option?.effectsSuggestionOnly === true) {
context.report({
node: node.parent.property,
messageId: 'preferPeekInNonReactiveContext',
suggest: [
{
messageId: 'preferPeekInNonReactiveContext',
fix: applyFix,
},
],
});
}
else {
context.report({
node: node.parent.property,
messageId: 'preferPeekInNonReactiveContext',
fix: applyFix,
});
}
},
[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;
}
let isCreator = false;
if (node.init.callee.type === AST_NODE_TYPES.Identifier) {
if (signalCreatorLocals.has(node.init.callee.name) ||
computedCreatorLocals.has(node.init.callee.name)) {
isCreator = true;
}
}
else if (node.init.callee.type === AST_NODE_TYPES.MemberExpression &&
node.init.callee.object.type === AST_NODE_TYPES.Identifier &&
creatorNamespaces.has(node.init.callee.object.name) &&
node.init.callee.property.type === AST_NODE_TYPES.Identifier &&
(node.init.callee.property.name === 'signal' ||
node.init.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') {
continue;
}
const allowedModules = new Set([
'@preact/signals-react',
...(option?.extraCreatorModules ?? []).filter((s) => {
return typeof s === 'string';
}),
]);
if (allowedModules.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-methods.js.map