UNPKG

@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
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)) {