@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
914 lines • 44.6 kB
JavaScript
/* eslint-disable react-signals-hooks/prefer-signal-reads */
/* eslint-disable react-signals-hooks/no-non-signal-with-signal-suffix */
// FIXED by @ospm/eslint-plugin-react-signals-hooks
/** biome-ignore-all assist/source/organizeImports: off */
import { ESLintUtils, AST_NODE_TYPES, } from '@typescript-eslint/utils';
import { buildNamedImport, getPreferredQuote, getPreferredSemicolon, } from './utils/import-format.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 getAssignmentType(node) {
if (node.left.type === AST_NODE_TYPES.MemberExpression) {
if (node.left.computed) {
return 'computedMemberAssignment';
}
return 'memberAssignment';
}
if (node.left.type === AST_NODE_TYPES.Identifier) {
return 'identifierAssignment';
}
return 'otherAssignment';
}
function trackIdentifier(name, perfKey, resolvedIdentifiers) {
const count = resolvedIdentifiers.get(name) ?? 0;
resolvedIdentifiers.set(name, count + 1);
if (count === 0) {
trackOperation(perfKey, PerformanceOperations.identifierResolution);
}
}
function getSeverity(messageId, option) {
if (!option?.severity) {
return 'error';
}
switch (messageId) {
case 'signalValueAssignment': {
return option.severity.signalValueAssignment ?? 'error';
}
case 'signalValueUpdate': {
return option.severity.signalValueUpdate ?? 'error';
}
case 'signalPropertyAssignment': {
return option.severity.signalPropertyAssignment ?? 'error';
}
case 'suggestUseEffect': {
return option.severity.suggestUseEffect ?? 'error';
}
case 'suggestEventHandler': {
return option.severity.suggestEventHandler ?? 'error';
}
case 'signalArrayIndexAssignment': {
return option.severity.signalArrayIndexAssignment ?? 'error';
}
case 'signalNestedPropertyAssignment': {
return option.severity.signalNestedPropertyAssignment ?? 'error';
}
default: {
return 'error';
}
}
}
// Resolve the base identifier name for patterns like:
// foo.value = ...
// foo.value.bar = ...
// foo.value[expr] = ...
// Returns the identifier name (e.g., "foo") if resolvable, else null
function resolveBaseIdentifierFromValueChain(node) {
// Unwrap ChainExpression if present (optional chaining not valid on LHS, but be safe)
if (node.type === AST_NODE_TYPES.ChainExpression) {
const inner = node.expression;
return resolveBaseIdentifierFromValueChain(inner);
}
if (node.type === AST_NODE_TYPES.Identifier) {
return node.name;
}
if (node.type === AST_NODE_TYPES.MemberExpression) {
// We want base.value[...]/base.value or base.value.prop
if (node.object.type === AST_NODE_TYPES.MemberExpression &&
!node.object.computed &&
node.object.property.type === AST_NODE_TYPES.Identifier &&
node.object.property.name === 'value' &&
node.object.object.type === AST_NODE_TYPES.Identifier) {
return node.object.object.name;
}
// Also support the direct base.value (no further nesting)
if (!node.computed &&
node.property.type === AST_NODE_TYPES.Identifier &&
node.property.name === 'value' &&
node.object.type === AST_NODE_TYPES.Identifier) {
return node.object.name;
}
}
return null;
}
function looksLikeSignal(baseName, suffixRegex, option) {
if (baseName === null) {
return false;
}
// Suffix-based heuristic
if (suffixRegex !== null && hasSignalSuffix(baseName, suffixRegex)) {
return true;
}
// Explicit configured names (creator/import-based detection to be added separately)
const names = option?.signalNames ?? [];
// Only exact matches against configured names
return names.includes(baseName);
}
const resolvedIdentifiers = new Map();
// Track variables created via signal/computed/effect creators in this file
const knownCreatorSignals = new Set();
// Track imported creator identifiers and namespaces from known modules
const creatorIdentifiers = new Set();
const creatorNamespaces = new Set();
const KNOWN_SIGNAL_MODULES = new Set(['@preact/signals-react', '@preact/signals-core']);
let inRenderContext = false;
let renderDepth = 0;
let hookDepth = 0;
let functionDepth = 0;
// Track when traversing the JSX subtree of a component's return statement
let inReturnJSX = false;
let returnJSXDepth = 0;
// Dedupe multiple reports for the same node/message
const reported = new Set();
function makeReportKey(node, messageId) {
// node.range is always present in @typescript-eslint parser
// Fallback to loc if needed
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const start = node.range[0] ?? node.loc.start.column ?? 0;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const end = node.range[1] ?? node.loc.end.column ?? 0;
return `${messageId}@${start}-${end}`;
}
function reportOnce(descriptor, context) {
const key = makeReportKey(descriptor.node, descriptor.messageId);
if (reported.has(key)) {
return;
}
reported.add(key);
context.report(descriptor);
}
const ruleName = 'no-mutation-in-render';
function isNamedCallee(callee, names) {
if (callee.type === AST_NODE_TYPES.Identifier) {
return names.has(callee.name);
}
if (callee.type === AST_NODE_TYPES.MemberExpression &&
!callee.computed &&
callee.property.type === AST_NODE_TYPES.Identifier) {
return names.has(callee.property.name);
}
return false;
}
const MEMO_WRAPPERS = new Set(['memo', 'forwardRef']);
function isMemoOrForwardRefCallee(c) {
return isNamedCallee(c, MEMO_WRAPPERS);
}
export const noMutationInRenderRule = ESLintUtils.RuleCreator((name) => {
return getRuleDocUrl(name);
})({
name: ruleName,
meta: {
type: 'problem',
docs: {
description: 'Disallow direct signal mutation during render',
url: getRuleDocUrl(ruleName),
},
hasSuggestions: true,
fixable: 'code',
schema: [
{
type: 'object',
properties: {
signalNames: {
type: 'array',
items: {
type: 'string',
},
uniqueItems: true,
description: 'Custom signal function names',
},
allowedPatterns: {
type: 'array',
items: {
type: 'string',
},
uniqueItems: true,
description: 'Patterns where mutations are allowed',
},
severity: {
type: 'object',
properties: {
signalValueAssignment: {
type: 'string',
enum: ['error', 'warn', 'off'],
default: 'error',
},
signalPropertyAssignment: {
type: 'string',
enum: ['error', 'warn', 'off'],
default: 'error',
},
signalArrayIndexAssignment: {
type: 'string',
enum: ['error', 'warn', 'off'],
default: 'error',
},
signalNestedPropertyAssignment: {
type: 'string',
enum: ['error', 'warn', 'off'],
default: 'error',
},
unsafeAutofix: { type: 'boolean' },
suffix: { type: 'string' },
},
additionalProperties: false,
},
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,
},
},
additionalProperties: false,
},
],
messages: {
signalValueAssignment: 'Avoid mutating signal.value directly in render. Move this to an effect or event handler.',
signalValueUpdate: 'Avoid updating signal.value with operators (++, --, +=, etc.) in render. Move this to an effect or event handler.',
signalPropertyAssignment: 'Avoid mutating signal properties directly in render. Move this to an effect or event handler.',
signalArrayIndexAssignment: 'Avoid mutating array indexes of signal values in render. Move this to an effect or event handler.',
signalNestedPropertyAssignment: 'Avoid mutating nested properties of signal values in render. Move this to an effect or event handler.',
suggestUseEffect: 'Wrap in useEffect',
suggestEventHandler: 'Move to event handler',
},
},
defaultOptions: [
{
signalNames: ['signal', 'useSignal', 'createSignal'],
allowedPatterns: [],
severity: {
suggestUseEffect: 'error',
signalValueUpdate: 'error',
suggestEventHandler: 'error',
signalValueAssignment: 'error',
signalPropertyAssignment: 'error',
signalArrayIndexAssignment: 'error',
signalNestedPropertyAssignment: 'error',
},
unsafeAutofix: false,
suffix: 'Signal',
performance: DEFAULT_PERFORMANCE_BUDGET,
},
],
create(context, [option]) {
const perfKey = `${ruleName}:${context.filename}:${Date.now()}`;
startPhase(perfKey, 'ruleInit');
const perf = createPerformanceTracker(perfKey, option?.performance);
// Build suffix regex for variable-name based signal detection
const suffixRegex = buildSuffixRegex(option?.suffix);
// Helper: detect if file already imports useEffect from 'react'
function hasUseEffectImport() {
for (const stmt of context.sourceCode.ast.body) {
if (stmt.type === AST_NODE_TYPES.ImportDeclaration &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
stmt.source.type === AST_NODE_TYPES.Literal &&
String(stmt.source.value) === 'react') {
// default import or namespace import don't matter; we need a named useEffect
for (const spec of stmt.specifiers) {
if (spec.type === AST_NODE_TYPES.ImportSpecifier &&
spec.imported.type === AST_NODE_TYPES.Identifier &&
spec.imported.name === 'useEffect') {
return true;
}
}
}
}
return false;
}
// Helper: fixes to ensure `import { useEffect } from 'react'` exists
function ensureUseEffectImportFixes(fixer) {
const fixes = [];
if (hasUseEffectImport()) {
return fixes;
}
const importText = '\n' +
buildNamedImport('react', ['useEffect'], getPreferredQuote(context.sourceCode), getPreferredSemicolon(context.sourceCode)) +
'\n';
const lastImport = context.sourceCode.ast.body.find((n) => {
return n.type === AST_NODE_TYPES.ImportDeclaration;
});
if (lastImport) {
fixes.push(fixer.insertTextAfter(lastImport, importText));
}
else {
fixes.push(fixer.insertTextBeforeRange([0, 0], importText));
}
return fixes;
}
// Early bail if file matches any of the allowed patterns
if (Array.isArray(option?.allowedPatterns) && option.allowedPatterns.length > 0) {
try {
const allowed = option.allowedPatterns.some((p) => {
try {
// eslint-disable-next-line security/detect-non-literal-regexp
const re = new RegExp(p);
return re.test(context.filename);
}
catch {
return false;
}
});
if (allowed) {
return {};
}
}
catch {
// ignore pattern errors and continue
}
}
if (!/\.(tsx|jsx)$/i.test(context.filename)) {
return {};
}
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 (nodeCount > (option?.performance?.maxNodes ?? 2000)) {
trackOperation(perfKey, PerformanceOperations.nodeBudgetExceeded);
return false;
}
return true;
}
startPhase(perfKey, 'fileAnalysis');
if (option?.allowedPatterns?.some((pattern) => {
try {
// eslint-disable-next-line security/detect-non-literal-regexp
return new RegExp(pattern).test(context.filename);
}
catch (error) {
if (option.performance?.enableMetrics === true &&
option.performance.logMetrics === true) {
console.error(`Invalid regex pattern: ${pattern}`, error);
}
// Invalid regex pattern, ignore it
return false;
}
}) ??
false) {
trackOperation(perfKey, PerformanceOperations.fileAnalysis);
endPhase(perfKey, 'fileAnalysis');
return {};
}
startPhase(perfKey, 'ruleExecution');
return {
'*': (node) => {
if (!shouldContinue()) {
endPhase(perfKey, 'recordMetrics');
return;
}
perf.trackNode(node);
trackOperation(perfKey,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
PerformanceOperations[`${node.type}Processing`] ?? PerformanceOperations.nodeProcessing);
},
// Capture creator-based signals: const x = signal(...)
[AST_NODE_TYPES.VariableDeclarator](node) {
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/strict-boolean-expressions
!node.id ||
node.id.type !== AST_NODE_TYPES.Identifier ||
!node.init ||
node.init.type !== AST_NODE_TYPES.CallExpression) {
return;
}
let creatorName = null;
if (node.init.callee.type === AST_NODE_TYPES.Identifier) {
creatorName = node.init.callee.name;
if (creatorIdentifiers.has(creatorName) ||
['signal', 'computed', 'effect'].includes(creatorName)) {
knownCreatorSignals.add(node.id.name);
}
}
else if (node.init.callee.type === AST_NODE_TYPES.MemberExpression &&
!node.init.callee.computed &&
node.init.callee.property.type === AST_NODE_TYPES.Identifier &&
node.init.callee.object.type === AST_NODE_TYPES.Identifier) {
creatorName = node.init.callee.property.name;
if (creatorNamespaces.has(node.init.callee.object.name) &&
['signal', 'computed', 'effect'].includes(creatorName)) {
knownCreatorSignals.add(node.id.name);
}
}
},
// Track imports from known signal modules
[AST_NODE_TYPES.ImportDeclaration](node) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (node.source.type !== AST_NODE_TYPES.Literal) {
return;
}
const source = String(node.source.value);
if (!KNOWN_SIGNAL_MODULES.has(source)) {
return;
}
for (const spec of node.specifiers) {
if (spec.type === AST_NODE_TYPES.ImportSpecifier) {
const importedName = spec.imported.type === AST_NODE_TYPES.Identifier ? spec.imported.name : null;
const localName = spec.local.name;
if (importedName !== null && ['signal', 'computed', 'effect'].includes(importedName)) {
creatorIdentifiers.add(localName);
}
}
else if (spec.type === AST_NODE_TYPES.ImportNamespaceSpecifier) {
creatorNamespaces.add(spec.local.name);
}
}
},
[AST_NODE_TYPES.FunctionDeclaration](node) {
trackOperation(perfKey, PerformanceOperations.FunctionDeclarationProcessing);
if (!(node.id !== null && /^[A-Z]/.test(node.id.name))) {
return;
}
trackOperation(perfKey, PerformanceOperations.reactComponentFunctionDeclarationProcessing);
renderDepth++;
trackIdentifier(node.id.name, perfKey, resolvedIdentifiers);
startPhase(perfKey, `render:${node.id.name}`);
},
[AST_NODE_TYPES.ArrowFunctionExpression](node) {
trackOperation(perfKey, PerformanceOperations.ArrowFunctionExpressionProcessing);
// Treat wrappers like memo/forwardRef as render roots when they wrap a function
if (node.parent.type === AST_NODE_TYPES.CallExpression &&
isMemoOrForwardRefCallee(node.parent.callee)) {
renderDepth++;
startPhase(perfKey, 'render:memo/forwardRef');
return;
}
if (node.parent.type === AST_NODE_TYPES.VariableDeclarator &&
node.parent.id.type === AST_NODE_TYPES.Identifier &&
/^[A-Z]/.test(node.parent.id.name)) {
trackOperation(perfKey, PerformanceOperations.reactComponentArrowFunctionExpressionProcessing);
renderDepth++;
trackIdentifier(node.parent.id.name, perfKey, resolvedIdentifiers);
startPhase(perfKey, `render:${node.parent.id.name}`);
return;
}
functionDepth++;
if (functionDepth === 1 && renderDepth >= 1) {
inRenderContext = false;
}
},
[AST_NODE_TYPES.FunctionExpression](node) {
trackOperation(perfKey, PerformanceOperations.FunctionExpressionProcessing);
// Treat wrappers like memo/forwardRef as render roots when they wrap a function
if (node.parent.type === AST_NODE_TYPES.CallExpression &&
isMemoOrForwardRefCallee(node.parent.callee)) {
renderDepth++;
startPhase(perfKey, 'render:memo/forwardRef');
return;
}
functionDepth++;
if (node.parent.type === AST_NODE_TYPES.VariableDeclarator &&
node.parent.id.type === AST_NODE_TYPES.Identifier &&
/^[A-Z]/.test(node.parent.id.name)) {
trackOperation(perfKey, PerformanceOperations.reactComponentFunctionExpressionProcessing);
renderDepth++;
trackIdentifier(node.parent.id.name, perfKey, resolvedIdentifiers);
startPhase(perfKey, `render:${node.parent.id.name}`);
}
else if (functionDepth === 1 && renderDepth >= 1) {
inRenderContext = false;
}
},
[AST_NODE_TYPES.ReturnStatement](node) {
if (renderDepth >= 1 &&
hookDepth === 0 &&
functionDepth === 0 &&
node.argument &&
(node.argument.type === AST_NODE_TYPES.JSXElement ||
node.argument.type === AST_NODE_TYPES.JSXFragment)) {
inRenderContext = true;
inReturnJSX = true;
returnJSXDepth = 0; // depth will be incremented by JSXElement/Fragment enter
}
},
[AST_NODE_TYPES.JSXElement]() {
if (inReturnJSX) {
returnJSXDepth++;
}
},
[AST_NODE_TYPES.JSXFragment]() {
if (inReturnJSX) {
returnJSXDepth++;
}
},
[`${AST_NODE_TYPES.JSXElement}:exit`]() {
if (inReturnJSX) {
returnJSXDepth--;
if (returnJSXDepth <= 0) {
inReturnJSX = false;
inRenderContext = false;
}
}
},
[`${AST_NODE_TYPES.JSXFragment}:exit`]() {
if (inReturnJSX) {
returnJSXDepth--;
if (returnJSXDepth <= 0) {
inReturnJSX = false;
inRenderContext = false;
}
}
},
[AST_NODE_TYPES.CallExpression](node) {
// Entering a hook/effect/computed call means we're not in top-level render
if (renderDepth >= 1 &&
node.callee.type === AST_NODE_TYPES.Identifier &&
[
'useEffect',
'useLayoutEffect',
'useCallback',
'useMemo',
'useImperativeHandle',
'effect',
'computed',
].includes(node.callee.name)) {
hookDepth++;
inRenderContext = false;
}
if (!inRenderContext || renderDepth < 1 || hookDepth > 0 || functionDepth > 0) {
return;
}
if (node.callee.type === AST_NODE_TYPES.MemberExpression &&
!node.callee.computed &&
node.callee.property.type === AST_NODE_TYPES.Identifier &&
node.callee.object.type === AST_NODE_TYPES.MemberExpression &&
!node.callee.object.computed &&
node.callee.object.property.type === AST_NODE_TYPES.Identifier &&
node.callee.object.property.name === 'value' &&
node.callee.object.object.type === AST_NODE_TYPES.Identifier) {
const mutatingArrayMethods = new Set([
'push',
'pop',
'splice',
'sort',
'reverse',
'copyWithin',
'fill',
'shift',
'unshift',
]);
const mutatingMapSetMethods = new Set(['set', 'add', 'delete', 'clear']);
if (mutatingArrayMethods.has(node.callee.property.name) ||
mutatingMapSetMethods.has(node.callee.property.name)) {
// Best-effort signal identification via suffix or explicit allowlist of names
const looksLikeSignal = hasSignalSuffix(node.callee.object.object.name, suffixRegex) ||
(option?.signalNames ?? []).some((n) => {
return ('object' in node.callee &&
'object' in node.callee.object &&
'name' in node.callee.object.object &&
n === node.callee.object.object.name);
});
if (!looksLikeSignal) {
return;
}
if (getSeverity('signalPropertyAssignment', option) === 'off') {
return;
}
reportOnce({
node,
messageId: 'signalPropertyAssignment',
suggest: option?.unsafeAutofix === true
? [
{
messageId: 'suggestUseEffect',
fix(fixer) {
if ('object' in node.callee &&
'object' in node.callee.object &&
'object' in node.callee.object.object &&
'name' in node.callee.object.object) {
return [
...ensureUseEffectImportFixes(fixer),
fixer.replaceText(node, `useEffect(() => { ${context.sourceCode.getText(node)} }, [${node.callee.object.object.name}]);`),
];
}
return null;
},
},
{
messageId: 'suggestEventHandler',
fix(fixer) {
return fixer.replaceText(node, `const handleEvent = () => { ${context.sourceCode.getText(node)} }`);
},
},
]
: [],
}, context);
}
}
},
[AST_NODE_TYPES.AssignmentExpression](node) {
trackOperation(perfKey, PerformanceOperations.AssignmentExpressionProcessing);
// Skip if not in a render context or inside hooks/functions
if (!inRenderContext || renderDepth < 1 || hookDepth > 0 || functionDepth > 0) {
return;
}
startPhase(perfKey, PerformanceOperations.assignmentAnalysis);
trackOperation(perfKey,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
PerformanceOperations[`assignmentType:${getAssignmentType(node)}`] ??
PerformanceOperations.assignmentAnalysis);
// Resolve base name for any .value-based assignment
const baseName = resolveBaseIdentifierFromValueChain(node.left);
if ((baseName != null && knownCreatorSignals.has(baseName)) ||
looksLikeSignal(baseName, suffixRegex, option)) {
if (node.left.type === AST_NODE_TYPES.MemberExpression &&
!node.left.computed &&
node.left.property.type === AST_NODE_TYPES.Identifier &&
node.left.property.name === 'value') {
const msg = node.operator === '=' ? 'signalValueAssignment' : 'signalValueUpdate';
if (getSeverity(msg, option) !== 'off') {
reportOnce({
node,
messageId: msg,
suggest: option?.unsafeAutofix === true
? [
{
messageId: 'suggestUseEffect',
fix(fixer) {
return [
...ensureUseEffectImportFixes(fixer),
fixer.replaceText(node, `useEffect(() => { ${context.sourceCode.getText(node)} }, []);`),
];
},
},
{
messageId: 'suggestEventHandler',
fix(fixer) {
return fixer.replaceText(node, `const handleEvent = () => { ${context.sourceCode.getText(node)} }`);
},
},
]
: [],
}, context);
}
return;
}
if (node.left.type === AST_NODE_TYPES.MemberExpression &&
node.left.computed &&
node.left.object.type === AST_NODE_TYPES.MemberExpression &&
!node.left.object.computed &&
node.left.object.property.type === AST_NODE_TYPES.Identifier &&
node.left.object.property.name === 'value') {
if (getSeverity('signalArrayIndexAssignment', option) !== 'off') {
reportOnce({
node,
messageId: 'signalArrayIndexAssignment',
suggest: option?.unsafeAutofix === true
? [
{
messageId: 'suggestUseEffect',
fix(fixer) {
return [
...ensureUseEffectImportFixes(fixer),
fixer.replaceText(node, `useEffect(() => { ${context.sourceCode.getText(node)} }, [${baseName ?? ''}]);`),
];
},
},
]
: [],
}, context);
}
return;
}
if (node.left.type === AST_NODE_TYPES.MemberExpression &&
!node.left.computed &&
node.left.object.type === AST_NODE_TYPES.MemberExpression &&
!node.left.object.computed &&
node.left.object.property.type === AST_NODE_TYPES.Identifier &&
node.left.object.property.name === 'value' &&
getSeverity('signalNestedPropertyAssignment', option) !== 'off') {
reportOnce({
node,
messageId: 'signalNestedPropertyAssignment',
suggest: option?.unsafeAutofix === true
? [
{
messageId: 'suggestUseEffect',
fix(fixer) {
return [
...ensureUseEffectImportFixes(fixer),
fixer.replaceText(node, `useEffect(() => { ${context.sourceCode.getText(node)} }, []);`),
];
},
},
]
: [],
}, context);
}
}
if (node.left.type === AST_NODE_TYPES.MemberExpression &&
node.left.computed &&
node.left.object.type === AST_NODE_TYPES.MemberExpression &&
node.left.object.property.type === AST_NODE_TYPES.Identifier &&
node.left.object.property.name === 'value' &&
node.left.object.object.type === AST_NODE_TYPES.Identifier &&
option?.signalNames?.some((name) => {
return ('object' in node.left &&
'object' in node.left.object &&
'name' in node.left.object.object &&
node.left.object.object.name === name);
}) === true) {
if (getSeverity('signalArrayIndexAssignment', option) !== 'off') {
reportOnce({
node,
messageId: 'signalArrayIndexAssignment',
suggest: [
{
messageId: 'suggestUseEffect',
fix(fixer) {
return [
...ensureUseEffectImportFixes(fixer),
fixer.replaceText(node, `useEffect(() => { ${context.sourceCode.getText(node)} }, [${'object' in node.left &&
'object' in node.left.object &&
'name' in node.left.object.object &&
node.left.object.object.name}])`),
];
},
},
],
}, context);
}
return;
}
if (node.left.type === AST_NODE_TYPES.MemberExpression &&
!node.left.computed &&
node.left.object.type === AST_NODE_TYPES.MemberExpression &&
node.left.object.property.type === AST_NODE_TYPES.Identifier &&
node.left.object.property.name === 'value' &&
node.left.object.object.type === AST_NODE_TYPES.Identifier &&
option?.signalNames?.some((name) => {
return (('object' in node.left &&
'object' in node.left.object &&
'name' in node.left.object.object &&
node.left.object.object.name === name) ||
('object' in node.left &&
'object' in node.left.object &&
'name' in node.left.object.object &&
node.left.object.object.name === name));
}) === true &&
getSeverity('signalNestedPropertyAssignment', option) !== 'off') {
reportOnce({
node,
messageId: 'signalNestedPropertyAssignment',
suggest: [
{
messageId: 'suggestUseEffect',
fix(fixer) {
return [
...ensureUseEffectImportFixes(fixer),
fixer.replaceText(node, `useEffect(() => { ${context.sourceCode.getText(node)} }, []);`),
];
},
},
],
}, context);
}
},
[AST_NODE_TYPES.UpdateExpression](node) {
if (!inRenderContext || renderDepth < 1 || hookDepth > 0 || functionDepth > 0) {
return;
}
// Check for signal.value++ or ++signal.value
if (node.argument.type === AST_NODE_TYPES.MemberExpression &&
node.argument.property.type === AST_NODE_TYPES.Identifier &&
node.argument.property.name === 'value') {
const baseName = resolveBaseIdentifierFromValueChain(node.argument);
if (!((baseName != null && knownCreatorSignals.has(baseName)) ||
looksLikeSignal(baseName, suffixRegex, option))) {
return;
}
if (getSeverity('signalValueUpdate', option) === 'off') {
return;
}
reportOnce({
node,
messageId: 'signalValueUpdate',
suggest: option?.unsafeAutofix === true
? [
{
messageId: 'suggestUseEffect',
fix(fixer) {
return [
...ensureUseEffectImportFixes(fixer),
fixer.replaceText(node, `useEffect(() => { ${context.sourceCode.getText(node)} }, [${baseName ?? ''}]);`),
];
},
},
{
messageId: 'suggestEventHandler',
fix(fixer) {
return fixer.replaceText(node, `const handleEvent = () => { ${context.sourceCode.getText(node)} }`);
},
},
]
: [],
}, context);
}
},
'FunctionDeclaration > :not(FunctionDeclaration)'(node) {
if (node.id != null &&
typeof node.id.name === 'string' &&
node.id.name !== '' &&
/^[A-Z]/.test(node.id.name)) {
renderDepth--;
if (renderDepth === 0) {
inRenderContext = false;
}
}
},
'ArrowFunctionExpression > :not(ArrowFunctionExpression)'(node) {
// Check if this is the main component arrow function
if (node.parent.type === AST_NODE_TYPES.VariableDeclarator &&
node.parent.id.type === AST_NODE_TYPES.Identifier &&
/^[A-Z]/.test(node.parent.id.name)) {
// This is a main component - exit render context
renderDepth--;
if (renderDepth === 0) {
inRenderContext = false;
}
}
else {
// This is a nested arrow function
functionDepth--;
}
},
// Explicit exit for memo/forwardRef-wrapped arrow functions
[`${AST_NODE_TYPES.ArrowFunctionExpression}:exit`](node) {
if (
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
node.parent &&
node.parent.type === AST_NODE_TYPES.CallExpression &&
isMemoOrForwardRefCallee(node.parent.callee)) {
renderDepth--;
if (renderDepth === 0) {
inRenderContext = false;
}
}
},
// Explicit exit for memo/forwardRef-wrapped function expressions
[`${AST_NODE_TYPES.FunctionExpression}:exit`](node) {
if (
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition
node.parent &&
node.parent.type === AST_NODE_TYPES.CallExpression &&
isMemoOrForwardRefCallee(node.parent.callee)) {
renderDepth--;
if (renderDepth === 0) {
inRenderContext = false;
}
}
},
'FunctionExpression > :not(FunctionExpression)'(_node) {
functionDepth--;
// Do not toggle inRenderContext here; it is controlled by returned JSX traversal
},
'CallExpression:exit'(node) {
if (node.callee.type === AST_NODE_TYPES.Identifier &&
[
'useEffect',
'useLayoutEffect',
'useCallback',
'useMemo',
'useImperativeHandle',
'effect', // @preact/signals-core effect
'computed', // @preact/signals-core computed
].includes(node.callee.name)) {
hookDepth--;
// Do not toggle inRenderContext here; it is controlled by returned JSX traversal
}
},
[`${AST_NODE_TYPES.Program}:exit`]() {
startPhase(perfKey, 'programExit');
perf['Program:exit']();
endPhase(perfKey, 'programExit');
},
};
},
});
//# sourceMappingURL=no-mutation-in-render.js.map