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,141 lines 199 kB
import { AST_NODE_TYPES, ESLintUtils, } from '@typescript-eslint/utils'; import { PerformanceOperations } from './utils/performance-constants.js'; import { endPhase, startPhase, recordMetric, startTracking, trackOperation, createPerformanceTracker, DEFAULT_PERFORMANCE_BUDGET, PerformanceLimitExceededError, } from './utils/performance.js'; import { buildSuffixRegex, hasSignalSuffix } from './utils/suffix.js'; import { getRuleDocUrl } from './utils/urls.js'; function getObservedFormatted(depKey, optionalChains, dependencies) { const dep = dependencies.get(depKey); if (typeof dep !== 'undefined' && dep.observedFormatted && dep.observedFormatted.size > 0) { // Prefer the shortest formatted variant (fewer optionals are usually shorter) let best = null; dep.observedFormatted.forEach((v) => { if (best === null || v.length < best.length) { best = v; } }); if (typeof best === 'string') { return best; } } return formatDependency(depKey, optionalChains); } function projectOptionalChains(path, optionalChains) { const projected = new Map(); const members = path.split('.'); let soFar = ''; for (let i = 0; i < members.length; i++) { soFar = i === 0 && typeof members[0] === 'string' ? members[0] : // eslint-disable-next-line security/detect-object-injection `${soFar}.${members[i]}`; const val = optionalChains.get(soFar); if (typeof val !== 'undefined') { projected.set(soFar, val); } } return projected; } function memoizeWithWeakMap(fn, map) { return (arg, componentScope, pureScopes) => { if (map.has(arg)) { return map.get(arg) ?? false; } const result = fn(arg, componentScope, pureScopes); map.set(arg, result); return result; }; } function isUseEffectEventIdentifier(node, perfKey) { trackOperation(perfKey, PerformanceOperations.hookCheck); if (node.type !== AST_NODE_TYPES.Identifier) { return false; } const { name } = node; return name === 'useEffectEvent' || name === 'experimental_useEffectEvent'; } function isSignalIdentifier(node, perfKey) { trackOperation(perfKey, PerformanceOperations.signalCheck); // Match bare identifiers: signal, computed, effect if (node.type === AST_NODE_TYPES.Identifier) { const { name } = node; return ['signal', 'computed', 'effect'].includes(name); } // Match namespaced creators: e.g., signals.signal(), ReactSignals.computed(), etc. if (node.type === AST_NODE_TYPES.MemberExpression && !node.computed && node.property.type === AST_NODE_TYPES.Identifier) { return ['signal', 'computed', 'effect'].includes(node.property.name); } return false; } const suffixByPerfKey = new Map(); function isSignalVariable(node, perfKey) { trackOperation(perfKey, PerformanceOperations.signalCheck); if (node.type !== AST_NODE_TYPES.Identifier) { return false; } const suffixRegex = suffixByPerfKey.get(perfKey) ?? buildSuffixRegex('Signal'); return hasSignalSuffix(node.name, suffixRegex); } function isSignalDependency(dependency, perfKey) { trackOperation(perfKey, PerformanceOperations.signalCheck); const suffixRegex = suffixByPerfKey.get(perfKey) ?? buildSuffixRegex('Signal'); // Treat names ending with the configured suffix as signals. // Handle both full path and the base identifier before the first '.'. if (hasSignalSuffix(dependency, suffixRegex)) { return true; } const base = dependency.split('.')[0] ?? ''; return base !== '' && hasSignalSuffix(base, suffixRegex); } function isSignalValueAccess(node, context) { // Check if this is a direct signal.value access if (node.type === AST_NODE_TYPES.MemberExpression && !node.computed && node.property.type === AST_NODE_TYPES.Identifier && node.property.name === 'value' && node.object.type === AST_NODE_TYPES.Identifier && hasSignalSuffix(node.object.name, buildSuffixRegex(typeof context.options[0]?.suffix === 'string' && context.options[0].suffix.length > 0 ? context.options[0].suffix : 'Signal'))) { const ancestors = context.sourceCode.getAncestors(node); const parent = ancestors[ancestors.length - 1]; // Check if this is part of an assignment operation (like countSignal.value++) if (typeof parent !== 'undefined') { if (parent.type === AST_NODE_TYPES.UpdateExpression || (parent.type === AST_NODE_TYPES.AssignmentExpression && ['=', '+=', '-=', '*=', '/=', '%='].includes(parent.operator))) { return false; } } return true; } return false; } function isNodeLike(val) { return (typeof val === 'object' && val !== null && !Array.isArray(val) && 'type' in val && typeof val.type === 'string'); } function isSameIdentifier(a, b) { return ((a.type === AST_NODE_TYPES.Identifier || a.type === AST_NODE_TYPES.JSXIdentifier) && a.type === b.type && a.name === b.name && // !!a.range && // !!b.range && a.range[0] === b.range[0] && a.range[1] === b.range[1]); } function isAncestorNodeOf(a, b) { return /* !!a.range && !!b.range && */ a.range[0] <= b.range[0] && a.range[1] >= b.range[1]; } function fastFindReferenceWithParent(start, target) { const queue = [start]; let item; while (queue.length) { item = queue.shift(); if (!item) { continue; } if (isSameIdentifier(item, target)) { return item; } if (!isAncestorNodeOf(item, target)) { continue; } for (const key in item) { if (key === 'parent') { continue; } const value = item[key]; if (isNodeLike(value) && isNodeLike(item)) { value.parent = item; queue.push(value); } else if (Array.isArray(value) && isNodeLike(item)) { for (const val of value) { if (isNodeLike(val)) { val.parent = item; queue.push(val); } } } } } return null; } function analyzePropertyChain(node, optionalChains, context, perfKey) { try { trackOperation(perfKey, PerformanceOperations.nodeProcessing); if (node.type === AST_NODE_TYPES.Identifier || node.type === AST_NODE_TYPES.JSXIdentifier) { const result = node.name; if (optionalChains) { optionalChains.set(result, false); } return result; } if (node.type === AST_NODE_TYPES.MemberExpression) { if (!node.computed) { // If this member expression is a method call (e.g., obj.method(...)), // we should NOT include the method name itself in the dependency path. // Only the object (`obj`) should be tracked as the dependency. // Example: matrixSignal.value[rowIndex]?.reduce(...) -> track up to matrixSignal.value[rowIndex] const isCalleeMethodCall = typeof node.parent !== 'undefined' && node.parent.type === AST_NODE_TYPES.CallExpression && node.parent.callee === node; const objectPath = analyzePropertyChain(node.object, optionalChains, context, perfKey); if (isCalleeMethodCall) { markNode(node, optionalChains, objectPath, perfKey); return objectPath; } const result = `${objectPath}.${analyzePropertyChain(node.property, null, context, perfKey)}`; markNode(node, optionalChains, result, perfKey); return result; } const object = analyzePropertyChain(node.object, optionalChains, context, perfKey); let computedResult; // Handle different types of computed properties if (node.property.type === AST_NODE_TYPES.Literal && typeof node.property.value === 'string') { computedResult = node.property.value; } else if (node.property.type === AST_NODE_TYPES.TemplateLiteral && node.property.quasis.length === 1) { computedResult = node.property.quasis[0]?.value.cooked ?? node.property.quasis[0]?.value.raw; } else { return context.sourceCode.getText(node); } // Handle computed property access result const result = `${object}[${computedResult}]`; markNode(node, optionalChains, result, perfKey); return result; } const fallback = context.sourceCode.getText(node); return fallback; } catch (error) { if (error instanceof PerformanceLimitExceededError) { trackOperation(perfKey, PerformanceOperations.analyzePropertyChainFailed); return error instanceof Error ? error.message : JSON.stringify(error); } throw error; } } function formatDependency(path, optionalChains) { // eslint-disable-next-line optimize-regex/optimize-regex path = path.replace(/\[\*\]/g, ''); const members = path.split('.'); let finalPath = ''; for (let i = 0; i < members.length; i++) { if (i !== 0) { const pathSoFar = members.slice(0, i + 1).join('.'); const isOptional = optionalChains.get(pathSoFar) === true; finalPath += isOptional ? '?.' : '.'; } // eslint-disable-next-line security/detect-object-injection finalPath += members[i]; } return finalPath; } // Returns true if every computed segment in a dependency path is a numeric literal index, e.g., // "arr[0]", "matrix[1].x", "a[2][3]". Dynamic indices (identifiers/expressions) return false. function hasOnlyNumericComputed(path) { // Quick reject if (!path.includes('[')) { return false; } // Match all bracketed segments // eslint-disable-next-line optimize-regex/optimize-regex const matches = path.match(/\[[^\]]+\]/g); if (!matches || matches.length === 0) { return false; } // All segments must be strictly digits return matches.every((seg) => { // eslint-disable-next-line optimize-regex/optimize-regex return /^\[(?:\d+)\]$/.test(seg); }); } function joinEnglish(arr) { let s = ''; for (let i = 0; i < arr.length; i++) { // eslint-disable-next-line security/detect-object-injection s += arr[i]; if (i === 0 && arr.length === 2) { s += ' and '; } else if (i === arr.length - 2 && arr.length > 2) { s += ', and '; } else if (i < arr.length - 1) { s += ', '; } } return s; } function scanTreeRecursively(node, missingPaths, satisfyingPaths, keyToPath, perfKey) { node.children.forEach((child, key) => { const path = keyToPath(key); const isSignalPath = isSignalDependency(path, perfKey) || isSignalDependency(key, perfKey); const hasValueAccess = path.endsWith('.value') || key.endsWith('.value'); const isAnySignalType = isSignalPath || hasValueAccess; if (child.isSatisfiedRecursively) { if (child.isSubtreeUsed || child.isUsed) { satisfyingPaths.add(path); } if (isAnySignalType) { return; } } else if (child.isUsed || (isAnySignalType && child.isSubtreeUsed) || (!isAnySignalType && child.isSubtreeUsed && child.children.size === 0)) { // If a root signal base is missing but its .value is used, prefer not to add the base if (!path.includes('.') && isSignalDependency(path, perfKey)) { const valueChild = child.children.get('value'); if (valueChild && (valueChild.isUsed || valueChild.isSubtreeUsed)) { return; } } missingPaths.add(path); return; } scanTreeRecursively(child, missingPaths, satisfyingPaths, (childKey) => `${path}.${childKey}`, perfKey); }); } function getWarningMessage(deps, singlePrefix, label, fixVerb, optionalChains, dependencies) { if (deps.size === 0) { return null; } return `${(deps.size > 1 ? '' : `${singlePrefix} `) + label} ${deps.size > 1 ? 'dependencies' : 'dependency'}: ${joinEnglish(Array.from(deps) .sort() .map((name) => { return `'${getObservedFormatted(name, optionalChains, dependencies)}'`; }))}. Either ${fixVerb} ${deps.size > 1 ? 'them' : 'it'} or remove the dependency array.`; } function collectRecommendations({ dependencies, declaredDependencies, stableDependencies, externalDependencies, isEffect, reactiveHookName, context, perfKey, }) { const depTree = createDepTree(); function createDepTree() { return { isUsed: false, isSatisfiedRecursively: false, isSubtreeUsed: false, children: new Map(), }; } dependencies.forEach(({ isStable, references }, key) => { if (isStable) { stableDependencies.add(key); } for (const reference of references) { if (reference.writeExpr) { const staleAssignments = new Set(); staleAssignments.add(key); if (getSeverity('useEffectEventInDependencyArray', context.options[0]) !== 'off') { context.report({ node: reference.writeExpr ?? reference.identifier, data: { eventName: key, hookName: reactiveHookName, }, messageId: 'useEffectEventInDependencyArray', suggest: [ { messageId: 'removeDependency', data: { dependency: key }, fix(fixer) { const [start, end] = reference.identifier.range; return fixer.removeRange([start, end + 1]); }, }, ], }); } } } }); dependencies.forEach((_, key) => { if (key.endsWith('.value')) { const signalName = key.slice(0, -6); if (isSignalDependency(signalName, perfKey)) { externalDependencies.delete(signalName); externalDependencies.delete(key); } } else if (key.includes('.value[')) { const valueIndex = key.indexOf('.value['); if (valueIndex !== -1) { const signalName = key.slice(0, valueIndex); if (isSignalDependency(signalName, perfKey)) { externalDependencies.delete(signalName); externalDependencies.delete(key); } } } else if (isSignalDependency(key, perfKey)) { externalDependencies.delete(key); } }); dependencies.forEach((_, key) => { if (isSignalDependency(key, perfKey)) { return; } // Skip dynamic computed members only when the base is a signal. // Always allow static numeric indices like arr[0]. For non-signal bases, allow dynamic too. if (key.includes('[') && key.includes(']') && !hasOnlyNumericComputed(key)) { const base = key.split('.')[0] ?? ''; if (base !== '' && isSignalDependency(base, perfKey)) { return; } } const parts = key.split('.'); if (parts.length > 2 && parts[1] === 'value') { const signalName = parts[0]; if (typeof signalName === 'undefined') { return; } if (!isSignalDependency(signalName, perfKey)) { return; } const dependency = dependencies.get(key); const isAssignmentOnly = dependency && dependency.hasReads === false; const hasInnerScopeComputedProperty = dependency && dependency.hasInnerScopeComputedProperty === true; if (!declaredDependencies.some(({ key: depKey }) => depKey === `${signalName}.value`) && (isAssignmentOnly === true || hasInnerScopeComputedProperty === true)) { dependencies.delete(key); } } }); dependencies.forEach((_, key) => { getOrCreateNodeByPath(depTree, key).isUsed = true; markAllParentsByPath(depTree, key, (parent) => { parent.isSubtreeUsed = true; }); }); for (const { key } of declaredDependencies) { // Do not normalize optional chaining here; tree splitting handles both '.' and '?.' getOrCreateNodeByPath(depTree, key).isSatisfiedRecursively = true; // Also mark all parents as satisfied to avoid reporting intermediate segments as missing markAllParentsByPath(depTree, key, (parent) => { parent.isSatisfiedRecursively = true; }); } for (const key of stableDependencies) { getOrCreateNodeByPath(depTree, key).isSatisfiedRecursively = true; } function getOrCreateNodeByPath(rootNode, path) { const keys = splitPathMembers(path); let node = rootNode; for (const key of keys) { let child = node.children.get(key); if (!child) { child = createDepTree(); node.children.set(key, child); } node = child; } return node; } function markAllParentsByPath(rootNode, path, fn) { const keys = splitPathMembers(path); let node = rootNode; for (const key of keys) { const child = node.children.get(key); if (!child) { return; } fn(child); node = child; } } const importedSignals = new Set(); dependencies.forEach((_, key) => { if (key.endsWith('.value')) { const signalName = key.slice(0, -6); if (isSignalDependency(signalName, perfKey)) { importedSignals.add(key); externalDependencies.delete(signalName); externalDependencies.delete(key); } } else if (key.includes('.value[')) { const valueIndex = key.indexOf('.value['); if (valueIndex !== -1) { const signalName = key.slice(0, valueIndex); if (isSignalDependency(signalName, perfKey)) { importedSignals.add(key); externalDependencies.delete(signalName); externalDependencies.delete(key); } } } else if (isSignalDependency(key, perfKey)) { const valueKey = `${key}.value`; const isBaseValueDeclared = declaredDependencies.some(({ key: depKey }) => { return depKey === valueKey; }); const dependency = dependencies.get(valueKey); const isAssignmentOnly = dependency && dependency.hasReads === false; const hasInnerScopeComputedProperty = dependency && dependency.hasInnerScopeComputedProperty === true; if (!isBaseValueDeclared && (isAssignmentOnly === true || hasInnerScopeComputedProperty === true)) { dependencies.delete(valueKey); } const node = getOrCreateNodeByPath(depTree, key); node.isSatisfiedRecursively = true; externalDependencies.delete(key); } }); dependencies.forEach((_, key) => { if (isSignalDependency(key, perfKey)) { return; } // Skip dynamic computed members only when the base is a signal. // Always allow static numeric indices like arr[0]. For non-signal bases, allow dynamic too. if (key.includes('[') && key.includes(']') && !hasOnlyNumericComputed(key)) { const base = key.split('.')[0] ?? ''; if (base !== '' && isSignalDependency(base, perfKey)) { return; } } const parts = key.split('.'); if (parts.length > 2 && parts[1] === 'value') { const signalName = parts[0]; if (typeof signalName !== 'string') { return; } if (!isSignalDependency(signalName, perfKey)) { return; } const baseValueKey = `${signalName}.value`; const isBaseValueDeclared = declaredDependencies.some(({ key: depKey }) => { return depKey === baseValueKey; }); const dependency = dependencies.get(key); const isAssignmentOnly = dependency && dependency.hasReads === false; const hasInnerScopeComputedProperty = dependency && dependency.hasInnerScopeComputedProperty === true; if (!isBaseValueDeclared && (isAssignmentOnly === true || hasInnerScopeComputedProperty === true)) { dependencies.delete(key); } } }); const missingDependencies = new Set(); const satisfyingDependencies = new Set(); dependencies.forEach((_, key) => { if (key.endsWith('.value')) { const signalName = key.slice(0, -6); if (isSignalDependency(signalName, perfKey)) { const node = getOrCreateNodeByPath(depTree, signalName); node.isSatisfiedRecursively = true; externalDependencies.delete(signalName); externalDependencies.delete(key); } } else if (key.includes('.value[')) { const valueIndex = key.indexOf('.value['); if (valueIndex !== -1) { const signalName = key.slice(0, valueIndex); if (isSignalDependency(signalName, perfKey)) { const node = getOrCreateNodeByPath(depTree, signalName); node.isSatisfiedRecursively = true; externalDependencies.delete(signalName); externalDependencies.delete(key); } } } else if (isSignalDependency(key, perfKey)) { const node = getOrCreateNodeByPath(depTree, key); node.isSatisfiedRecursively = true; externalDependencies.delete(key); } }); dependencies.forEach((_, key) => { if (!isSignalDependency(key, perfKey)) { if (key.includes('[') && key.includes(']')) { return; } const parts = key.split('.'); if (parts.length > 2 && parts[1] === 'value') { const signalName = parts[0]; if (typeof signalName !== 'string') { return; } if (!isSignalDependency(signalName, perfKey)) { return; } const baseValueKey = `${signalName}.value`; const isBaseValueDeclared = declaredDependencies.some(({ key: depKey }) => { return depKey === baseValueKey; }); const dependency = dependencies.get(key); const isAssignmentOnly = dependency && dependency.hasReads === false; const hasInnerScopeComputedProperty = dependency && dependency.hasInnerScopeComputedProperty === true; if (!isBaseValueDeclared && (isAssignmentOnly === true || hasInnerScopeComputedProperty === true)) { dependencies.delete(key); } } } }); const declaredSignals = new Set(); declaredDependencies.forEach(({ key }) => { if (key.endsWith('.value')) { const signalName = key.slice(0, -6); if (isSignalDependency(signalName, perfKey)) { declaredSignals.add(signalName); } } else if (key.includes('.value[')) { const valueIndex = key.indexOf('.value['); if (valueIndex !== -1) { const signalName = key.slice(0, valueIndex); if (isSignalDependency(signalName, perfKey)) { declaredSignals.add(signalName); } } } else if (isSignalDependency(key, perfKey)) { declaredSignals.add(key); } }); importedSignals.forEach((signal) => { if (signal.includes('.value[')) { const isComputedPropertyDeclared = declaredDependencies.some(({ key: depKey }) => { return depKey === signal; }); if (!isComputedPropertyDeclared) { const dependency = dependencies.get(signal); // const hasInnerScopeComputedProperty = // dependency && dependency.hasInnerScopeComputedProperty === true; const isAssignmentOnly = dependency && dependency.hasReads === false; // If it's a read, require it even if it involves inner-scope computed property if (isAssignmentOnly !== true) { missingDependencies.add(signal); } const valueIndex = signal.indexOf('.value['); if (valueIndex !== -1) { const signalName = signal.slice(0, valueIndex); if (!isSignalDependency(signalName, perfKey)) { return; } const valueNode = getOrCreateNodeByPath(depTree, signalName); valueNode.isUsed = true; valueNode.isSubtreeUsed = true; if (signal.includes('.', valueIndex + 7)) { const baseSignalSatisfied = getOrCreateNodeByPath(depTree, signalName); baseSignalSatisfied.isSatisfiedRecursively = true; } } } return; } else if (signal.endsWith('.value')) { const isValueDeclared = declaredDependencies.some(({ key }) => { return key === signal; }); if (!isValueDeclared) { const dependency = dependencies.get(signal); // const hasInnerScopeComputedProperty = // dependency && dependency.hasInnerScopeComputedProperty === true; const isAssignmentOnly = dependency && dependency.hasReads === false; // If it's a read of .value, require it regardless of inner-scope computed property if (isAssignmentOnly !== true) { missingDependencies.add(signal); } const valueNode = getOrCreateNodeByPath(depTree, signal); valueNode.isUsed = true; valueNode.isSubtreeUsed = true; } return; } const valueAccessKey = `${signal}.value`; const hasValueAccess = dependencies.has(valueAccessKey); if (hasValueAccess) { const isValueDeclared = declaredDependencies.some(({ key }) => { return key === valueAccessKey || key.startsWith(`${valueAccessKey}[`); }); if (!isValueDeclared) { // Check if this dependency is only used for assignments const dependency = dependencies.get(valueAccessKey); // const hasInnerScopeComputedProperty = // dependency && dependency.hasInnerScopeComputedProperty === true; const isAssignmentOnly = dependency && dependency.hasReads === false; // Require base .value when it's read, even if there are inner-scope computed members if (isAssignmentOnly !== true) { missingDependencies.add(valueAccessKey); } const valueNode = getOrCreateNodeByPath(depTree, valueAccessKey); valueNode.isUsed = true; valueNode.isSubtreeUsed = true; } return; } const node = getOrCreateNodeByPath(depTree, signal); node.isUsed = true; node.isSubtreeUsed = true; const isDeclared = declaredDependencies.some(({ key }) => { return key === signal; }) || declaredSignals.has(signal); if (!isDeclared) { const dependency = dependencies.get(signal); const isAssignmentOnly = dependency && (dependency.hasReads === false || dependency.isComputedAssignmentOnly === true); let hasAssignmentOnlyComputedMembers = false; let hasDeepPropertyChains = false; if (isSignalDependency(signal, perfKey)) { const deepPropertyChains = Array.from(dependencies.keys()).filter((key) => { return key.startsWith(`${signal}.value[`) && key.includes('.neighbors.'); }); hasDeepPropertyChains = deepPropertyChains.length > 0; const computedMembers = Array.from(dependencies.keys()).filter((key) => { return key.startsWith(`${signal}.value[`); }); hasAssignmentOnlyComputedMembers = computedMembers.every((key) => { const dep = dependencies.get(key); return typeof dep !== 'undefined' && dep.hasReads === false; }); } if (isAssignmentOnly !== true && hasAssignmentOnlyComputedMembers !== true && !hasDeepPropertyChains) { missingDependencies.add(signal); } } }); dependencies.forEach((depInfo, index) => { if (index.includes('.value[') && isSignalDependency(index.split('.')[0] ?? '', perfKey)) { const valueIndex = index.indexOf('.value['); if (valueIndex !== -1) { const isComputedPropertyDeclared = declaredDependencies.some(({ key }) => key === index); if (!isComputedPropertyDeclared && depInfo.hasInnerScopeComputedProperty !== true && depInfo.hasReads !== false) { missingDependencies.add(index); } } } }); const depDump = Array.from(dependencies.entries()).map(([k, v]) => { return { key: k, hasReads: v.hasReads === true, isStable: v.isStable === true, isSignal: isSignalDependency(k, perfKey), }; }); const bases = new Set(depDump .map((d) => { return d.key.split('.')[0]; }) .filter(Boolean)); const treeDump = {}; bases.forEach((b) => { const node = getOrCreateNodeByPath(depTree, b); // eslint-disable-next-line security/detect-object-injection treeDump[b] = Array.from(node.children.keys()); }); scanTreeRecursively(depTree, missingDependencies, satisfyingDependencies, (key) => { return key; }, perfKey); // Drop false positives: if a missing path is already declared (optionals treated as equal) if (missingDependencies.size > 0 && declaredDependencies.length > 0) { for (const m of Array.from(missingDependencies)) { if (declaredDependencies.some(({ key }) => pathsEquivalent(key, m))) { missingDependencies.delete(m); } } } // Prune missing dependencies that are unsafe to suggest: // - dynamic/computed indexing (e.g., foo.value[bar]) // - dependencies marked with hasInnerScopeComputedProperty { const toRemove = []; missingDependencies.forEach((m) => { // Keep static numeric indices like arr[0]. For dynamic indices, only drop when base is a signal. if (m.includes('[') && !hasOnlyNumericComputed(m)) { const base = m.split('.')[0] ?? ''; if (base !== '' && isSignalDependency(base, perfKey)) { toRemove.push(m); return; } } const dep = dependencies.get(m); if (dep && dep.hasInnerScopeComputedProperty === true) { const base = m.split('.')[0]; if (typeof base === 'string' && base !== '' && isSignalDependency(base, perfKey)) { toRemove.push(m); } } }); for (const m of toRemove) { missingDependencies.delete(m); } } // Accumulators for dependency recommendations const suggestedDependencies = []; const addedPaths = new Set(); const addedRootPaths = new Set(); for (const base of Array.from(missingDependencies)) { if (!base.includes('.') && !isSignalDependency(base, perfKey)) { const hasDeeperMissing = Array.from(missingDependencies).some((d) => { return d.startsWith(`${base}.`); }); const hasAnyDeclaredDeeper = declaredDependencies.some(({ key }) => { return key.startsWith(`${base}.`); }); if (hasDeeperMissing || hasAnyDeclaredDeeper) { const hasExistingDeepMissing = Array.from(missingDependencies).some((m) => { return m.startsWith(`${base}.`); }); if (!hasExistingDeepMissing) { const deepReadKeys = []; dependencies.forEach((depInfo, depKey) => { if (depKey.startsWith(`${base}.`) && depInfo.hasReads === true && depInfo.hasInnerScopeComputedProperty !== true && // allow static numeric indices like base[0].x; for dynamic, allow if base is not a signal !(depKey.includes('[') && !hasOnlyNumericComputed(depKey) && (() => { const root = depKey.split('.')[0]; return (typeof root === 'string' && root !== '' && isSignalDependency(root, perfKey)); })())) { deepReadKeys.push(depKey); } }); for (const dk of deepReadKeys) { if (!addedPaths.has(dk) && !addedRootPaths.has(dk)) { suggestedDependencies.push(dk); addedPaths.add(dk); const dkRoot = dk.split('.')[0]; if (dk.includes('.') && typeof dkRoot === 'string' && dkRoot !== '') { addedRootPaths.add(dkRoot); } } } } missingDependencies.delete(base); } } } /* suggestedDependencies declared above */ const unnecessaryDependencies = new Set(); const duplicateDependencies = new Set(); const incompleteDependencies = new Set(); const redundantDependencies = new Set(); const declaredDepsMap = new Map(); declaredDependencies.forEach(({ key }) => { declaredDepsMap.set(key, true); }); for (const { key } of declaredDependencies) { if (!isSignalDependency(key, perfKey) && key.includes('.')) { const baseKey = key.replace(/\?\./g, '.'); const depInfo = dependencies.get(baseKey); const node = getOrCreateNodeByPath(depTree, baseKey); const isUsed = (typeof depInfo !== 'undefined' && depInfo.hasReads) || node.isUsed || node.isSubtreeUsed; if (isUsed) { satisfyingDependencies.add(key); // Also satisfy the base key used internally by the dep tree so it doesn't get marked missing satisfyingDependencies.add(baseKey); } } } declaredDependencies.forEach(({ key }) => { if (isSignalDependency(key, perfKey) && !key.includes('.')) { const valueKey = `${key}.value`; if (missingDependencies.has(valueKey)) { incompleteDependencies.add(key); satisfyingDependencies.delete(key); } else if (declaredDepsMap.has(valueKey)) { redundantDependencies.add(key); satisfyingDependencies.delete(key); } } if (key.includes('.value[') && isSignalDependency(key.split('.')[0] ?? '', perfKey)) { const valueIndex = key.indexOf('.value['); if (valueIndex !== -1) { const closingBracket = key.indexOf(']', valueIndex); if (closingBracket !== -1) { const baseComputedExpression = key.slice(0, closingBracket + 1); if (declaredDepsMap.has(baseComputedExpression) && baseComputedExpression !== key) { const isDeepPropertyChain = key.length > baseComputedExpression.length && key.charAt(closingBracket + 1) === '.'; if (isDeepPropertyChain) { const hasOtherDeepPropertyChains = Array.from(declaredDepsMap.keys()).some((declaredKey) => { return (declaredKey.startsWith(baseComputedExpression) && declaredKey !== baseComputedExpression && declaredKey !== key); }); if (hasOtherDeepPropertyChains) { // Only mark as redundant if the base computed expression is not directly read const baseDependency = dependencies.get(baseComputedExpression); const isDirectlyUsed = baseDependency && baseDependency.hasReads === true; if (isDirectlyUsed !== true) { redundantDependencies.add(baseComputedExpression); satisfyingDependencies.delete(baseComputedExpression); } } } } } } } if (!key.includes('.') && !isSignalDependency(key, perfKey)) { const hasDeepPropertyUsage = Array.from(missingDependencies).some((dep) => { return dep.startsWith(`${key}.`) && dep !== key; }); if (hasDeepPropertyUsage) { // If the base is directly used (e.g., in guards like `if (x == null || x === 'loading') return ...`), // allow listing the base dependency without requiring deep property chains. const baseDependency = dependencies.get(key); if ((typeof baseDependency !== 'undefined' && baseDependency.hasReads === true) !== true) { incompleteDependencies.add(key); satisfyingDependencies.delete(key); } } else if (Array.from(declaredDepsMap.keys()).some((declaredKey) => { return declaredKey.startsWith(`${key}.`) && declaredKey !== key; })) { // Check if the base dependency is directly used (not just through deeper properties) const baseDependency = dependencies.get(key); if (!(typeof baseDependency !== 'undefined' && baseDependency.hasReads === true)) { redundantDependencies.add(key); satisfyingDependencies.delete(key); } } } }); declaredDependencies.forEach(({ key }) => { if (satisfyingDependencies.has(key)) { if (suggestedDependencies.includes(key)) { duplicateDependencies.add(key); } else { suggestedDependencies.push(key); } return; } const isSignalDep = isSignalDependency(key, perfKey) || key.endsWith('.value'); const dependency = dependencies.get(key); const isAssignmentOnly = isSignalDep && dependency && dependency.hasReads === false; if (isAssignmentOnly === true) { unnecessaryDependencies.add(key); } else if (incompleteDependencies.has(key) || redundantDependencies.has(key)) { unnecessaryDependencies.add(key); } else if ((isEffect && !key.endsWith('.current') && !externalDependencies.has(key)) || isSignalDep || (!isEffect && !externalDependencies.has(key))) { if (!suggestedDependencies.includes(key)) { suggestedDependencies.push(key); } } else { unnecessaryDependencies.add(key); } }); if (missingDependencies.size > 0) { const toDelete = []; missingDependencies.forEach((m) => { if (!m.includes('.')) { return; } const base = m.split('.')[0] ?? ''; if (base === '' || isSignalDependency(base, perfKey)) { return; } const baseDep = dependencies.get(base); const isBaseDeclared = declaredDepsMap.has(base); if (isBaseDeclared && baseDep && baseDep.hasReads === true) { toDelete.push(m); satisfyingDependencies.add(base); incompleteDependencies.delete(base); } }); for (const m of toDelete) { missingDependencies.delete(m); } } const missingDepsArray = Array.from(missingDependencies); missingDepsArray.sort((a, b) => { return b.length - a.length; }); const baseMissing = Array.from(missingDependencies).filter((m) => { return !m.includes('.') && !isSignalDependency(m, perfKey); }); if (baseMissing.length > 0) { // Helper to collect leaf usage from dep tree const collectUsedLeaves = (node, path, acc) => { if (node.children.size === 0) { acc.push({ path, isUsed: node.isUsed === true, isSubtreeUsed: node.isSubtreeUsed === true, }); return; } node.children.forEach((child, key) => { collectUsedLeaves(child, `${path}.${key}`, acc); }); }; for (const base of baseMissing) { const deepReads = []; dependencies.forEach((dep, key) => { if (key.startsWith(`${base}.`) && dep.hasReads === true) deepReads.push(key); }); const leafUsage = []; const baseNode = getOrCreateNodeByPath(depTree, base); baseNode.children.forEach((child, key) => { collectUsedLeaves(child, `${base}.${key}`, leafUsage); }); } } for (const key of missingDepsArray) { const rootPath = key.split('.')[0]; const isChildPath = key.includes('.'); if (!isSignalDependency(key, perfKey) && !key.endsWith('.value') && !isChildPath && declaredDependencies.some(({ key: depKey }) => { return depKey.startsWith(`${key}.`); }) && missingDepsArray.some((dep) => { return dep.startsWith(`${key}.`); })) { const hasExistingDeepMissing = missingDepsArray.some((m) => { return m.startsWith(`${key}.`); }); if (!hasExistingDeepMissing) { const deepReadKeys = []; dependencies.forEach((depInfo, depKey) => { if (depKey.startsWith(`${key}.`) && depInfo.hasReads === true) { deepReadKeys.push(depKey); } }); for (const dk of deepReadKeys) { if (!addedPaths.has(dk) && !addedRootPaths.has(dk)) { suggestedDependencies.push(dk); addedPaths.add(dk); const dkRoot = dk.split('.')[0]; if (dk.includes('.') && typeof dkRoot === 'string' && dkRoot !== '') { addedRootPaths.add(dkRoot); } } } } continue; } if (key.endsWith('.value')) { const signalName = key.slice(0, -6); if (isSignalDependency(signalName, perfKey)) { addedRootPaths.add(signalName); suggestedDependencies.push(key); addedPaths.add(key); continue; } } // Avoid suggesting any dynamic or inner-scope-computed dependency const keyDep = dependencies.get(key); if (!addedPaths.has(key) && !addedRootPaths.has(key) && // allow static numeric indices like base[0].x; for dynamic, allow if base is not a signal !(key.includes('[') && !hasOnlyNumericComputed(key) && (() => { const root = key.split('.')[0]; return typeof root === 'string' && root !== '' && isSignalDependency(root, perfKey); })()) && !(keyDep && keyDep.hasInnerScopeComputedProperty === true)) { suggestedDependencies.push(key); addedPaths.add(key); if (isChildPath && typeof rootPath === 'string' && rootPath !== '') { addedRootPaths.add(rootPath); } } } // Normalize: replace non-signal base suggestions with deep properties if present { const toRemove = new Set(); const toAdd = []; for (const s of suggestedDependencies) { if (!s.includes('.') && !s.endsWith('.value') && !isSignalDependency(s, perfKey)) { const deepMissingDependencies = Array.from(missingDependencies).filter((m) => { return m.startsWith(`${s}.`); }); const deepReads = []; if (deepMissingDependencies.length === 0) { dependencies.forEach((depInfo, depKey) => { if (depKey.startsWith(`${s}.`) && depInfo.hasReads === true && depInfo.hasInnerScopeComputedProperty !== true && // allow static numeric indices like base[0].x; for dynamic, allow if base is not a signal !(depKey.includes('[') && !hasOnlyNumericComputed(depKey) && (() => { const root = depKey.split('.')[0]; return (typeof root === 'string' && root !== '' && isSignalDependency(root, perfKey)); })())) { deepReads.push(depKey); } }); } const replacements = deepMissingDependencies.length > 0 ? deepMissingDependencies : deepReads; if (replacements.length > 0) { toRemove.add(s); for (const r of replacements) { if (!suggestedDependencies.includes(r)) { toAdd.push(r); } } } } } if (toRemove.size > 0 || toAdd.length > 0) { const filtered = suggestedDependencies.filter((s) => { return !toRemove.has(s); }); suggestedDependencies.length = 0; suggestedDependencies.push(...filtered, ...toAdd); } } return { suggestedDependencies, unnecessaryDependencies, duplicateDependencies, missingDependencies, }; } function getConstructionExpressionType(node) { switch (node.type) { case AST_NODE_TYPES.ObjectExpression: { return 'object'; } case AST_NODE_TYPES.ArrayExpression: { return 'array'; } case AST_NODE_TYPES.ArrowFunctionExpression: case AST_NODE_TYPES.FunctionExpression: { return 'function'; } case AST_NODE_TYPES.ClassExpression: { return 'class'; } case AST_NODE_TYPES.ConditionalExpression: { if (getConstructionExpressionType(node.consequent) != null || getConstructionExpressionType(node.alternate) != null) { return 'conditional'; } return null; } case AST_NODE_TYPES.LogicalExpression: { if (getConstructionExpressionType(node.left) != null || getConstructionExpressionType(node.right) != null) { return '