@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
1,147 lines (1,146 loc) • 60.8 kB
JavaScript
import { ESLintUtils, AST_NODE_TYPES } from '@typescript-eslint/utils';
import ts from 'typescript';
import { ensureNamedImportFixes } from './utils/imports.js';
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';
// Per-context caches to avoid repeated subtree walks
const updateCacheByContext = new WeakMap();
const readCacheByContext = new WeakMap();
// Narrow unknown object values to ESTree nodes
function isESTreeNode(value) {
return typeof value === 'object' && value !== null && 'type' in value;
}
// Recursively check if a node subtree contains any signal update
function containsSignalUpdate(node, context) {
let cache = updateCacheByContext.get(context);
if (!cache) {
cache = new WeakMap();
updateCacheByContext.set(context, cache);
}
const cached = cache.get(node);
if (typeof cached !== 'undefined') {
return cached;
}
// If this node itself is an update, return true
if (isSignalUpdate(node, context)) {
cache.set(node, true);
return true;
}
// For block statements, explicitly iterate children
if (node.type === AST_NODE_TYPES.BlockStatement) {
for (const s of node.body) {
if (containsSignalUpdate(s, context)) {
cache.set(node, true);
return true;
}
}
cache.set(node, false);
return false;
}
// Generic shallow walk similar to containsSignalRead
for (const key of Object.keys(node)) {
if (key === 'parent') {
continue;
}
const value = node[key];
if (typeof value === 'undefined') {
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
if (isESTreeNode(item) && containsSignalUpdate(item, context)) {
cache.set(node, true);
return true;
}
}
}
else if (isESTreeNode(value) && containsSignalUpdate(value, context)) {
cache.set(node, true);
return true;
}
}
cache.set(node, false);
return false;
}
// Heuristic: statements considered trivial/ignorable for batching contiguity
function isTriviallyIgnorableStatement(s, _context) {
switch (s.type) {
case AST_NODE_TYPES.EmptyStatement: {
return true;
}
case AST_NODE_TYPES.VariableDeclaration: {
// Safe only if no initializers at all
return s.declarations.every((d) => {
return typeof d.init === 'undefined' || d.init === null;
});
}
case AST_NODE_TYPES.ExpressionStatement: {
// Allow pure literals or template literals with no expressions
const e = s.expression;
if (e.type === AST_NODE_TYPES.Literal) {
return true;
}
if (e.type === AST_NODE_TYPES.TemplateLiteral && e.expressions.length === 0) {
return true;
}
return false;
}
default: {
return false;
}
}
}
function onlyTrivialBetween(first, last, context) {
const block = context.sourceCode
.getAncestors(first)
.reverse()
.find((a) => a.type === AST_NODE_TYPES.BlockStatement);
if (!block) {
return false;
}
const idxFirst = block.body.findIndex((s) => {
return first.range[0] >= s.range[0] && first.range[1] <= s.range[1];
});
const idxLast = block.body.findIndex((s) => {
return last.range[0] >= s.range[0] && last.range[1] <= s.range[1];
});
if (idxFirst === -1 || idxLast === -1 || idxLast < idxFirst) {
return false;
}
for (let i = idxFirst + 1; i < idxLast; i++) {
// eslint-disable-next-line security/detect-object-injection
const s = block.body[i];
if (!s) {
continue;
}
if (!isTriviallyIgnorableStatement(s, context)) {
return false;
}
}
return true;
}
// Count statements between first and last that are NOT trivially ignorable
function countNonTrivialBetween(first, last, context) {
const block = context.sourceCode
.getAncestors(first)
.reverse()
.find((a) => a.type === AST_NODE_TYPES.BlockStatement);
if (!block) {
return Number.POSITIVE_INFINITY;
}
const idxFirst = block.body.findIndex((s) => {
return first.range[0] >= s.range[0] && first.range[1] <= s.range[1];
});
const idxLast = block.body.findIndex((s) => {
return last.range[0] >= s.range[0] && last.range[1] <= s.range[1];
});
if (idxFirst === -1 || idxLast === -1 || idxLast < idxFirst) {
return Number.POSITIVE_INFINITY;
}
let count = 0;
for (let i = idxFirst + 1; i < idxLast; i++) {
// eslint-disable-next-line security/detect-object-injection
const s = block.body[i];
if (!s) {
continue;
}
if (!isTriviallyIgnorableStatement(s, context))
count += 1;
}
return count;
}
function isSafeAutofixRange(context, start, end) {
if (end <= start) {
return true;
}
return (context.sourceCode.text
.slice(start, end)
// eslint-disable-next-line optimize-regex/optimize-regex
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|\n)\s*\/\/.*(?=\n|$)/g, '\n')
.replace(/[\s;]+/g, '').length === 0);
}
// Conservative CFG-aware guard: avoid wrapping across control-flow/structural statements
function hasControlFlowBoundaryBetween(first, last, context) {
const block = context.sourceCode
.getAncestors(first)
.reverse()
.find((a) => {
return a.type === AST_NODE_TYPES.BlockStatement;
});
if (!block) {
return true;
}
const idxFirst = block.body.findIndex((s) => {
return first.range[0] >= s.range[0] && first.range[1] <= s.range[1];
});
const idxLast = block.body.findIndex((s) => {
return last.range[0] >= s.range[0] && last.range[1] <= s.range[1];
});
if (idxFirst === -1 || idxLast === -1 || idxLast < idxFirst) {
return true;
}
for (let i = idxFirst + 1; i < idxLast; i++) {
// eslint-disable-next-line security/detect-object-injection
const s = block.body[i];
if (typeof s === 'undefined') {
continue;
}
switch (s.type) {
case AST_NODE_TYPES.ReturnStatement:
case AST_NODE_TYPES.ThrowStatement:
case AST_NODE_TYPES.BreakStatement:
case AST_NODE_TYPES.ContinueStatement:
case AST_NODE_TYPES.IfStatement:
case AST_NODE_TYPES.SwitchStatement:
case AST_NODE_TYPES.TryStatement:
case AST_NODE_TYPES.ForStatement:
case AST_NODE_TYPES.ForInStatement:
case AST_NODE_TYPES.ForOfStatement:
case AST_NODE_TYPES.WhileStatement:
case AST_NODE_TYPES.DoWhileStatement:
case AST_NODE_TYPES.LabeledStatement:
case AST_NODE_TYPES.WithStatement:
return true;
default:
break;
}
}
return false;
}
function getSeverity(messageId, options) {
if (typeof options?.severity === 'undefined') {
return 'error';
}
switch (messageId) {
case 'useBatch': {
return options.severity.useBatch ?? 'error';
}
case 'suggestUseBatch': {
return options.severity.suggestUseBatch ?? 'warn';
}
case 'addBatchImport': {
return options.severity.addBatchImport ?? 'error';
}
case 'wrapWithBatch': {
return options.severity.wrapWithBatch ?? 'error';
}
case 'useBatchSuggestion': {
return options.severity.useBatchSuggestion ?? 'warn';
}
case 'removeUnnecessaryBatch': {
return options.severity.removeUnnecessaryBatch ?? 'error';
}
case 'nonUpdateSignalInBatch': {
return options.severity.nonUpdateSignalInBatch ?? 'warn';
}
case 'updatesSeparatedByCode': {
return options.severity.updatesSeparatedByCode ?? 'warn';
}
default: {
return 'error';
}
}
}
let isProcessedByHandlers = false;
const DEFAULT_MIN_UPDATES = 2;
const updatesInScope = [];
const allUpdates = [];
let trackedSignalVars = new Set();
function processBlock(statements, context, perfKey, scopeDepth = 0, inBatch = false) {
if (isProcessedByHandlers) {
return [];
}
// Reset accumulators for a fresh analysis pass at the top-level invocation
if (scopeDepth === 0) {
updatesInScope.length = 0;
allUpdates.length = 0;
}
const minUpdates = context.options[0]?.minUpdates ?? DEFAULT_MIN_UPDATES;
if (inBatch) {
recordMetric(perfKey, PerformanceOperations.skipProcessing, {
scopeDepth,
statementCount: statements.length,
});
return [];
}
recordMetric(perfKey, 'processBlockStart', {
scopeDepth,
inBatch,
statementCount: statements.length,
});
const hasBatchImport = context.sourceCode.ast.body.some((node) => {
return (node.type === AST_NODE_TYPES.ImportDeclaration &&
node.source.value === '@preact/signals-react' &&
node.specifiers.some((specifier) => {
return ('imported' in specifier &&
'name' in specifier.imported &&
specifier.imported.name === 'batch');
}));
});
for (const stmt of statements) {
if (stmt.type !== AST_NODE_TYPES.ExpressionStatement) {
if (stmt.type === AST_NODE_TYPES.BlockStatement) {
allUpdates.push(...processBlock(stmt.body, context, perfKey, scopeDepth + 1, inBatch));
}
continue;
}
if (isBatchCall(stmt.expression, context)) {
if (stmt.expression.type === AST_NODE_TYPES.CallExpression &&
stmt.expression.arguments.length > 0 &&
(stmt.expression.arguments[0]?.type === AST_NODE_TYPES.ArrowFunctionExpression ||
stmt.expression.arguments[0]?.type === AST_NODE_TYPES.FunctionExpression) &&
stmt.expression.arguments[0].body.type === AST_NODE_TYPES.BlockStatement) {
recordMetric(perfKey, 'skipBatchBody', { scopeDepth });
}
continue;
}
if (isSignalUpdate(stmt.expression, context)) {
const updateType = getUpdateType(stmt.expression);
const signalName = getSignalName(stmt.expression);
recordMetric(perfKey, 'signalUpdateFound', {
type: updateType,
location: scopeDepth === 0 ? 'top-level' : `nested-${scopeDepth}`,
signalName,
hasBatchImport,
inBatchScope: inBatch,
});
updatesInScope.push({
node: stmt.expression,
isTopLevel: scopeDepth === 0,
signalName,
updateType,
scopeDepth,
});
}
}
recordMetric(perfKey, 'processBlockEnd', {
scopeDepth,
totalUpdates: updatesInScope.length,
uniqueSignals: new Set(updatesInScope.map((u) => {
return u.signalName;
})).size,
hasBatchImport,
minUpdatesRequired: context.options[0]?.minUpdates,
});
allUpdates.push(...updatesInScope);
if (typeof minUpdates === 'number' && updatesInScope.length < minUpdates) {
recordMetric(perfKey, 'batchUpdateNotNeeded', {
scopeDepth,
updateCount: updatesInScope.length,
minUpdates,
});
return allUpdates;
}
const firstNode = updatesInScope[0]?.node;
recordMetric(perfKey, 'batchUpdateSuggested', {
updateCount: updatesInScope.length,
uniqueSignals: new Set(updatesInScope.map((u) => {
return u.signalName;
})).size,
});
if (typeof firstNode === 'undefined') {
return allUpdates;
}
const messageId = 'useBatch';
if (updatesInScope.length >= minUpdates && !isInsideBatchCall(firstNode, context)) {
// If there is any non-update code between first and last updates in this scope, warn separately
const firstUpdateNode = updatesInScope[0]?.node;
const lastUpdateNode = updatesInScope[updatesInScope.length - 1]?.node;
if (typeof firstUpdateNode !== 'undefined' &&
typeof lastUpdateNode !== 'undefined' &&
// Control-flow boundaries are always unsafe
(hasControlFlowBoundaryBetween(firstUpdateNode, lastUpdateNode, context) ||
// Otherwise, if range has non-trivial tokens, allow if trivial or within threshold between
(!isSafeAutofixRange(context, firstUpdateNode.range[1], lastUpdateNode.range[0]) &&
(() => {
if (onlyTrivialBetween(firstUpdateNode, lastUpdateNode, context)) {
return false;
}
return (countNonTrivialBetween(firstUpdateNode, lastUpdateNode, context) >
(context.options[0]?.detection?.allowNonTrivialBetween ?? 0));
})()))) {
if (getSeverity('updatesSeparatedByCode', context.options[0]) !== 'off') {
context.report({
node: firstNode,
messageId: 'updatesSeparatedByCode',
data: { count: updatesInScope.length },
});
}
}
else if (getSeverity(messageId, context.options[0]) !== 'off') {
context.report({
node: firstNode,
messageId,
data: {
count: updatesInScope.length,
signals: Array.from(new Set(allUpdates.map((update) => {
return update.signalName;
}))).join(', '),
},
suggest: [
{
messageId: 'useBatchSuggestion',
data: { count: updatesInScope.length },
*fix(fixer) {
const firstUpdate = updatesInScope[0]?.node;
const lastUpdate = updatesInScope[updatesInScope.length - 1]?.node;
if (!firstUpdate || !lastUpdate) {
return null;
}
// Guard: ensure no non-update code or control-flow boundary exists between updates
if (hasControlFlowBoundaryBetween(firstUpdate, lastUpdate, context) ||
(!isSafeAutofixRange(context, firstUpdate.range[1], lastUpdate.range[0]) &&
(() => {
if (onlyTrivialBetween(firstUpdate, lastUpdate, context)) {
return false;
}
return (countNonTrivialBetween(firstUpdate, lastUpdate, context) >
(context.options[0]?.detection?.allowNonTrivialBetween ?? 0));
})())) {
return null;
}
const b = context.sourceCode.ast.body[0];
if (!b) {
return null;
}
if (!hasBatchImport) {
const fixes = ensureNamedImportFixes({ sourceCode: context.sourceCode }, fixer, '@preact/signals-react', 'batch');
for (const f of fixes) {
yield f;
}
}
const baseIndentMatch = context.sourceCode.text
.slice(context.sourceCode.text.lastIndexOf('\n', firstUpdate.range[0] - 1) + 1, firstUpdate.range[0])
.match(/^\s*/);
const baseIndent = baseIndentMatch ? baseIndentMatch[0] : '';
const innerIndent = `${baseIndent} `;
yield fixer.replaceTextRange([firstUpdate.range[0], lastUpdate.range[1]], `batch(() => {\n${innerIndent}${context.sourceCode.text.slice(firstUpdate.range[0], lastUpdate.range[1]).replace(/\n/g, `\n${innerIndent}`)}\n${baseIndent}});`);
recordMetric(perfKey, 'batchFixApplied', {
updateCount: updatesInScope.length,
});
return null;
},
},
{
messageId: 'addBatchImport',
data: {
count: updatesInScope.length,
},
*fix(fixer) {
if (hasBatchImport) {
return;
}
const fixes = ensureNamedImportFixes({ sourceCode: context.sourceCode }, fixer, '@preact/signals-react', 'batch');
for (const f of fixes) {
yield f;
}
},
},
],
});
}
}
return allUpdates;
}
const batchScopeStack = [false];
function pushBatchScope(inBatch) {
batchScopeStack.push(inBatch);
}
function popBatchScope() {
const popped = batchScopeStack.pop();
if (batchScopeStack.length === 0) {
batchScopeStack.push(false);
}
return popped ?? false;
}
function isInsideBatchCall(node, context) {
if (batchScopeStack.length > 0 && batchScopeStack[batchScopeStack.length - 1] === true) {
return true;
}
for (const ancestor of context.sourceCode.getAncestors(node)) {
if (ancestor.type === AST_NODE_TYPES.CallExpression &&
isBatchCall(ancestor, context) &&
ancestor.arguments.length > 0 &&
typeof ancestor.arguments[0] !== 'undefined' &&
'body' in ancestor.arguments[0] &&
'range' in ancestor.arguments[0].body) {
return (node.range[0] >= ancestor.arguments[0].body.range[0] &&
node.range[1] <= ancestor.arguments[0].body.range[1]);
}
}
return false;
}
function isBatchCall(node, context) {
if (node.type !== AST_NODE_TYPES.CallExpression) {
return false;
}
if (node.callee.type === AST_NODE_TYPES.Identifier && node.callee.name === 'batch') {
return true;
}
if (node.callee.type === AST_NODE_TYPES.Identifier) {
const variable = context.sourceCode.getScope(node).variables.find((v) => {
return 'name' in node.callee && v.name === node.callee.name;
});
if (typeof variable !== 'undefined') {
return variable.defs.some((def) => {
if (def.type === 'ImportBinding') {
return ('imported' in def.node &&
'name' in def.node.imported &&
def.node.imported.name === 'batch');
}
return false;
});
}
}
return false;
}
function getUpdateType(node) {
if (node.type === AST_NODE_TYPES.AssignmentExpression) {
return 'assignment';
}
if (node.type === AST_NODE_TYPES.CallExpression) {
return 'method';
}
return 'update';
}
function getSignalName(node) {
if (node.type === AST_NODE_TYPES.AssignmentExpression) {
if (node.left.type === AST_NODE_TYPES.MemberExpression &&
!node.left.computed &&
node.left.property.type === AST_NODE_TYPES.Identifier &&
node.left.property.name === 'value' &&
node.left.object.type === AST_NODE_TYPES.Identifier) {
return node.left.object.name;
}
}
else if (node.type === AST_NODE_TYPES.CallExpression &&
node.callee.type === AST_NODE_TYPES.MemberExpression &&
!node.callee.computed &&
node.callee.property.type === AST_NODE_TYPES.Identifier &&
(node.callee.property.name === 'set' || node.callee.property.name === 'update') &&
node.callee.object.type === AST_NODE_TYPES.Identifier) {
return node.callee.object.name;
}
return 'signal';
}
function isSignalUpdate(node, context) {
if (node.type === AST_NODE_TYPES.AssignmentExpression) {
if (node.left.type === AST_NODE_TYPES.MemberExpression &&
node.left.property.type === AST_NODE_TYPES.Identifier &&
node.left.property.name === 'value' &&
isSignalReference(node.left.object, context)) {
return true;
}
if (node.operator !== '=' &&
node.left.type === AST_NODE_TYPES.MemberExpression &&
node.left.property.type === AST_NODE_TYPES.Identifier &&
node.left.property.name === 'value' &&
isSignalReference(node.left.object, context)) {
return true;
}
}
if (node.type === AST_NODE_TYPES.CallExpression &&
node.callee.type === AST_NODE_TYPES.MemberExpression &&
node.callee.property.type === AST_NODE_TYPES.Identifier &&
(node.callee.property.name === 'set' ||
(node.callee.property.name === 'update' &&
(context.options[0]?.detection?.ignoreUpdateCalls !== true))) &&
isSignalReference(node.callee.object, context)) {
return true;
}
if (node.type === AST_NODE_TYPES.UpdateExpression &&
node.argument.type === AST_NODE_TYPES.MemberExpression &&
node.argument.property.type === AST_NODE_TYPES.Identifier &&
node.argument.property.name === 'value' &&
isSignalReference(node.argument.object, context)) {
return true;
}
return false;
}
const cacheByProgram = new WeakMap();
function getCache(program) {
let c = cacheByProgram.get(program);
if (!c) {
c = { symbolOrigin: new WeakMap() };
cacheByProgram.set(program, c);
}
return c;
}
function getProgram(context) {
const services = context.sourceCode.parserServices;
if (typeof services === 'undefined' ||
typeof services.program === 'undefined' ||
services.program === null ||
typeof services.esTreeNodeToTSNodeMap === 'undefined') {
return null;
}
return services.program;
}
function resolveTsNode(node, context) {
const services = context.sourceCode.parserServices;
if (typeof services === 'undefined' || typeof services.esTreeNodeToTSNodeMap === 'undefined') {
return null;
}
try {
return services.esTreeNodeToTSNodeMap.get(node);
}
catch {
return null;
}
}
function resolveSymbolAt(id, context) {
const program = getProgram(context);
const tsNode = resolveTsNode(id, context);
if (!program || !tsNode)
return null;
const checker = program.getTypeChecker();
try {
return checker.getSymbolAtLocation(tsNode) ?? null;
}
catch {
return null;
}
}
// Configurable module detection for signals
let SIGNAL_MODULES = new Set(['@preact/signals-react']);
function isFromSignalsReact(decls) {
if (!decls) {
return false;
}
for (const d of decls) {
// Match against any configured module substring
for (const mod of SIGNAL_MODULES) {
if (d.getSourceFile().fileName.includes(mod)) {
return true;
}
}
if (d.getSourceFile().fileName.includes('@preact/signals-react')) {
return true;
}
}
return false;
}
function isSignalType(symbol, checker) {
const decl = symbol.valueDeclaration ?? symbol.getDeclarations()?.[0];
if (typeof decl === 'undefined') {
return false;
}
const type = checker.getTypeOfSymbolAtLocation(symbol, decl);
// Some types (e.g., unions/intersections/anonymous types) may not have a symbol.
// Guard all symbol dereferences to avoid runtime crashes.
if (
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
type.symbol &&
['Signal', 'ReadableSignal', 'WritableSignal'].includes(String(type.symbol.escapedName)) &&
typeof type.symbol.getDeclarations === 'function' &&
isFromSignalsReact(type.symbol.getDeclarations())) {
return true;
}
// Structural fallback: has a readonly `.value` and methods `set`/`update` on the instance type
const valueProp = type.getProperty('value');
if (typeof valueProp === 'undefined') {
return false;
}
const vpDecl = valueProp.valueDeclaration ?? valueProp.declarations?.[0];
if (vpDecl && isFromSignalsReact(valueProp.getDeclarations())) {
return true;
}
return false;
}
function getImportedNameAndModuleFromCall(call, checker) {
const expr = call.expression;
// handle direct identifier: useSignal()/computed()
if (ts.isIdentifier(expr)) {
const sym = checker.getSymbolAtLocation(expr);
if (typeof sym === 'undefined') {
return null;
}
const aliased = sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
return {
name: aliased.getName(),
module: aliased.declarations?.[0]?.getSourceFile().fileName ?? '',
};
}
if (ts.isPropertyAccessExpression(expr)) {
const leftSym = checker.getSymbolAtLocation(expr.expression);
return {
name: expr.name.getText(),
module: (typeof leftSym !== 'undefined' && leftSym.flags & ts.SymbolFlags.Alias
? checker.getAliasedSymbol(leftSym)
: leftSym)?.declarations?.[0]?.getSourceFile().fileName ?? '',
};
}
return null;
}
function traceSignalOrigin(symbol, checker, program) {
const cache = getCache(program);
const cached = cache.symbolOrigin.get(symbol);
if (cached !== undefined) {
return cached;
}
// Direct declarations
const decls = symbol.getDeclarations() ?? [];
for (const d of decls) {
if (ts.isVariableDeclaration(d)) {
const init = d.initializer;
if (typeof init !== 'undefined' && ts.isCallExpression(init)) {
const info = getImportedNameAndModuleFromCall(init, checker);
if (info !== null && info.module.includes('@preact/signals-react') === true) {
if (info.name === 'useSignal') {
const res = {
kind: 'useSignal',
sourceModule: info.module,
};
cache.symbolOrigin.set(symbol, res);
return res;
}
if (info.name === 'computed') {
const res = {
kind: 'computed',
sourceModule: info.module,
};
cache.symbolOrigin.set(symbol, res);
return res;
}
}
}
// Aliasing: const a = b; follow b
if (init && ts.isIdentifier(init)) {
const s = checker.getSymbolAtLocation(init);
if (typeof s !== 'undefined') {
const aliased = s.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s;
const traced = traceSignalOrigin(aliased, checker, program);
if (traced !== null) {
cache.symbolOrigin.set(symbol, traced);
return traced;
}
}
}
}
if (symbol.flags & ts.SymbolFlags.Alias) {
const traced = traceSignalOrigin(checker.getAliasedSymbol(symbol), checker, program);
if (traced !== null) {
cache.symbolOrigin.set(symbol, traced);
return traced;
}
}
}
cache.symbolOrigin.set(symbol, null);
return null;
}
function isSignalIdentifier(id, context) {
const program = getProgram(context);
if (program === null || !resolveTsNode(id, context)) {
return false;
}
const checker = program.getTypeChecker();
const symbol = resolveSymbolAt(id, context);
if (symbol === null) {
return false;
}
if (isSignalType(symbol, checker)) {
return true;
}
const origin = traceSignalOrigin(symbol, checker, program);
return origin !== null;
}
function isSignalReference(node, context) {
if (node.type === AST_NODE_TYPES.Identifier) {
return isSignalIdentifier(node, context);
}
if (node.type === AST_NODE_TYPES.MemberExpression &&
node.property.type === AST_NODE_TYPES.Identifier &&
node.property.name === 'value') {
return isSignalReference(node.object, context);
}
return false;
}
function containsSignalRead(node, context) {
let cache = readCacheByContext.get(context);
if (!cache) {
cache = new WeakMap();
readCacheByContext.set(context, cache);
}
const cached = cache.get(node);
if (typeof cached !== 'undefined') {
return cached;
}
// Direct identifier like `countSignal`
if (node.type === AST_NODE_TYPES.Identifier) {
const res = isSignalReference(node, context);
cache.set(node, res);
return res;
}
// Member expression like `countSignal.value` or deeper
if (node.type === AST_NODE_TYPES.MemberExpression) {
if (isSignalReference(node.object, context)) {
cache.set(node, true);
return true;
}
return (
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
(node.object && containsSignalRead(node.object, context)) ||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
(node.property &&
node.property.type !== AST_NODE_TYPES.PrivateIdentifier &&
containsSignalRead(node.property, context)));
}
for (const key of Object.keys(node)) {
if (key === 'parent') {
continue;
}
const value = node[key];
if (typeof value === 'undefined') {
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
if (isESTreeNode(item) && containsSignalRead(item, context)) {
cache.set(node, true);
return true;
}
}
}
else if (isESTreeNode(value) && containsSignalRead(value, context)) {
cache.set(node, true);
return true;
}
}
cache.set(node, false);
return false;
}
let signalUpdates = [];
let trackedSignalCreators = new Set();
let trackedSignalNamespaces = new Set();
const ruleName = 'prefer-batch-updates';
export const preferBatchUpdatesRule = ESLintUtils.RuleCreator((name) => {
return getRuleDocUrl(name);
})({
name: ruleName,
meta: {
type: 'suggestion',
fixable: 'code',
hasSuggestions: true,
docs: {
description: 'Suggest batching multiple signal updates to optimize performance',
url: getRuleDocUrl(ruleName),
},
messages: {
useBatch: '{{count}} signal updates detected in the same scope. Use `batch` to optimize performance by reducing renders.',
suggestUseBatch: 'Use `batch` to group {{count}} signal updates',
addBatchImport: "Add `batch` import from '@preact/signals-react'",
wrapWithBatch: 'Wrap with `batch` to optimize signal updates',
useBatchSuggestion: 'Use `batch` to group {{count}} signal updates',
removeUnnecessaryBatch: 'Unnecessary batch around a single signal update. Remove the batch wrapper',
nonUpdateSignalInBatch: 'Signal read inside `batch()` without an update. Batch is intended for grouping updates.',
updatesSeparatedByCode: 'Multiple signal updates detected but separated by other code; cannot safely batch automatically.',
},
schema: [
{
type: 'object',
properties: {
minUpdates: {
type: 'number',
minimum: 2,
default: DEFAULT_MIN_UPDATES,
description: 'Minimum number of signal updates to trigger the rule',
},
detection: {
type: 'object',
properties: {
allowSingleReads: {
type: 'number',
minimum: 0,
default: 0,
description: 'Allow up to N signal reads inside a batch without emitting nonUpdateSignalInBatch',
},
allowNonTrivialBetween: {
type: 'number',
minimum: 0,
default: 0,
description: 'Allow up to N non-trivial statements between updates to still consider them contiguous for batching',
},
ignoreUpdateCalls: {
type: 'boolean',
default: false,
description: 'When true, treat `.update(...)` method calls as non-updates for batching detection',
},
},
additionalProperties: false,
default: { allowSingleReads: 0, allowNonTrivialBetween: 0, ignoreUpdateCalls: false },
},
performance: {
type: 'object',
properties: {
maxTime: {
type: 'number',
minimum: 1,
description: 'Maximum time in milliseconds to spend analyzing a file',
},
maxMemory: {
type: 'number',
minimum: 1,
description: 'Maximum memory in MB to use for analysis',
},
maxNodes: {
type: 'number',
minimum: 1,
description: 'Maximum number of AST nodes to process',
},
enableMetrics: {
type: 'boolean',
description: 'Whether to enable performance metrics collection',
},
logMetrics: {
type: 'boolean',
description: 'Whether to log performance metrics',
},
maxUpdates: {
type: 'number',
minimum: 1,
description: 'Maximum number of signal updates to process',
},
maxDepth: {
type: 'number',
minimum: 1,
description: 'Maximum depth of nested scopes to analyze',
},
maxOperations: {
type: 'object',
description: 'Limits for specific operations',
properties: Object.fromEntries(Object.entries(PerformanceOperations).map(([key]) => [
key,
{
type: 'number',
minimum: 1,
description: `Maximum number of ${key} operations`,
},
])),
},
},
additionalProperties: false,
},
severity: {
type: 'object',
properties: {
arrayUpdateInLoop: {
type: 'string',
enum: ['error', 'warn', 'off'],
description: 'Severity for array updates in loops',
},
suggestBatchArrayUpdate: {
type: 'string',
description: 'Severity for suggesting batch for array updates',
},
// Add other severity options from the spec
useBatch: { type: 'string', enum: ['error', 'warn', 'off'] },
suggestUseBatch: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
addBatchImport: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
wrapWithBatch: { type: 'string', enum: ['error', 'warn', 'off'] },
useBatchSuggestion: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
removeUnnecessaryBatch: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
nonUpdateSignalInBatch: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
updatesSeparatedByCode: {
type: 'string',
enum: ['error', 'warn', 'off'],
},
},
additionalProperties: false,
},
},
additionalProperties: false,
},
],
},
defaultOptions: [
{
minUpdates: 2,
performance: DEFAULT_PERFORMANCE_BUDGET,
extraSignalModules: [],
detection: { allowSingleReads: 0, allowNonTrivialBetween: 0, ignoreUpdateCalls: false },
severity: {
useBatch: 'error',
suggestUseBatch: 'error',
addBatchImport: 'error',
wrapWithBatch: 'error',
useBatchSuggestion: 'error',
removeUnnecessaryBatch: 'error',
nonUpdateSignalInBatch: 'warn',
updatesSeparatedByCode: 'warn',
},
},
],
create(context, [option]) {
const perfKey = `${ruleName}:${context.filename}:${Date.now()}`;
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);
}
let nodeCount = 0;
function shouldContinue() {
nodeCount++;
if (typeof option?.performance?.maxNodes === 'number' &&
nodeCount > option.performance.maxNodes) {
trackOperation(perfKey, PerformanceOperations.nodeBudgetExceeded);
return false;
}
return true;
}
// Initialize module detection set per file (options-applied)
SIGNAL_MODULES = new Set(['@preact/signals-react', ...(option?.extraSignalModules ?? [])]);
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.Program]: () => {
// reset tracking for this file
trackedSignalVars = new Set();
trackedSignalCreators = new Set();
trackedSignalNamespaces = new Set();
},
[`${AST_NODE_TYPES.ImportDeclaration}`](node) {
if (node.source.value !== '@preact/signals-react') {
return;
}
for (const spec of node.specifiers) {
if (spec.type === AST_NODE_TYPES.ImportSpecifier) {
// Track known creators
if (spec.imported.type === AST_NODE_TYPES.Identifier &&
(spec.imported.name === 'signal' || spec.imported.name === 'computed')) {
trackedSignalCreators.add(spec.local.name);
}
}
else if (spec.type === AST_NODE_TYPES.ImportNamespaceSpecifier) {
trackedSignalNamespaces.add(spec.local.name);
}
}
},
[AST_NODE_TYPES.VariableDeclarator](node) {
if (node.id.type !== AST_NODE_TYPES.Identifier || !node.init) {
return;
}
// signal creator call: signal(...)
if (node.init.type === AST_NODE_TYPES.CallExpression &&
node.init.callee.type === AST_NODE_TYPES.Identifier &&
trackedSignalCreators.has(node.init.callee.name)) {
trackedSignalVars.add(node.id.name);
return;
}
// namespaced call: ns.signal(...) or ns.computed(...)
if (node.init.type === AST_NODE_TYPES.CallExpression &&
node.init.callee.type === AST_NODE_TYPES.MemberExpression &&
!node.init.callee.computed &&
node.init.callee.object.type === AST_NODE_TYPES.Identifier &&
trackedSignalNamespaces.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')) {
trackedSignalVars.add(node.id.name);
}
},
[AST_NODE_TYPES.CallExpression](node) {
if (!shouldContinue()) {
return;
}
if (isBatchCall(node, context)) {
recordMetric(perfKey, 'batchCallDetected', {
location: context.sourceCode.getLocFromIndex(node.range[0]),
hasCallback: node.arguments.length > 0 &&
(node.arguments[0]?.type === AST_NODE_TYPES.ArrowFunctionExpression ||
node.arguments[0]?.type === AST_NODE_TYPES.FunctionExpression),
});
if (node.arguments.length > 0 &&
(node.arguments[0]?.type === AST_NODE_TYPES.ArrowFunctionExpression ||
node.arguments[0]?.type === AST_NODE_TYPES.FunctionExpression)) {
pushBatchScope(true);
if (node.arguments[0].type === AST_NODE_TYPES.ArrowFunctionExpression &&
node.arguments[0].body.type === AST_NODE_TYPES.BlockStatement) {
recordMetric(perfKey, 'skipArrowBatchBody', {
location: context.sourceCode.getLocFromIndex(node.arguments[0].body.range[0]),
});
if (Array.isArray(node.arguments[0].body.body)) {
const bodyStatements = node.arguments[0].body.body;
// Report non-update signal reads inside batch body only if there are no updates anywhere in the body
if (!containsSignalUpdate(node.arguments[0].body, context)) {
const allow = context.options[0]?.detection?.allowSingleReads ?? 0;
let readCount = 0;
for (const stmt of bodyStatements) {
if (stmt.type === AST_NODE_TYPES.ExpressionStatement &&
!isSignalUpdate(stmt.expression, context) &&
containsSignalRead(stmt.expression, context)) {
readCount += 1;
}
}
if (readCount > allow &&
getSeverity('nonUpdateSignalInBatch', context.options[0]) !== 'off') {
// Report once on the body to avoid noisy diagnostics
context.report({
node: node.arguments[0].body,
messageId: 'nonUpdateSignalInBatch',
});
}
}
}
}
else if (node.arguments[0].type === AST_NODE_TYPES.ArrowFunctionExpression) {
// Concise arrow body case: body is an expression
if (node.arguments[0].body.type !== AST_NODE_TYPES.BlockStatement) {
const allow = context.options[0]?.detection?.allowSingleReads ?? 0;
const hasUpdate = isSignalUpdate(node.arguments[0].body, context);
const hasRead = containsSignalRead(node.arguments[0].body, context);
if (!hasUpdate &&
hasRead &&
allow < 1 &&
getSeverity('nonUpdateSignalInBatch', context.options[0]) !== 'off') {
context.report({
node: node.arguments[0].body,
messageId: 'nonUpdateSignalInBatch',
});
}
}
}
else if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
node.arguments[0].type === AST_NODE_TYPES.FunctionExpression) {
recordMetric(perfKey, 'skipFunctionBatchBody', {
location: context.sourceCode.getLocFromIndex(node.arguments[0].body.range[0]),
});
const bodyStatements = node.arguments[0].body.body;
// Report non-update signal reads inside batch body only if there are no updates anywhere in the body
if (Array.isArray(bodyStatements)) {
if (!containsSignalUpdate(node.arguments[0].body, context)) {
const allow = context.options[0]?.detection?.allowSingleReads ?? 0;
let readCount = 0;
for (const stmt of bodyStatements) {
if (stmt.type === AST_NODE_TYPES.ExpressionStatement &&
!isSignalUpdate(stmt.expression, context) &&
containsSignalRead(stmt.expression, context)) {