@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
520 lines • 24.5 kB
JavaScript
/** biome-ignore-all assist/source/organizeImports: off */
import { ESLintUtils, AST_NODE_TYPES, } from '@typescript-eslint/utils';
import { isInJSXContext, 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 getSeverity(messageId, options) {
if (!options?.severity) {
return 'error';
}
switch (messageId) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
case 'useValueInNonJSX': {
return options.severity.useValueInNonJSX ?? 'error';
}
default: {
return 'error';
}
}
}
function hasOptionalChainAncestor(node) {
let current = node.parent;
while (current) {
if (current.type === AST_NODE_TYPES.ChainExpression) {
return true;
}
if ((current.type === AST_NODE_TYPES.MemberExpression ||
current.type === AST_NODE_TYPES.CallExpression) &&
current.optional === true) {
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;
}
// Detect if an identifier is inside a React hook dependency array argument
function isInHookDependencyArray(node) {
// Walk up until we see an ArrayExpression that's directly an argument of a CallExpression
let current = node.parent;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
while (current) {
if (current.type === AST_NODE_TYPES.ArrayExpression) {
const parent = current.parent;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
if (parent && parent.type === AST_NODE_TYPES.CallExpression) {
// Find the position of this array within the call arguments
const argIndex = parent.arguments.indexOf(current);
if (argIndex === -1) {
return false;
}
// Identify hook name: Identifier or MemberExpression (.property)
let hookName = null;
if (parent.callee.type === AST_NODE_TYPES.Identifier) {
hookName = parent.callee.name;
}
else if (parent.callee.type === AST_NODE_TYPES.MemberExpression &&
parent.callee.property.type === AST_NODE_TYPES.Identifier) {
hookName = parent.callee.property.name;
}
if (hookName === null) {
return false;
}
// Hooks where deps array is at index 1
const depsIndexOne = new Set([
'useEffect',
'useLayoutEffect',
'useInsertionEffect',
'useMemo',
'useCallback',
]);
// Hooks where deps array is at index 2 (e.g. useImperativeHandle)
const depsIndexTwo = new Set(['useImperativeHandle']);
if ((depsIndexOne.has(hookName) && argIndex === 1) ||
(depsIndexTwo.has(hookName) && argIndex === 2)) {
return true;
}
return false;
}
}
// Stop early at boundaries where deps arrays won't be found above
if (current.type === AST_NODE_TYPES.FunctionDeclaration ||
current.type === AST_NODE_TYPES.FunctionExpression ||
current.type === AST_NODE_TYPES.ArrowFunctionExpression ||
current.type === AST_NODE_TYPES.Program) {
return false;
}
current = current.parent;
}
return false;
}
function isBindingOrWritePosition(node) {
const p = node.parent;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/strict-boolean-expressions
if (!p) {
return false;
}
// Direct writes like: fooSignal = ..., ++fooSignal, --fooSignal
if ((p.type === AST_NODE_TYPES.AssignmentExpression && p.left === node) ||
(p.type === AST_NODE_TYPES.UpdateExpression && p.argument === node)) {
return true;
}
// Function parameters: function f(fooSignal) {}
if ((p.type === AST_NODE_TYPES.FunctionDeclaration ||
p.type === AST_NODE_TYPES.FunctionExpression ||
p.type === AST_NODE_TYPES.ArrowFunctionExpression) &&
p.params.includes(node)) {
return true;
}
// Catch clause parameter
if (p.type === AST_NODE_TYPES.CatchClause && p.param === node) {
return true;
}
// Destructuring/binding patterns: const { fooSignal } = obj; const [fooSignal] = arr;
// Identifier as value of Property within ObjectPattern
if (p.type === AST_NODE_TYPES.Property &&
p.value === node &&
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
p.parent &&
p.parent.type === AST_NODE_TYPES.ObjectPattern) {
return true;
}
// Array pattern element
if (p.type === AST_NODE_TYPES.ArrayPattern) {
return true;
}
// Rest element within patterns
if (p.type === AST_NODE_TYPES.RestElement) {
return true;
}
// AssignmentPattern on the left side (default param or destructuring default)
if (p.type === AST_NODE_TYPES.AssignmentPattern && p.left === node) {
return true;
}
// VariableDeclarator with simple id
if (p.type === AST_NODE_TYPES.VariableDeclarator && p.id === node) {
return true;
}
// Declaration identifiers (names) are bindings too
// e.g. function Foo() {}, const Foo = () => {}, class Bar {}
if ((p.type === AST_NODE_TYPES.FunctionDeclaration && p.id === node) ||
(p.type === AST_NODE_TYPES.FunctionExpression && p.id === node) ||
(p.type === AST_NODE_TYPES.ClassDeclaration && p.id === node)) {
return true;
}
return false;
}
const ruleName = 'prefer-signal-reads';
export const preferSignalReadsRule = ESLintUtils.RuleCreator((name) => {
return getRuleDocUrl(name);
})({
name: ruleName,
meta: {
type: 'suggestion',
hasSuggestions: false,
docs: {
description: 'Enforces using `.value` when reading signal values in non-JSX contexts. In JSX, signals are automatically unwrapped, but in regular JavaScript/TypeScript code, you must explicitly access the `.value` property to read the current value of a signal. This rule helps catch cases where you might have forgotten to use `.value` when needed.',
url: getRuleDocUrl(ruleName),
},
messages: {
useValueInNonJSX: 'Use .value to read the current value of the signal in non-JSX context',
},
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 },
consumers: {
type: 'array',
items: { type: 'string', minLength: 1 },
default: [],
},
extraCreatorModules: {
type: 'array',
items: { type: 'string', minLength: 1 },
default: [],
},
typeAware: { type: 'boolean' },
severity: {
type: 'object',
properties: {
useValueInNonJSX: {
type: 'string',
enum: ['error', 'warn', 'off'],
default: 'error',
},
},
additionalProperties: false,
},
},
additionalProperties: false,
},
],
fixable: 'code',
},
defaultOptions: [
{
performance: DEFAULT_PERFORMANCE_BUDGET,
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;
}
const suffixRegex = buildSuffixRegex(typeof option?.suffix === 'string' && option.suffix.length > 0 ? option.suffix : 'Signal');
// Build a set of configured consumer names that accept Signal<T> directly.
const consumerAllowlist = new Set([
// default consumers that accept Signal instances directly
'subscribe',
...(Array.isArray(option?.consumers) ? option.consumers : []),
]);
startPhase(perfKey, 'ruleExecution');
// Avoid touching identifiers within TypeScript type positions
function isInTypePosition(node) {
const ancestors = context.sourceCode.getAncestors(node);
// If any TS* node is in the chain, conservatively treat as type position
if (ancestors.some((a) => {
return (a.type.startsWith('TS') ||
a.type === AST_NODE_TYPES.TSTypeAnnotation ||
a.type === AST_NODE_TYPES.TSTypeAliasDeclaration ||
a.type === AST_NODE_TYPES.TSInterfaceDeclaration ||
a.type === AST_NODE_TYPES.TSEnumDeclaration ||
a.type === AST_NODE_TYPES.TSModuleDeclaration);
})) {
return true;
}
if (!node.parent) {
return false;
}
// Direct parent in TS type constructs
switch (node.parent.type) {
case AST_NODE_TYPES.TSTypeReference:
case AST_NODE_TYPES.TSQualifiedName:
case AST_NODE_TYPES.TSTypeQuery:
case AST_NODE_TYPES.TSTypeOperator:
case AST_NODE_TYPES.TSTypePredicate:
case AST_NODE_TYPES.TSImportType:
case AST_NODE_TYPES.TSTypeAnnotation:
case AST_NODE_TYPES.TSTypeParameter:
case AST_NODE_TYPES.TSTypeLiteral:
case AST_NODE_TYPES.TSPropertySignature:
case AST_NODE_TYPES.TSMethodSignature:
case AST_NODE_TYPES.TSIndexSignature:
case AST_NODE_TYPES.TSInterfaceDeclaration:
case AST_NODE_TYPES.TSTypeAliasDeclaration:
case AST_NODE_TYPES.TSEnumDeclaration:
case AST_NODE_TYPES.TSModuleDeclaration: {
return true;
}
default: {
return false;
}
}
}
// Track local names and namespaces for signal/computed creators
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 : []),
]);
// 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 tsNode = context.sourceCode.parserServices.esTreeNodeToTSNodeMap.get(node);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/strict-boolean-expressions
if (!tsNode) {
return undefined;
}
const type = checker.getTypeAtLocation(tsNode);
if (typeof type.getProperty('value') !== 'undefined' &&
typeof type.getProperty('peek') !== 'undefined') {
return true;
}
const apparent = checker.getApparentType(type);
if (typeof apparent.getProperty('value') !== 'undefined' &&
typeof apparent.getProperty('peek') !== 'undefined') {
return true;
}
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.Identifier](node) {
if (node.type !== AST_NODE_TYPES.Identifier) {
return;
}
// Never modify identifiers inside TS type positions
if (isInTypePosition(node)) {
return;
}
const byHeuristicSuffix = hasSignalSuffix(node.name, suffixRegex);
const byVariableTracking = signalVariables.has(node.name);
let isSignalIdent = byHeuristicSuffix || byVariableTracking;
if (option?.typeAware === true) {
const byType = isSignalType(node);
if (byType === true) {
isSignalIdent = true;
}
else if (byType === false) {
isSignalIdent = byVariableTracking; // avoid suffix-only false positives if type says no
}
}
if (!isSignalIdent) {
return;
}
// Skip inside JSX elements/attributes
if (isInJSXContext(node) || isInJSXAttribute(node)) {
return;
}
// Be conservative: bail when inside optional chaining
if (hasOptionalChainAncestor(node)) {
return;
}
// Skip identifiers used inside React hook dependency arrays
if (isInHookDependencyArray(node)) {
return;
}
if (node.parent.type === AST_NODE_TYPES.MemberExpression &&
node.parent.object === node &&
'property' in node.parent &&
node.parent.property.type === AST_NODE_TYPES.Identifier &&
(node.parent.property.name === 'value' || node.parent.property.name === 'peek')) {
return;
}
// Skip if identifier is being written to or bound (not a read context)
if (isBindingOrWritePosition(node)) {
return;
}
// Allow member calls like signal.subscribe(...)
if (node.parent.type === AST_NODE_TYPES.MemberExpression &&
node.parent.object === node &&
node.parent.property.type === AST_NODE_TYPES.Identifier &&
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
node.parent.parent &&
node.parent.parent.type === AST_NODE_TYPES.CallExpression &&
node.parent.parent.callee === node.parent &&
consumerAllowlist.has(node.parent.property.name)) {
return;
}
if ((node.parent.type === AST_NODE_TYPES.CallExpression && node.parent.callee === node) ||
(node.parent.type === AST_NODE_TYPES.NewExpression && node.parent.callee === node) ||
// Object literal property key
(node.parent.type === AST_NODE_TYPES.Property && node.parent.key === node) ||
// Class method/field names (declaration keys)
(node.parent.type === AST_NODE_TYPES.MethodDefinition &&
node.parent.key === node &&
!node.parent.computed) ||
(node.parent.type === AST_NODE_TYPES.PropertyDefinition &&
node.parent.key === node &&
!node.parent.computed) ||
// Member property name (non-computed)
(node.parent.type === AST_NODE_TYPES.MemberExpression &&
node.parent.property === node &&
!node.parent.computed) ||
node.parent.type === AST_NODE_TYPES.ImportSpecifier ||
node.parent.type === AST_NODE_TYPES.ExportSpecifier ||
node.parent.type === AST_NODE_TYPES.LabeledStatement ||
node.parent.type === AST_NODE_TYPES.TSTypeReference ||
node.parent.type === AST_NODE_TYPES.TSQualifiedName ||
node.parent.type === AST_NODE_TYPES.TSTypeQuery ||
node.parent.type === AST_NODE_TYPES.TSTypeOperator) {
return;
}
if (node.parent.type === AST_NODE_TYPES.CallExpression &&
node.parent.arguments.includes(node)) {
let calleeName = null;
if (node.parent.callee.type === AST_NODE_TYPES.Identifier) {
calleeName = node.parent.callee.name;
}
else if (node.parent.callee.type === AST_NODE_TYPES.MemberExpression &&
node.parent.callee.property.type === AST_NODE_TYPES.Identifier) {
calleeName = node.parent.callee.property.name;
}
// Configured APIs that accept a Signal instance directly
if (calleeName !== null && consumerAllowlist.has(calleeName)) {
return;
}
}
if (getSeverity('useValueInNonJSX', option) === 'off') {
return;
}
context.report({
node,
messageId: 'useValueInNonJSX',
fix(fixer) {
return fixer.insertTextAfter(node, '.value');
},
});
},
[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' && 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-reads.js.map