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

250 lines 10.8 kB
/** biome-ignore-all assist/source/organizeImports: off */ import { ESLintUtils, AST_NODE_TYPES, } from '@typescript-eslint/utils'; import { PerformanceOperations } from './utils/performance-constants.js'; import { createPerformanceTracker, DEFAULT_PERFORMANCE_BUDGET, endPhase, recordMetric, startPhase, startTracking, trackOperation, } from './utils/performance.js'; import { getRuleDocUrl } from './utils/urls.js'; const ruleName = 'forbid-signal-update-in-computed'; function createImportsState() { return { computedIds: new Set(), batchIds: new Set(), namespaces: new Set() }; } function isIdentifier(node, nameSet) { return !!(node && node.type === AST_NODE_TYPES.Identifier && nameSet.has(node.name)); } function isMemberOfNamespace(node, nsSet, prop) { if (!node || node.type !== AST_NODE_TYPES.MemberExpression) { return false; } if (node.computed) { return false; } if (node.object.type === AST_NODE_TYPES.Identifier && node.property.type === AST_NODE_TYPES.Identifier && nsSet.has(node.object.name) && node.property.name === prop) { return true; } return false; } function isComputedCall(node, imp) { return (isIdentifier(node.callee, imp.computedIds) || isMemberOfNamespace(node.callee, imp.namespaces, 'computed')); } function isBatchCall(node, imp) { return (isIdentifier(node.callee, imp.batchIds) || isMemberOfNamespace(node.callee, imp.namespaces, 'batch')); } function getSeverity(messageId, options) { if (!options?.severity) { return 'error'; } switch (messageId) { case 'noSignalWriteInComputed': { return options.severity.noSignalWriteInComputed ?? 'error'; } case 'noBatchedWritesInComputed': { return options.severity.noBatchedWritesInComputed ?? 'error'; } default: { return 'error'; } } } export const forbidSignalUpdateInComputedRule = ESLintUtils.RuleCreator((name) => { return getRuleDocUrl(name); })({ name: ruleName, meta: { type: 'problem', docs: { description: 'Forbid updating signals inside computed(...) callbacks. Computed must be pure and read-only.', url: getRuleDocUrl(ruleName), }, hasSuggestions: false, schema: [ { type: 'object', properties: { performance: { type: 'object', properties: { maxTime: { type: 'number', minimum: 1 }, maxMemory: { type: 'number', minimum: 1 }, maxNodes: { type: 'number', minimum: 1 }, enableMetrics: { type: 'boolean' }, logMetrics: { type: 'boolean' }, maxOperations: { type: 'object', properties: Object.fromEntries(Object.entries(PerformanceOperations).map(([k]) => [ k, { type: 'number', minimum: 1 }, ])), }, }, additionalProperties: false, }, severity: { type: 'object', properties: { noSignalWriteInComputed: { type: 'string', enum: ['error', 'warn', 'off'] }, noBatchedWritesInComputed: { type: 'string', enum: ['error', 'warn', 'off'] }, }, additionalProperties: false, }, suffix: { type: 'string', minLength: 1 }, }, additionalProperties: false, }, ], messages: { noSignalWriteInComputed: "Do not update signal '{{name}}' inside computed(). Computed functions must be pure and read-only.", noBatchedWritesInComputed: 'Do not batch updates inside computed(). Computed functions must be pure and read-only.', }, }, defaultOptions: [ { performance: DEFAULT_PERFORMANCE_BUDGET, }, ], create(context, [option]) { const perfKey = `${ruleName}:${context.filename}:${Date.now()}`; startPhase(perfKey, 'ruleInit'); const perf = createPerformanceTracker(perfKey, option?.performance); if (option?.performance?.enableMetrics === true) { startTracking(context, perfKey, option.performance, ruleName); } if (option?.performance?.enableMetrics === true && option.performance.logMetrics === true) { console.info(`${ruleName}: Initializing rule for file: ${context.filename}`); console.info(`${ruleName}: Rule configuration:`, option); } recordMetric(perfKey, 'config', { performance: { enableMetrics: option?.performance?.enableMetrics, logMetrics: option?.performance?.logMetrics, }, }); trackOperation(perfKey, PerformanceOperations.ruleInit); endPhase(perfKey, 'ruleInit'); let nodeCount = 0; function shouldContinue() { nodeCount++; if (typeof option?.performance?.maxNodes === 'number' && nodeCount > option.performance.maxNodes) { trackOperation(perfKey, PerformanceOperations.nodeBudgetExceeded); return false; } return true; } startPhase(perfKey, 'ruleExecution'); const imports = createImportsState(); const computedBodies = new WeakSet(); function isInsideComputed(node) { const ancestors = context.sourceCode.getAncestors(node); for (const a of ancestors) { if (computedBodies.has(a)) { return true; } } return false; } function reportWrite(node, name) { if (getSeverity('noSignalWriteInComputed', option) === 'off') { return; } context.report({ node, messageId: 'noSignalWriteInComputed', data: { name } }); } function reportBatch(node) { if (getSeverity('noBatchedWritesInComputed', option) === 'off') { return; } context.report({ node, messageId: 'noBatchedWritesInComputed' }); } return { '*': (node) => { if (!shouldContinue()) { endPhase(perfKey, 'recordMetrics'); return; } perf.trackNode(node); trackOperation(perfKey, PerformanceOperations.nodeProcessing); }, [AST_NODE_TYPES.ImportDeclaration](node) { // Only consider @preact/signals-react if ( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition node.source.type !== AST_NODE_TYPES.Literal || node.source.value !== '@preact/signals-react') { return; } for (const spec of node.specifiers) { if (spec.type === AST_NODE_TYPES.ImportSpecifier) { const imported = spec.imported.type === AST_NODE_TYPES.Identifier ? spec.imported.name : ''; if (imported === 'computed') { imports.computedIds.add(spec.local.name); } if (imported === 'batch') { imports.batchIds.add(spec.local.name); } } else if (spec.type === AST_NODE_TYPES.ImportNamespaceSpecifier) { imports.namespaces.add(spec.local.name); } } }, [AST_NODE_TYPES.CallExpression](node) { // Track computed bodies if (isComputedCall(node, imports) && node.arguments.length >= 1) { const arg = node.arguments[0]; if (arg && (arg.type === AST_NODE_TYPES.ArrowFunctionExpression || arg.type === AST_NODE_TYPES.FunctionExpression)) { computedBodies.add(arg); } } // Report batch usage inside computed if (isInsideComputed(node) && isBatchCall(node, imports)) { reportBatch(node); } // Method-based updates: X.set(...), X.update(...) if (!isInsideComputed(node)) { return; } if (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')) { const objText = context.sourceCode.getText(node.callee.object); reportWrite(node, objText); } }, [AST_NODE_TYPES.AssignmentExpression](node) { if (!isInsideComputed(node)) { return; } if (node.left.type === AST_NODE_TYPES.MemberExpression && !node.left.computed && node.left.property.type === AST_NODE_TYPES.Identifier && node.left.property.name === 'value') { reportWrite(node, context.sourceCode.getText(node.left.object)); } }, [AST_NODE_TYPES.UpdateExpression](node) { if (!isInsideComputed(node)) { return; } if (node.argument.type === AST_NODE_TYPES.MemberExpression && !node.argument.computed && node.argument.property.type === AST_NODE_TYPES.Identifier && node.argument.property.name === 'value') { const name = context.sourceCode.getText(node.argument.object); reportWrite(node, name); } }, [`${AST_NODE_TYPES.Program}:exit`]() { startPhase(perfKey, 'programExit'); perf['Program:exit'](); endPhase(perfKey, 'programExit'); }, }; }, }); //# sourceMappingURL=forbid-signal-update-in-computed.js.map